From a5a8c49993464f7c86b78b28ba5c1f191ef10ca8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 19:17:21 +0900 Subject: [PATCH 1/3] test(sensitive): deny unrelated model conversation history --- .../tests/sensitive_model_invocation.rs | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/crates/originweave-policy/tests/sensitive_model_invocation.rs b/crates/originweave-policy/tests/sensitive_model_invocation.rs index ecfffc35..89de8ae6 100644 --- a/crates/originweave-policy/tests/sensitive_model_invocation.rs +++ b/crates/originweave-policy/tests/sensitive_model_invocation.rs @@ -1,11 +1,11 @@ #![allow(clippy::expect_used)] -//! Fail-closed prompt, output-schema, token-budget, and expiry contracts for sensitive model use. +//! Fail-closed prompt, output-schema, token-budget, expiry, and context-isolation 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, finite input/output token budgets, and -//! one exclusive invocation-policy expiry before a trusted broker/orchestrator may consider a model -//! invocation. +//! one reviewed prompt contract, one reviewed output schema, finite input/output token budgets, one +//! exclusive invocation-policy expiry, and an explicit absence of unrelated conversation history +//! before a trusted broker/orchestrator may consider a model invocation. use originweave_core::Origin; use originweave_policy::{ @@ -50,12 +50,21 @@ fn route_scope() -> ModelRouteScope { } fn request(input_tokens: u32, output_tokens: u32) -> ModelInvocationRequest { + request_with_unrelated_history(input_tokens, output_tokens, 0) +} + +fn request_with_unrelated_history( + input_tokens: u32, + output_tokens: u32, + unrelated_history_items: u32, +) -> ModelInvocationRequest { ModelInvocationRequest::new( route_request("provider-private"), "case-resolution-prompt-v1", "customer-email-summary-v1", input_tokens, output_tokens, + unrelated_history_items, ) } @@ -75,7 +84,7 @@ fn scope( } #[test] -fn exact_route_prompt_schema_bounded_tokens_and_fresh_policy_are_authorized() { +fn exact_route_prompt_schema_bounded_tokens_fresh_policy_and_isolated_context_are_authorized() { assert_eq!( evaluate_model_invocation(&request(4_096, 1_024), &scope(8_192, 2_048, 1_000), 999), ModelInvocationDecision::Authorized @@ -83,13 +92,14 @@ fn exact_route_prompt_schema_bounded_tokens_and_fresh_policy_are_authorized() { } #[test] -fn route_denial_remains_distinct_from_invocation_policy_mismatch_or_expiry() { +fn route_denial_remains_distinct_from_invocation_policy_mismatch_expiry_or_history() { let request = ModelInvocationRequest::new( route_request("provider-other"), "case-resolution-prompt-v1", "customer-email-summary-v1", 4_096, 1_024, + 1, ); assert_eq!( @@ -106,6 +116,7 @@ fn prompt_and_output_schema_contracts_are_exact() { "customer-email-summary-v1", 4_096, 1_024, + 0, ); let wrong_schema = ModelInvocationRequest::new( route_request("provider-private"), @@ -113,6 +124,7 @@ fn prompt_and_output_schema_contracts_are_exact() { "different-schema-v2", 4_096, 1_024, + 0, ); for candidate in [wrong_prompt, wrong_schema] { @@ -157,6 +169,7 @@ fn malformed_prompt_or_schema_policy_identifiers_fail_closed() { "customer-email-summary-v1", 4_096, 1_024, + 0, ), ModelInvocationRequest::new( route_request("provider-private"), @@ -164,6 +177,7 @@ fn malformed_prompt_or_schema_policy_identifiers_fail_closed() { malformed, 4_096, 1_024, + 0, ), ]; for candidate in candidates { @@ -218,6 +232,20 @@ fn invocation_policy_expiry_is_exclusive_and_fail_closed() { ); } +#[test] +fn unrelated_conversation_history_is_never_admitted_for_sensitive_model_disclosure() { + for unrelated_history_items in [1, 2, u32::MAX] { + assert_eq!( + evaluate_model_invocation( + &request_with_unrelated_history(4_096, 1_024, unrelated_history_items), + &scope(8_192, 2_048, 1_000), + 999, + ), + ModelInvocationDecision::UnrelatedConversationHistoryDenied + ); + } +} + #[test] fn zero_expiry_is_invalid_but_maximum_epoch_remains_representable() { assert_eq!( @@ -232,4 +260,4 @@ fn zero_expiry_is_invalid_but_maximum_epoch_remains_representable() { ), ModelInvocationDecision::Authorized ); -} +} \ No newline at end of file From 4dbddd4ce4e3101d16a9e14ed3d4fedb84711440 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 19:20:47 +0900 Subject: [PATCH 2/3] test(sensitive): format context-isolation contract --- crates/originweave-policy/tests/sensitive_model_invocation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-policy/tests/sensitive_model_invocation.rs b/crates/originweave-policy/tests/sensitive_model_invocation.rs index 89de8ae6..cfad63c2 100644 --- a/crates/originweave-policy/tests/sensitive_model_invocation.rs +++ b/crates/originweave-policy/tests/sensitive_model_invocation.rs @@ -260,4 +260,4 @@ fn zero_expiry_is_invalid_but_maximum_epoch_remains_representable() { ), ModelInvocationDecision::Authorized ); -} \ No newline at end of file +} From 0ec604deb1c0293008560e0fcd4af7ccb65d93ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 20:04:09 +0900 Subject: [PATCH 3/3] feat(sensitive): deny unrelated model history --- crates/originweave-policy/src/model_route.rs | 36 ++++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/crates/originweave-policy/src/model_route.rs b/crates/originweave-policy/src/model_route.rs index e2a89cb3..bb19a1d1 100644 --- a/crates/originweave-policy/src/model_route.rs +++ b/crates/originweave-policy/src/model_route.rs @@ -4,8 +4,9 @@ //! 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, derive actual runtime identities from trusted configuration, and -//! supply invocation time from the same authoritative time domain used to issue policy expiry. +//! the permitted value form, derive actual runtime identities from trusted configuration, derive +//! context-isolation metadata from the actual bounded outgoing message set, and supply invocation +//! time from the same authoritative time domain used to issue policy expiry. use crate::sensitive_data::{ DisclosureDecision, DisclosureScope, SensitiveDataAuthority, SensitiveDataRequest, @@ -153,10 +154,12 @@ 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, token budgets, and policy lifetime are authorized. + /// Exact route, isolated context, prompt/schema contracts, token budgets, and policy lifetime are authorized. Authorized, /// Route admission failed before invocation-specific policy could authorize the request. RouteDenied(ModelRouteDecision), + /// The broker detected unrelated conversation history in the sensitive invocation context. + UnrelatedConversationHistoryDenied, /// Prompt/schema metadata, token budgets, or the reviewed expiry are malformed or out of scope. InvocationPolicyMismatch, /// The otherwise valid invocation policy is no longer fresh at the caller-supplied trusted time. @@ -171,10 +174,15 @@ pub struct ModelInvocationRequest { output_schema_id: String, input_tokens: u32, output_tokens: u32, + unrelated_history_items: u32, } impl ModelInvocationRequest { /// Build one invocation request without authorizing protected-value disclosure or execution. + /// + /// `unrelated_history_items` must be derived by the trusted broker/orchestrator from the actual + /// bounded outgoing message set. A caller-provided zero alone is not proof of isolation; this + /// pure policy boundary only guarantees that a known nonzero count cannot be authorized. #[must_use] pub fn new( route: ModelRouteRequest, @@ -182,6 +190,7 @@ impl ModelInvocationRequest { output_schema_id: &str, input_tokens: u32, output_tokens: u32, + unrelated_history_items: u32, ) -> Self { Self { route, @@ -189,6 +198,7 @@ impl ModelInvocationRequest { output_schema_id: output_schema_id.to_owned(), input_tokens, output_tokens, + unrelated_history_items, } } } @@ -282,18 +292,20 @@ pub fn evaluate_model_route( } } -/// Evaluate reviewed prompt/schema, token limits, and lifetime after exact route admission. +/// Evaluate reviewed context isolation, prompt/schema, token limits, and lifetime 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, request budgets no larger than the reviewed maxima, and a nonzero exclusive expiry. -/// After those static checks pass, `trusted_time >= valid_until` returns +/// [`ModelInvocationDecision::RouteDenied`]. Once the exact route is admitted, any broker-derived +/// nonzero unrelated-history count fails closed as +/// [`ModelInvocationDecision::UnrelatedConversationHistoryDenied`]. Invocation policy then requires +/// bounded 1–128 byte ASCII prompt/schema identifiers, exact identifier matches, nonzero requested +/// and trusted token budgets, request budgets no larger than the reviewed maxima, and a nonzero +/// exclusive expiry. After those static checks pass, `trusted_time >= valid_until` returns /// [`ModelInvocationDecision::InvocationExpired`]. The caller must source `trusted_time` from the /// same authoritative time domain used to issue `valid_until`; this pure policy function neither /// reads a clock nor attests clock provenance. Authorization remains metadata-only: it does not -/// disclose a protected value, invoke a provider, validate output, retain/export data, or select a -/// fallback route. +/// inspect messages, prove context isolation, 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, @@ -305,6 +317,10 @@ pub fn evaluate_model_invocation( return ModelInvocationDecision::RouteDenied(route_decision); } + if request.unrelated_history_items != 0 { + return ModelInvocationDecision::UnrelatedConversationHistoryDenied; + } + 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)