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
36 changes: 26 additions & 10 deletions crates/originweave-policy/src/model_route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -171,24 +174,31 @@ 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,
prompt_contract_id: &str,
output_schema_id: &str,
input_tokens: u32,
output_tokens: u32,
unrelated_history_items: u32,
) -> Self {
Self {
route,
prompt_contract_id: prompt_contract_id.to_owned(),
output_schema_id: output_schema_id.to_owned(),
input_tokens,
output_tokens,
unrelated_history_items,
}
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
40 changes: 34 additions & 6 deletions crates/originweave-policy/tests/sensitive_model_invocation.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -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,
)
}

Expand All @@ -75,21 +84,22 @@ 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
);
}

#[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!(
Expand All @@ -106,13 +116,15 @@ 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"),
"case-resolution-prompt-v1",
"different-schema-v2",
4_096,
1_024,
0,
);

for candidate in [wrong_prompt, wrong_schema] {
Expand Down Expand Up @@ -157,13 +169,15 @@ fn malformed_prompt_or_schema_policy_identifiers_fail_closed() {
"customer-email-summary-v1",
4_096,
1_024,
0,
),
ModelInvocationRequest::new(
route_request("provider-private"),
"case-resolution-prompt-v1",
malformed,
4_096,
1_024,
0,
),
];
for candidate in candidates {
Expand Down Expand Up @@ -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!(
Expand Down
Loading