From 8099a24093407ac0e69f9530d180da5f90e592fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 18:12:22 +0900 Subject: [PATCH 1/4] test(sensitive): require bounded model invocation policy --- .../tests/sensitive_model_invocation.rs | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 crates/originweave-policy/tests/sensitive_model_invocation.rs diff --git a/crates/originweave-policy/tests/sensitive_model_invocation.rs b/crates/originweave-policy/tests/sensitive_model_invocation.rs new file mode 100644 index 00000000..4f3c54f4 --- /dev/null +++ b/crates/originweave-policy/tests/sensitive_model_invocation.rs @@ -0,0 +1,188 @@ +#![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 + ); + } + } +} From 83f27c19e76d236b889aa446173f7aac12fffa8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 18:17:31 +0900 Subject: [PATCH 2/4] test(sensitive): apply canonical invocation formatting --- .../originweave-policy/tests/sensitive_model_invocation.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/originweave-policy/tests/sensitive_model_invocation.rs b/crates/originweave-policy/tests/sensitive_model_invocation.rs index 4f3c54f4..27b45a29 100644 --- a/crates/originweave-policy/tests/sensitive_model_invocation.rs +++ b/crates/originweave-policy/tests/sensitive_model_invocation.rs @@ -119,7 +119,12 @@ fn prompt_and_output_schema_contracts_are_exact() { #[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)] { + 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 From c60b9236d39b46189db9f2e1fce2cc129d66c940 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 18:21:30 +0900 Subject: [PATCH 3/4] feat(sensitive): bind reviewed model invocation policy --- crates/originweave-policy/src/model_route.rs | 121 ++++++++++++++++++- 1 file changed, 116 insertions(+), 5 deletions(-) diff --git a/crates/originweave-policy/src/model_route.rs b/crates/originweave-policy/src/model_route.rs index bb02847f..20423a04 100644 --- a/crates/originweave-policy/src/model_route.rs +++ b/crates/originweave-policy/src/model_route.rs @@ -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, @@ -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 @@ -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 + } +} From adb67f8de3e4828db14dfa0e2950b672b60709c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 18:23:43 +0900 Subject: [PATCH 4/4] feat(sensitive): expose model invocation policy --- crates/originweave-policy/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index 52290f9d..d0a54b70 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -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,