Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/originweave-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ mod model_route;
mod sensitive_data;

pub use model_route::{
ModelRouteDecision, ModelRouteRequest, ModelRouteScope, evaluate_model_route,
ModelInvocationDecision, ModelInvocationRequest, ModelInvocationScope, ModelRouteDecision,
ModelRouteRequest, ModelRouteScope, evaluate_model_invocation, evaluate_model_route,
};
pub use sensitive_data::{
DataClassification, DisclosureDecision, DisclosureScope, HandleRevocationReason,
Expand Down
121 changes: 116 additions & 5 deletions crates/originweave-policy/src/model_route.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
//! Exact provider/model/region/retention/training/subprocessor/export route admission for sensitive-data workflows.
//!
//! This module evaluates route metadata only. An [`ModelRouteDecision::Authorized`] result does
//! not authorize disclosure of a protected value, authenticate a provider, prove the provider's
//! physical region, invoke a model, or choose a fallback. A trusted broker/orchestrator must
//! independently authorize the permitted value form and derive the actual route identity from
//! trusted runtime configuration.
//! This module evaluates route and invocation metadata only. An [`ModelRouteDecision::Authorized`]
//! or [`ModelInvocationDecision::Authorized`] result does not authorize disclosure of a protected
//! value, authenticate a provider, prove the provider's physical region, invoke a model, validate
//! model output, or choose a fallback. A trusted broker/orchestrator must independently authorize
//! the permitted value form and derive actual runtime identities from trusted configuration.

use crate::sensitive_data::{
DisclosureDecision, DisclosureScope, SensitiveDataAuthority, SensitiveDataRequest,
Expand Down Expand Up @@ -149,6 +149,80 @@ impl ModelRouteScope {
}
}

/// Result of composing exact route admission with one reviewed model-invocation policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelInvocationDecision {
/// Exact route, prompt/schema contracts, and token budgets are authorized.
Authorized,
/// Route admission failed before invocation-specific policy could authorize the request.
RouteDenied(ModelRouteDecision),
/// Prompt/schema metadata or token budgets are malformed or outside the reviewed scope.
InvocationPolicyMismatch,
}

/// One proposed model invocation after a route has been selected.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelInvocationRequest {
route: ModelRouteRequest,
prompt_contract_id: String,
output_schema_id: String,
input_tokens: u32,
output_tokens: u32,
}

impl ModelInvocationRequest {
/// Build one invocation request without authorizing protected-value disclosure or execution.
#[must_use]
pub fn new(
route: ModelRouteRequest,
prompt_contract_id: &str,
output_schema_id: &str,
input_tokens: u32,
output_tokens: u32,
) -> Self {
Self {
route,
prompt_contract_id: prompt_contract_id.to_owned(),
output_schema_id: output_schema_id.to_owned(),
input_tokens,
output_tokens,
}
}
}

/// Reviewed invocation-policy scope layered on one exact model-route scope.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelInvocationScope {
route: ModelRouteScope,
prompt_contract_id: String,
output_schema_id: String,
maximum_input_tokens: u32,
maximum_output_tokens: u32,
}

impl ModelInvocationScope {
/// Build trusted invocation policy for one prompt/schema pair and finite token maxima.
///
/// Identifiers and token maxima are validated during evaluation so malformed trusted policy
/// state remains fail-closed instead of becoming authority because request and scope match.
#[must_use]
pub fn new(
route: ModelRouteScope,
prompt_contract_id: &str,
output_schema_id: &str,
maximum_input_tokens: u32,
maximum_output_tokens: u32,
) -> Self {
Self {
route,
prompt_contract_id: prompt_contract_id.to_owned(),
output_schema_id: output_schema_id.to_owned(),
maximum_input_tokens,
maximum_output_tokens,
}
}
}

fn route_identifier_is_valid(identifier: &str) -> bool {
!identifier.is_empty()
&& identifier.len() <= MAX_ROUTE_IDENTIFIER_BYTES
Expand Down Expand Up @@ -199,3 +273,40 @@ pub fn evaluate_model_route(
ModelRouteDecision::Authorized
}
}

/// Evaluate reviewed prompt/schema and token limits after exact route admission.
///
/// Route admission remains a separate prerequisite and its failure is preserved in
/// [`ModelInvocationDecision::RouteDenied`]. Invocation policy then requires bounded 1–128 byte
/// ASCII prompt/schema identifiers, exact identifier matches, nonzero requested and trusted token
/// budgets, and request budgets no larger than the reviewed maxima. Authorization from this
/// function is metadata-only: it does not disclose a protected value, invoke a provider, validate
/// output, retain/export data, or select a fallback route.
#[must_use]
pub fn evaluate_model_invocation(
request: &ModelInvocationRequest,
scope: &ModelInvocationScope,
) -> ModelInvocationDecision {
let route_decision = evaluate_model_route(&request.route, &scope.route);
if route_decision != ModelRouteDecision::Authorized {
return ModelInvocationDecision::RouteDenied(route_decision);
}

if !route_identifier_is_valid(&request.prompt_contract_id)
|| !route_identifier_is_valid(&request.output_schema_id)
|| !route_identifier_is_valid(&scope.prompt_contract_id)
|| !route_identifier_is_valid(&scope.output_schema_id)
|| request.prompt_contract_id != scope.prompt_contract_id
|| request.output_schema_id != scope.output_schema_id
|| request.input_tokens == 0
|| request.output_tokens == 0
|| scope.maximum_input_tokens == 0
|| scope.maximum_output_tokens == 0
|| request.input_tokens > scope.maximum_input_tokens
|| request.output_tokens > scope.maximum_output_tokens
{
ModelInvocationDecision::InvocationPolicyMismatch
} else {
ModelInvocationDecision::Authorized
}
}
193 changes: 193 additions & 0 deletions crates/originweave-policy/tests/sensitive_model_invocation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#![allow(clippy::expect_used)]

