From ad5696c8ee31a2f8d31a23be5c4a4aef532bb3a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 21:49:20 +0900 Subject: [PATCH 1/6] test(sensitive): require fail-closed model fallback policy --- .../tests/sensitive_model_fallback.rs | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 crates/originweave-policy/tests/sensitive_model_fallback.rs diff --git a/crates/originweave-policy/tests/sensitive_model_fallback.rs b/crates/originweave-policy/tests/sensitive_model_fallback.rs new file mode 100644 index 00000000..89b2fd43 --- /dev/null +++ b/crates/originweave-policy/tests/sensitive_model_fallback.rs @@ -0,0 +1,158 @@ +#![allow(clippy::expect_used)] + +use originweave_core::Origin; +use originweave_policy::{ + DataClassification, ModelFallbackDecision, ModelFallbackRequest, ModelFallbackScope, + ModelRouteAvailability, ModelRouteDecision, ModelRouteRequest, ModelRouteScope, + SensitiveDataAuthority, evaluate_model_fallback, +}; + +fn authority() -> SensitiveDataAuthority { + SensitiveDataAuthority::new( + "tenant-alpha", + "task-42", + "customer-email", + "case-resolution", + Origin::parse("https://model-gateway.example").expect("valid destination origin"), + DataClassification::PersonalData, + ) +} + +fn route_request(provider: &str, model: &str, region: &str) -> ModelRouteRequest { + ModelRouteRequest::new( + authority(), + provider, + model, + region, + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ) +} + +fn route_scope(provider: &str, model: &str, region: &str) -> ModelRouteScope { + ModelRouteScope::new( + authority(), + provider, + model, + region, + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ) +} + +fn primary_request() -> ModelRouteRequest { + route_request("provider-primary", "model-primary-v1", "kr-central") +} + +fn primary_scope() -> ModelRouteScope { + route_scope("provider-primary", "model-primary-v1", "kr-central") +} + +fn fallback_request() -> ModelRouteRequest { + route_request("provider-fallback", "model-fallback-v1", "kr-central") +} + +fn fallback_scope() -> ModelRouteScope { + route_scope("provider-fallback", "model-fallback-v1", "kr-central") +} + +#[test] +fn available_exact_primary_route_is_used_without_fallback() { + let request = ModelFallbackRequest::new(primary_request(), ModelRouteAvailability::Available); + let scope = ModelFallbackScope::new(primary_scope()); + + assert_eq!( + evaluate_model_fallback(&request, &scope), + ModelFallbackDecision::PrimaryAuthorized + ); +} + +#[test] +fn primary_policy_mismatch_never_falls_back() { + let request = ModelFallbackRequest::new( + route_request("provider-unreviewed", "model-primary-v1", "kr-central"), + ModelRouteAvailability::Unavailable, + ) + .with_fallback(fallback_request()); + let scope = ModelFallbackScope::new(primary_scope()).with_fallback(fallback_scope()); + + assert_eq!( + evaluate_model_fallback(&request, &scope), + ModelFallbackDecision::PrimaryRouteDenied(ModelRouteDecision::RouteMismatch) + ); +} + +#[test] +fn unknown_primary_availability_fails_closed() { + let request = ModelFallbackRequest::new(primary_request(), ModelRouteAvailability::Unknown) + .with_fallback(fallback_request()); + let scope = ModelFallbackScope::new(primary_scope()).with_fallback(fallback_scope()); + + assert_eq!( + evaluate_model_fallback(&request, &scope), + ModelFallbackDecision::PrimaryAvailabilityUnknown + ); +} + +#[test] +fn unavailable_primary_without_reviewed_fallback_fails_closed() { + let request = ModelFallbackRequest::new(primary_request(), ModelRouteAvailability::Unavailable); + let scope = ModelFallbackScope::new(primary_scope()); + + assert_eq!( + evaluate_model_fallback(&request, &scope), + ModelFallbackDecision::PrimaryUnavailableNoReviewedFallback + ); +} + +#[test] +fn fallback_must_exist_on_both_request_and_trusted_scope() { + let request_only = ModelFallbackRequest::new( + primary_request(), + ModelRouteAvailability::Unavailable, + ) + .with_fallback(fallback_request()); + let no_fallback_scope = ModelFallbackScope::new(primary_scope()); + assert_eq!( + evaluate_model_fallback(&request_only, &no_fallback_scope), + ModelFallbackDecision::FallbackPolicyMismatch + ); + + let no_fallback_request = + ModelFallbackRequest::new(primary_request(), ModelRouteAvailability::Unavailable); + let scope_only = ModelFallbackScope::new(primary_scope()).with_fallback(fallback_scope()); + assert_eq!( + evaluate_model_fallback(&no_fallback_request, &scope_only), + ModelFallbackDecision::FallbackPolicyMismatch + ); +} + +#[test] +fn unavailable_primary_can_use_only_an_exact_reviewed_fallback() { + let request = ModelFallbackRequest::new(primary_request(), ModelRouteAvailability::Unavailable) + .with_fallback(fallback_request()); + let scope = ModelFallbackScope::new(primary_scope()).with_fallback(fallback_scope()); + + assert_eq!( + evaluate_model_fallback(&request, &scope), + ModelFallbackDecision::ReviewedFallbackAuthorized + ); +} + +#[test] +fn mismatched_reviewed_fallback_is_denied() { + let request = ModelFallbackRequest::new(primary_request(), ModelRouteAvailability::Unavailable) + .with_fallback(route_request( + "provider-fallback", + "model-unreviewed-v2", + "kr-central", + )); + let scope = ModelFallbackScope::new(primary_scope()).with_fallback(fallback_scope()); + + assert_eq!( + evaluate_model_fallback(&request, &scope), + ModelFallbackDecision::FallbackRouteDenied(ModelRouteDecision::RouteMismatch) + ); +} From 3c0b6a750d0ff85673800c37f1872c937f30e12f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 21:53:34 +0900 Subject: [PATCH 2/6] test(sensitive): canonicalize fallback regression formatting --- .../originweave-policy/tests/sensitive_model_fallback.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/originweave-policy/tests/sensitive_model_fallback.rs b/crates/originweave-policy/tests/sensitive_model_fallback.rs index 89b2fd43..5afada72 100644 --- a/crates/originweave-policy/tests/sensitive_model_fallback.rs +++ b/crates/originweave-policy/tests/sensitive_model_fallback.rs @@ -109,11 +109,9 @@ fn unavailable_primary_without_reviewed_fallback_fails_closed() { #[test] fn fallback_must_exist_on_both_request_and_trusted_scope() { - let request_only = ModelFallbackRequest::new( - primary_request(), - ModelRouteAvailability::Unavailable, - ) - .with_fallback(fallback_request()); + let request_only = + ModelFallbackRequest::new(primary_request(), ModelRouteAvailability::Unavailable) + .with_fallback(fallback_request()); let no_fallback_scope = ModelFallbackScope::new(primary_scope()); assert_eq!( evaluate_model_fallback(&request_only, &no_fallback_scope), From 110cf838acff75e367d2c8e97ded3b16936fab61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 21:56:14 +0900 Subject: [PATCH 3/6] feat(sensitive): enforce reviewed model fallback policy --- .../originweave-policy/src/model_fallback.rs | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 crates/originweave-policy/src/model_fallback.rs diff --git a/crates/originweave-policy/src/model_fallback.rs b/crates/originweave-policy/src/model_fallback.rs new file mode 100644 index 00000000..c7f4bf0b --- /dev/null +++ b/crates/originweave-policy/src/model_fallback.rs @@ -0,0 +1,143 @@ +//! Fail-closed fallback selection layered on exact sensitive-model route authority. +//! +//! This module consumes caller-supplied provider availability evidence only after the primary route +//! itself passes [`crate::evaluate_model_route`]. It performs no provider health check, retry, +//! network I/O, protected-value disclosure, model invocation, or execution of the selected route. +//! A trusted broker/orchestrator must derive availability from an authoritative runtime boundary and +//! may execute only the exact route authorized by this deterministic policy. + +use crate::{ModelRouteDecision, ModelRouteRequest, ModelRouteScope, evaluate_model_route}; + +/// Trusted runtime availability classification for the exact reviewed primary model route. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelRouteAvailability { + /// The trusted runtime boundary reports that the exact primary route is available. + Available, + /// The trusted runtime boundary reports that the exact primary route is unavailable. + Unavailable, + /// Availability is missing, stale, contradictory, or otherwise not trustworthy enough to use. + Unknown, +} + +/// One proposed primary route and optional fallback after trusted availability observation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelFallbackRequest { + primary_route: ModelRouteRequest, + primary_availability: ModelRouteAvailability, + fallback_route: Option, +} + +impl ModelFallbackRequest { + /// Build a request for one primary route without an alternate route. + /// + /// `primary_availability` is evidence supplied by a trusted runtime boundary; constructing this + /// value does not prove provider health or grant permission to disclose protected data. + #[must_use] + pub fn new( + primary_route: ModelRouteRequest, + primary_availability: ModelRouteAvailability, + ) -> Self { + Self { + primary_route, + primary_availability, + fallback_route: None, + } + } + + /// Attach one explicitly proposed fallback route. + /// + /// The route is still denied unless trusted policy independently contains the exact same reviewed + /// fallback scope and the existing route evaluator authorizes it. + #[must_use] + pub fn with_fallback(mut self, fallback_route: ModelRouteRequest) -> Self { + self.fallback_route = Some(fallback_route); + self + } +} + +/// Trusted primary route and optional separately reviewed fallback route authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelFallbackScope { + primary_route: ModelRouteScope, + fallback_route: Option, +} + +impl ModelFallbackScope { + /// Build trusted fallback policy with no alternate route authorized. + #[must_use] + pub fn new(primary_route: ModelRouteScope) -> Self { + Self { + primary_route, + fallback_route: None, + } + } + + /// Attach one exact separately reviewed fallback route scope. + /// + /// Presence in this scope does not make the route healthy or execute it; it only permits the + /// evaluator to consider that exact route after the primary has been authorized then observed + /// unavailable. + #[must_use] + pub fn with_fallback(mut self, fallback_route: ModelRouteScope) -> Self { + self.fallback_route = Some(fallback_route); + self + } +} + +/// Result of composing exact primary route authority, trusted availability, and reviewed fallback. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelFallbackDecision { + /// The exact primary route is policy-authorized and reported available. + PrimaryAuthorized, + /// Primary route policy failed; availability and fallback are intentionally not considered. + PrimaryRouteDenied(ModelRouteDecision), + /// The primary route is authorized but its runtime availability cannot be trusted. + PrimaryAvailabilityUnknown, + /// The primary route is unavailable and policy contains no reviewed fallback route. + PrimaryUnavailableNoReviewedFallback, + /// Request and trusted scope disagree about whether a fallback route exists. + FallbackPolicyMismatch, + /// The primary route is unavailable and the exact separately reviewed fallback route is authorized. + ReviewedFallbackAuthorized, + /// A proposed fallback exists in both request and scope but its exact route policy denied it. + FallbackRouteDenied(ModelRouteDecision), +} + +/// Evaluate fail-closed sensitive-model fallback selection without executing a model route. +/// +/// Primary route policy is always evaluated first. A malformed or mismatched primary route therefore +/// cannot be converted into a fallback trigger. Unknown availability also fails closed. Only explicit +/// `Unavailable` evidence permits fallback consideration, and only when request and trusted scope both +/// carry a fallback that independently passes the existing exact route evaluator. +#[must_use] +pub fn evaluate_model_fallback( + request: &ModelFallbackRequest, + scope: &ModelFallbackScope, +) -> ModelFallbackDecision { + let primary_decision = evaluate_model_route(&request.primary_route, &scope.primary_route); + if primary_decision != ModelRouteDecision::Authorized { + return ModelFallbackDecision::PrimaryRouteDenied(primary_decision); + } + + match request.primary_availability { + ModelRouteAvailability::Available => ModelFallbackDecision::PrimaryAuthorized, + ModelRouteAvailability::Unknown => ModelFallbackDecision::PrimaryAvailabilityUnknown, + ModelRouteAvailability::Unavailable => { + match (&request.fallback_route, &scope.fallback_route) { + (None, None) => ModelFallbackDecision::PrimaryUnavailableNoReviewedFallback, + (Some(_), None) | (None, Some(_)) => { + ModelFallbackDecision::FallbackPolicyMismatch + } + (Some(fallback_request), Some(fallback_scope)) => { + let fallback_decision = + evaluate_model_route(fallback_request, fallback_scope); + if fallback_decision == ModelRouteDecision::Authorized { + ModelFallbackDecision::ReviewedFallbackAuthorized + } else { + ModelFallbackDecision::FallbackRouteDenied(fallback_decision) + } + } + } + } + } +} From 0c864d49b3b48f3dc6c5c575059c99f417b62d00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 22:11:25 +0900 Subject: [PATCH 4/6] feat(sensitive): export model fallback policy --- crates/originweave-policy/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index 25f1a6bf..12f89f22 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -7,10 +7,15 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod model_fallback; mod model_output; mod model_route; mod sensitive_data; +pub use model_fallback::{ + ModelFallbackDecision, ModelFallbackRequest, ModelFallbackScope, ModelRouteAvailability, + evaluate_model_fallback, +}; pub use model_output::{ ModelOutputDecision, ModelOutputRequest, ModelOutputScope, ModelOutputValidation, evaluate_model_output, From f4ebe1efabeeb416eccbf54177e43f897f878765 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 22:14:54 +0900 Subject: [PATCH 5/6] style(sensitive): apply canonical fallback formatting --- crates/originweave-policy/src/model_fallback.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/originweave-policy/src/model_fallback.rs b/crates/originweave-policy/src/model_fallback.rs index c7f4bf0b..975830e3 100644 --- a/crates/originweave-policy/src/model_fallback.rs +++ b/crates/originweave-policy/src/model_fallback.rs @@ -125,12 +125,9 @@ pub fn evaluate_model_fallback( ModelRouteAvailability::Unavailable => { match (&request.fallback_route, &scope.fallback_route) { (None, None) => ModelFallbackDecision::PrimaryUnavailableNoReviewedFallback, - (Some(_), None) | (None, Some(_)) => { - ModelFallbackDecision::FallbackPolicyMismatch - } + (Some(_), None) | (None, Some(_)) => ModelFallbackDecision::FallbackPolicyMismatch, (Some(fallback_request), Some(fallback_scope)) => { - let fallback_decision = - evaluate_model_route(fallback_request, fallback_scope); + let fallback_decision = evaluate_model_route(fallback_request, fallback_scope); if fallback_decision == ModelRouteDecision::Authorized { ModelFallbackDecision::ReviewedFallbackAuthorized } else { From a2c391a5e038dc9e3d6978885d9bdd943487294f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 22:20:59 +0900 Subject: [PATCH 6/6] docs: record fail-closed model fallback policy --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e1d846..57cc3553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Fail-closed sensitive-model fallback selection that authorizes the exact primary route before considering trusted availability, rejects unknown availability and one-sided or unreviewed fallback policy, and permits only an exact separately reviewed fallback route through the existing model-route authority; this deterministic boundary does not probe provider health, retry, invoke a model, disclose protected values, or execute the selected route. - Separate sensitive-model output admission that requires an exact reviewed output-schema identifier, exact retention-policy identifier, and trusted validation result before authorization; malformed or mismatched policy fails closed, validation rejection remains distinct, and this metadata-only boundary does not inspect model-output bytes, persist output, enforce retention, authorize invocation, or disclose protected values. - 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.