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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo

### Added

- Reviewed sensitive-model invocation authority that composes exact route admission with bounded prompt-contract and output-schema identifiers, nonzero requested and reviewed token budgets, and an exclusive caller-supplied trusted-time expiry; malformed policy fails closed as `InvocationPolicyMismatch`, an otherwise valid policy at or after `valid_until` returns `InvocationExpired`, and this metadata-only boundary does not disclose protected values, invoke a provider, or attest clock provenance.
- Exact sensitive-data model-route admission that binds the complete existing sensitive authority to bounded provider, model, region, retention-policy, training-policy, reviewed subprocessor-policy, and export-policy identifiers; the compatibility constructor defaults export to `no-export`, and route admission remains explicitly separate from protected-value disclosure, export execution, provider authentication, runtime region attestation, model invocation, and fallback selection.
- In-process authoritative sensitive-handle use reservation and first-revocation-wins lifecycle state that owns the bounded use count, records task-completion/policy-change/key-rotation/session-termination/suspicious-use revocation causes, blocks all future reservations after revocation, increments only after exact scope/classification/expiry/use-limit authorization, and leaves denied reservations unconsumed; this is a policy primitive only and does not claim durable broker storage, protected-value resolution, or cross-process transactionality.
- Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules.
Expand Down
40 changes: 29 additions & 11 deletions crates/originweave-policy/src/model_route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
//! 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.
//! 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.

use crate::sensitive_data::{
DisclosureDecision, DisclosureScope, SensitiveDataAuthority, SensitiveDataRequest,
Expand Down Expand Up @@ -152,12 +153,14 @@ 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.
/// Exact route, prompt/schema contracts, token budgets, and policy lifetime 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.
/// 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.
InvocationExpired,
}

/// One proposed model invocation after a route has been selected.
Expand Down Expand Up @@ -198,27 +201,32 @@ pub struct ModelInvocationScope {
output_schema_id: String,
maximum_input_tokens: u32,
maximum_output_tokens: u32,
valid_until: u64,
}

impl ModelInvocationScope {
/// Build trusted invocation policy for one prompt/schema pair and finite token maxima.
/// Build trusted invocation policy for one prompt/schema pair, token maxima, and expiry.
///
/// 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.
/// Identifiers, token maxima, and the exclusive `valid_until` value are validated during
/// evaluation so malformed trusted policy remains fail-closed instead of becoming authority
/// because request and scope happen to match. The expiry is only meaningful when compared with
/// a trusted time from the same caller-owned authoritative time domain.
#[must_use]
pub fn new(
route: ModelRouteScope,
prompt_contract_id: &str,
output_schema_id: &str,
maximum_input_tokens: u32,
maximum_output_tokens: u32,
valid_until: u64,
) -> 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,
valid_until,
}
}
}
Expand Down Expand Up @@ -274,18 +282,23 @@ pub fn evaluate_model_route(
}
}