//! Fail-closed prompt, output-schema, and token-budget contracts for sensitive model use.
//!
//! Route admission is a prerequisite, not disclosure authority. This contract additionally binds
//! one reviewed prompt contract, one reviewed output schema, and finite input/output token budgets
//! before a trusted broker/orchestrator may consider a model invocation.

use originweave_core::Origin;
use originweave_policy::{
DataClassification, ModelInvocationDecision, ModelInvocationRequest, ModelInvocationScope,
ModelRouteDecision, ModelRouteRequest, ModelRouteScope, SensitiveDataAuthority,
evaluate_model_invocation,
};

fn authority(destination: &str) -> SensitiveDataAuthority {
SensitiveDataAuthority::new(
"tenant-alpha",
"task-42",
"customer-email",
"case-resolution",
Origin::parse(destination).expect("valid destination origin"),
DataClassification::PersonalData,
)
}

fn route_request(provider_id: &str) -> ModelRouteRequest {
ModelRouteRequest::new(
authority("https://model-gateway.example"),
provider_id,
"model-reviewed-v1",
"kr-central",
"ephemeral-retention",
"no-training",
"subprocessors-reviewed-v1",
)
}

fn route_scope() -> ModelRouteScope {
ModelRouteScope::new(
authority("https://model-gateway.example"),
"provider-private",
"model-reviewed-v1",
"kr-central",
"ephemeral-retention",
"no-training",
"subprocessors-reviewed-v1",
)
}

fn request(input_tokens: u32, output_tokens: u32) -> ModelInvocationRequest {
ModelInvocationRequest::new(
route_request("provider-private"),
"case-resolution-prompt-v1",
"customer-email-summary-v1",
input_tokens,
output_tokens,
)
}

fn scope(maximum_input_tokens: u32, maximum_output_tokens: u32) -> ModelInvocationScope {
ModelInvocationScope::new(
route_scope(),
"case-resolution-prompt-v1",
"customer-email-summary-v1",
maximum_input_tokens,
maximum_output_tokens,
)
}

#[test]
fn exact_route_prompt_schema_and_bounded_tokens_are_authorized() {
assert_eq!(
evaluate_model_invocation(&request(4_096, 1_024), &scope(8_192, 2_048)),
ModelInvocationDecision::Authorized
);
}

#[test]
fn route_denial_remains_distinct_from_invocation_policy_mismatch() {
let request = ModelInvocationRequest::new(
route_request("provider-other"),
"case-resolution-prompt-v1",
"customer-email-summary-v1",
4_096,
1_024,
);

assert_eq!(
evaluate_model_invocation(&request, &scope(8_192, 2_048)),
ModelInvocationDecision::RouteDenied(ModelRouteDecision::RouteMismatch)
);
}

#[test]
fn prompt_and_output_schema_contracts_are_exact() {
let wrong_prompt = ModelInvocationRequest::new(
route_request("provider-private"),
"different-prompt-v2",
"customer-email-summary-v1",
4_096,
1_024,
);
let wrong_schema = ModelInvocationRequest::new(
route_request("provider-private"),
"case-resolution-prompt-v1",
"different-schema-v2",
4_096,
1_024,
);

for candidate in [wrong_prompt, wrong_schema] {
assert_eq!(
evaluate_model_invocation(&candidate, &scope(8_192, 2_048)),
ModelInvocationDecision::InvocationPolicyMismatch
);
}
}

#[test]
fn token_budgets_must_be_nonzero_and_within_reviewed_maxima() {
for candidate in [
request(0, 1_024),
request(4_096, 0),
request(8_193, 1_024),
request(4_096, 2_049),
] {
assert_eq!(
evaluate_model_invocation(&candidate, &scope(8_192, 2_048)),
ModelInvocationDecision::InvocationPolicyMismatch
);
}

for malformed_scope in [scope(0, 2_048), scope(8_192, 0)] {
assert_eq!(
evaluate_model_invocation(&request(4_096, 1_024), &malformed_scope),
ModelInvocationDecision::InvocationPolicyMismatch
);
}
}

#[test]
fn malformed_prompt_or_schema_policy_identifiers_fail_closed() {
let malformed_values = ["", "contains space", "🚫", &"x".repeat(129)];

for malformed in malformed_values {
let candidates = [
ModelInvocationRequest::new(
route_request("provider-private"),
malformed,
"customer-email-summary-v1",
4_096,
1_024,
),
ModelInvocationRequest::new(
route_request("provider-private"),
"case-resolution-prompt-v1",
malformed,
4_096,
1_024,
),
];
for candidate in candidates {
assert_eq!(
evaluate_model_invocation(&candidate, &scope(8_192, 2_048)),
ModelInvocationDecision::InvocationPolicyMismatch
);
}

let scopes = [
ModelInvocationScope::new(
route_scope(),
malformed,
"customer-email-summary-v1",
8_192,
2_048,
),
ModelInvocationScope::new(
route_scope(),
"case-resolution-prompt-v1",
malformed,
8_192,
2_048,
),
];
for malformed_scope in scopes {
assert_eq!(
evaluate_model_invocation(&request(4_096, 1_024), &malformed_scope),
ModelInvocationDecision::InvocationPolicyMismatch
);
}
}
}
Loading