/// Evaluate reviewed prompt/schema and token limits after exact route admission.
/// Evaluate reviewed 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, 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.
/// 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.
#[must_use]
pub fn evaluate_model_invocation(
request: &ModelInvocationRequest,
scope: &ModelInvocationScope,
trusted_time: u64,
) -> ModelInvocationDecision {
let route_decision = evaluate_model_route(&request.route, &scope.route);
if route_decision != ModelRouteDecision::Authorized {
Expand All @@ -302,10 +315,15 @@ pub fn evaluate_model_invocation(
|| request.output_tokens == 0
|| scope.maximum_input_tokens == 0
|| scope.maximum_output_tokens == 0
|| scope.valid_until == 0
|| request.input_tokens > scope.maximum_input_tokens
|| request.output_tokens > scope.maximum_output_tokens
{
ModelInvocationDecision::InvocationPolicyMismatch
return ModelInvocationDecision::InvocationPolicyMismatch;
}

if trusted_time >= scope.valid_until {
ModelInvocationDecision::InvocationExpired
} else {
ModelInvocationDecision::Authorized
}
Expand Down
70 changes: 56 additions & 14 deletions crates/originweave-policy/tests/sensitive_model_invocation.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
#![allow(clippy::expect_used)]

//! Fail-closed prompt, output-schema, and token-budget contracts for sensitive model use.
//! Fail-closed prompt, output-schema, token-budget, and expiry 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.
//! 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.

use originweave_core::Origin;
use originweave_policy::{
Expand Down Expand Up @@ -58,26 +59,31 @@ fn request(input_tokens: u32, output_tokens: u32) -> ModelInvocationRequest {
)
}

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

#[test]
fn exact_route_prompt_schema_and_bounded_tokens_are_authorized() {
fn exact_route_prompt_schema_bounded_tokens_and_fresh_policy_are_authorized() {
assert_eq!(
evaluate_model_invocation(&request(4_096, 1_024), &scope(8_192, 2_048)),
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() {
fn route_denial_remains_distinct_from_invocation_policy_mismatch_or_expiry() {
let request = ModelInvocationRequest::new(
route_request("provider-other"),
"case-resolution-prompt-v1",
Expand All @@ -87,7 +93,7 @@ fn route_denial_remains_distinct_from_invocation_policy_mismatch() {
);

assert_eq!(
evaluate_model_invocation(&request, &scope(8_192, 2_048)),
evaluate_model_invocation(&request, &scope(8_192, 2_048, 1), 1_000),
ModelInvocationDecision::RouteDenied(ModelRouteDecision::RouteMismatch)
);
}
Expand All @@ -111,7 +117,7 @@ fn prompt_and_output_schema_contracts_are_exact() {

for candidate in [wrong_prompt, wrong_schema] {
assert_eq!(
evaluate_model_invocation(&candidate, &scope(8_192, 2_048)),
evaluate_model_invocation(&candidate, &scope(8_192, 2_048, 1_000), 999),
ModelInvocationDecision::InvocationPolicyMismatch
);
}
Expand All @@ -126,14 +132,14 @@ fn token_budgets_must_be_nonzero_and_within_reviewed_maxima() {
request(4_096, 2_049),
] {
assert_eq!(
evaluate_model_invocation(&candidate, &scope(8_192, 2_048)),
evaluate_model_invocation(&candidate, &scope(8_192, 2_048, 1_000), 999),
ModelInvocationDecision::InvocationPolicyMismatch
);
}

for malformed_scope in [scope(0, 2_048), scope(8_192, 0)] {
for malformed_scope in [scope(0, 2_048, 1_000), scope(8_192, 0, 1_000)] {
assert_eq!(
evaluate_model_invocation(&request(4_096, 1_024), &malformed_scope),
evaluate_model_invocation(&request(4_096, 1_024), &malformed_scope, 999),
ModelInvocationDecision::InvocationPolicyMismatch
);
}
Expand Down Expand Up @@ -162,7 +168,7 @@ fn malformed_prompt_or_schema_policy_identifiers_fail_closed() {
];
for candidate in candidates {
assert_eq!(
evaluate_model_invocation(&candidate, &scope(8_192, 2_048)),
evaluate_model_invocation(&candidate, &scope(8_192, 2_048, 1_000), 999),
ModelInvocationDecision::InvocationPolicyMismatch
);
}
Expand All @@ -174,20 +180,56 @@ fn malformed_prompt_or_schema_policy_identifiers_fail_closed() {
"customer-email-summary-v1",
8_192,
2_048,
1_000,
),
ModelInvocationScope::new(
route_scope(),
"case-resolution-prompt-v1",
malformed,
8_192,
2_048,
1_000,
),
];
for malformed_scope in scopes {
assert_eq!(
evaluate_model_invocation(&request(4_096, 1_024), &malformed_scope),
evaluate_model_invocation(&request(4_096, 1_024), &malformed_scope, 999),
ModelInvocationDecision::InvocationPolicyMismatch
);
}
}
}

#[test]
fn invocation_policy_expiry_is_exclusive_and_fail_closed() {
let policy = scope(8_192, 2_048, 1_000);

assert_eq!(
evaluate_model_invocation(&request(4_096, 1_024), &policy, 999),
ModelInvocationDecision::Authorized
);
assert_eq!(
evaluate_model_invocation(&request(4_096, 1_024), &policy, 1_000),
ModelInvocationDecision::InvocationExpired
);
assert_eq!(
evaluate_model_invocation(&request(4_096, 1_024), &policy, u64::MAX),
ModelInvocationDecision::InvocationExpired
);
}

#[test]
fn zero_expiry_is_invalid_but_maximum_epoch_remains_representable() {
assert_eq!(
evaluate_model_invocation(&request(4_096, 1_024), &scope(8_192, 2_048, 0), 0),
ModelInvocationDecision::InvocationPolicyMismatch
);
assert_eq!(
evaluate_model_invocation(
&request(4_096, 1_024),
&scope(8_192, 2_048, u64::MAX),
u64::MAX - 1,
),
ModelInvocationDecision::Authorized
);
}
Loading