From 8642375fa5a96a5a0cb5d6fab11aee8077092b8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 02:05:59 +0900 Subject: [PATCH 1/4] test(sensitive): require model disclosure necessity --- .../tests/sensitive_model_necessity.rs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 crates/originweave-policy/tests/sensitive_model_necessity.rs diff --git a/crates/originweave-policy/tests/sensitive_model_necessity.rs b/crates/originweave-policy/tests/sensitive_model_necessity.rs new file mode 100644 index 00000000..93d1445c --- /dev/null +++ b/crates/originweave-policy/tests/sensitive_model_necessity.rs @@ -0,0 +1,140 @@ +#![allow(clippy::expect_used)] + +//! Fail-first contract requiring a lower-disclosure-path check before raw model input. + +use originweave_core::Origin; +use originweave_policy::{ + DataClassification, DisclosureDecision, DisclosureScope, ModelDisclosureAlternative, + ModelDisclosureDecision, ModelDisclosureNecessity, ModelInvocationRequest, + ModelInvocationScope, ModelRouteRequest, ModelRouteScope, SensitiveDataAuthority, + SensitiveDataRequest, evaluate_full_field_model_disclosure, +}; + +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 invocation_request(authority: SensitiveDataAuthority) -> ModelInvocationRequest { + ModelInvocationRequest::new( + ModelRouteRequest::new( + authority, + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ), + "case-resolution-prompt-v1", + "customer-email-summary-v1", + 4_096, + 1_024, + 0, + ) +} + +fn invocation_scope(authority: SensitiveDataAuthority) -> ModelInvocationScope { + ModelInvocationScope::new( + ModelRouteScope::new( + authority, + "provider-private", + "model-reviewed-v1", + "kr-central", + "ephemeral-retention", + "no-training", + "subprocessors-reviewed-v1", + ), + "case-resolution-prompt-v1", + "customer-email-summary-v1", + 8_192, + 2_048, + 1_000, + ) +} + +#[test] +fn exact_full_field_model_disclosure_requires_no_lower_disclosure_path() { + let exact_authority = authority(); + let disclosure_request = SensitiveDataRequest::new(exact_authority.clone()); + let disclosure_scope = DisclosureScope::new( + exact_authority.clone(), + DisclosureDecision::FullFieldDisclosure, + ); + let invocation_request = invocation_request(exact_authority.clone()); + let invocation_scope = invocation_scope(exact_authority); + + assert_eq!( + evaluate_full_field_model_disclosure( + &disclosure_request, + &disclosure_scope, + ModelDisclosureNecessity::NoLowerDisclosurePath, + &invocation_request, + &invocation_scope, + 999, + ), + ModelDisclosureDecision::Authorized + ); +} + +#[test] +fn any_available_lower_disclosure_path_blocks_raw_model_input() { + for alternative in [ + ModelDisclosureAlternative::OpaqueHandle, + ModelDisclosureAlternative::DeterministicTransform, + ModelDisclosureAlternative::LocalRule, + ModelDisclosureAlternative::StructuredTool, + ModelDisclosureAlternative::ApprovedDerivedValue, + ] { + let exact_authority = authority(); + let disclosure_request = SensitiveDataRequest::new(exact_authority.clone()); + let disclosure_scope = DisclosureScope::new( + exact_authority.clone(), + DisclosureDecision::FullFieldDisclosure, + ); + let invocation_request = invocation_request(exact_authority.clone()); + let invocation_scope = invocation_scope(exact_authority); + + assert_eq!( + evaluate_full_field_model_disclosure( + &disclosure_request, + &disclosure_scope, + ModelDisclosureNecessity::LowerDisclosurePathAvailable(alternative), + &invocation_request, + &invocation_scope, + 999, + ), + ModelDisclosureDecision::FullFieldNotNecessary(alternative) + ); + } +} + +#[test] +fn necessity_never_upgrades_a_weaker_disclosure_decision() { + let exact_authority = authority(); + let disclosure_request = SensitiveDataRequest::new(exact_authority.clone()); + let disclosure_scope = DisclosureScope::new( + exact_authority.clone(), + DisclosureDecision::OpaqueHandleOnly, + ); + let invocation_request = invocation_request(exact_authority.clone()); + let invocation_scope = invocation_scope(exact_authority); + + assert_eq!( + evaluate_full_field_model_disclosure( + &disclosure_request, + &disclosure_scope, + ModelDisclosureNecessity::NoLowerDisclosurePath, + &invocation_request, + &invocation_scope, + 999, + ), + ModelDisclosureDecision::DisclosureNotAuthorized(DisclosureDecision::OpaqueHandleOnly) + ); +} From bb626fd07a4dbd5342bbc1807cf96367e50c054a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 02:12:20 +0900 Subject: [PATCH 2/4] feat(sensitive): require lower-disclosure exhaustion --- crates/originweave-policy/src/lib.rs | 10 +- .../src/model_disclosure.rs | 111 ++++++++++++++++++ .../tests/sensitive_model_disclosure.rs | 15 ++- 3 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 crates/originweave-policy/src/model_disclosure.rs diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index e8d696b6..7d9c68e0 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -7,11 +7,16 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod model_disclosure; mod model_fallback; mod model_output; mod model_route; mod sensitive_data; +pub use model_disclosure::{ + ModelDisclosureAlternative, ModelDisclosureDecision, ModelDisclosureNecessity, + evaluate_full_field_model_disclosure, +}; pub use model_fallback::{ ModelFallbackDecision, ModelFallbackRequest, ModelFallbackScope, ModelRouteAvailability, ModelRouteAvailabilityEvidence, evaluate_model_fallback, @@ -21,9 +26,8 @@ pub use model_output::{ evaluate_model_output, }; pub use model_route::{ - ModelDisclosureDecision, ModelInvocationDecision, ModelInvocationRequest, ModelInvocationScope, - ModelRouteDecision, ModelRouteRequest, ModelRouteScope, evaluate_full_field_model_disclosure, - evaluate_model_invocation, evaluate_model_route, + ModelInvocationDecision, ModelInvocationRequest, ModelInvocationScope, ModelRouteDecision, + ModelRouteRequest, ModelRouteScope, evaluate_model_invocation, evaluate_model_route, }; pub use sensitive_data::{ DataClassification, DisclosureDecision, DisclosureScope, HandleRevocationReason, diff --git a/crates/originweave-policy/src/model_disclosure.rs b/crates/originweave-policy/src/model_disclosure.rs new file mode 100644 index 00000000..5a14cfc6 --- /dev/null +++ b/crates/originweave-policy/src/model_disclosure.rs @@ -0,0 +1,111 @@ +//! Necessity-gated composition for exceptional full-field model disclosure. +//! +//! This module narrows the existing sensitive-data and reviewed-model policy composition with one +//! additional fail-closed precondition: raw protected field bytes may be considered for model input +//! only after a trusted broker or orchestrator has determined that no lower-disclosure execution path +//! can satisfy the approved task. The types here carry policy metadata only; they never carry a +//! protected value, inspect task state, authenticate a provider, invoke a model, or prove that a +//! caller-supplied necessity claim is truthful. + +use crate::model_route::{ + ModelDisclosureDecision as InvocationDisclosureDecision, ModelInvocationDecision, + ModelInvocationRequest, ModelInvocationScope, + evaluate_full_field_model_disclosure as evaluate_disclosure_and_invocation, +}; +use crate::sensitive_data::{DisclosureDecision, DisclosureScope, SensitiveDataRequest}; + +/// A lower-disclosure execution path that can satisfy the approved task without raw model input. +/// +/// The trusted broker or orchestrator derives this classification from current task/runtime state. +/// Selecting a variant is evidence that full-field model disclosure is unnecessary, so the policy +/// composition fails closed before returning model-disclosure authorization. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelDisclosureAlternative { + /// An opaque handle can reach the trusted execution boundary without exposing the raw field to a model. + OpaqueHandle, + /// A deterministic transformation can produce the required task value without raw model input. + DeterministicTransform, + /// A local deterministic rule can complete the required decision or transformation. + LocalRule, + /// A reviewed structured tool can perform the operation without disclosing the raw field to a model. + StructuredTool, + /// A separately approved derived value is sufficient for the model-backed portion of the task. + ApprovedDerivedValue, +} + +/// Necessity evidence supplied to the full-field model-disclosure composition boundary. +/// +/// This pure policy type is not self-authenticating. A trusted broker or orchestrator must derive it +/// from the actual current task, available tools, handle capabilities, deterministic transforms, and +/// approved derived values immediately before protected-value resolution. Untrusted model or page +/// content must never be permitted to assert `NoLowerDisclosurePath` as authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelDisclosureNecessity { + /// The trusted execution boundary found no lower-disclosure path able to satisfy the approved task. + NoLowerDisclosurePath, + /// A lower-disclosure path remains available, so raw protected model input is unnecessary. + LowerDisclosurePathAvailable(ModelDisclosureAlternative), +} + +/// Result of composing sensitive disclosure, necessity, and one reviewed model invocation. +/// +/// Authorization is metadata-only. Even [`Self::Authorized`] does not resolve a protected value, +/// authenticate or contact a provider, validate output, attest runtime region/time, enforce retention, +/// or prove necessity independently of the trusted caller that derived [`ModelDisclosureNecessity`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelDisclosureDecision { + /// Full-field disclosure, necessity, exact authority, and reviewed invocation policy all authorize. + Authorized, + /// Sensitive-data policy did not explicitly authorize complete-field disclosure. + DisclosureNotAuthorized(DisclosureDecision), + /// Disclosure and invocation metadata belong to different exact sensitive-data authority tuples. + AuthorityMismatch, + /// A lower-disclosure path remains available, so raw protected model input is not necessary. + FullFieldNotNecessary(ModelDisclosureAlternative), + /// Model-route or invocation policy denied the otherwise full-field-authorized request. + InvocationDenied(ModelInvocationDecision), +} + +/// Compose an exceptional full-field disclosure with necessity and one reviewed model invocation. +/// +/// The existing disclosure/invocation composition runs first so malformed, weaker, mismatched, expired, +/// or otherwise denied policy cannot be upgraded by a necessity claim. Only an otherwise authorized +/// composition reaches the necessity gate. When a lower-disclosure alternative remains available, this +/// function returns [`ModelDisclosureDecision::FullFieldNotNecessary`] instead of authorization. +/// +/// `necessity` must be derived by a trusted broker/orchestrator from current executable alternatives; +/// `ModelDisclosureNecessity::NoLowerDisclosurePath` is not proof merely because a caller supplied it. +/// The trusted value-resolution boundary must still revalidate policy and lifecycle state immediately +/// before releasing protected bytes, execute only the exact authorized route, and enforce output, +/// retention, export, audit, and revocation controls. +#[must_use] +pub fn evaluate_full_field_model_disclosure( + disclosure_request: &SensitiveDataRequest, + disclosure_scope: &DisclosureScope, + necessity: ModelDisclosureNecessity, + invocation_request: &ModelInvocationRequest, + invocation_scope: &ModelInvocationScope, + trusted_time: u64, +) -> ModelDisclosureDecision { + match evaluate_disclosure_and_invocation( + disclosure_request, + disclosure_scope, + invocation_request, + invocation_scope, + trusted_time, + ) { + InvocationDisclosureDecision::DisclosureNotAuthorized(decision) => { + ModelDisclosureDecision::DisclosureNotAuthorized(decision) + } + InvocationDisclosureDecision::AuthorityMismatch => ModelDisclosureDecision::AuthorityMismatch, + InvocationDisclosureDecision::InvocationDenied(decision) => { + ModelDisclosureDecision::InvocationDenied(decision) + } + InvocationDisclosureDecision::Authorized => match necessity { + ModelDisclosureNecessity::NoLowerDisclosurePath => ModelDisclosureDecision::Authorized, + ModelDisclosureNecessity::LowerDisclosurePathAvailable(alternative) => { + ModelDisclosureDecision::FullFieldNotNecessary(alternative) + } + }, + } +} diff --git a/crates/originweave-policy/tests/sensitive_model_disclosure.rs b/crates/originweave-policy/tests/sensitive_model_disclosure.rs index be440877..48ceaa9b 100644 --- a/crates/originweave-policy/tests/sensitive_model_disclosure.rs +++ b/crates/originweave-policy/tests/sensitive_model_disclosure.rs @@ -4,15 +4,16 @@ //! //! The model route and invocation policy are not raw-value disclosure authority. This contract //! requires the exact sensitive-data authority to authorize full-field disclosure, requires that -//! authority to be the same authority carried by the reviewed model invocation, and then requires -//! the invocation policy itself to authorize. It carries no protected bytes and performs no model I/O. +//! authority to be the same authority carried by the reviewed model invocation, requires no known +//! lower-disclosure task path, and then requires the invocation policy itself to authorize. It carries +//! no protected bytes and performs no model I/O. use originweave_core::Origin; use originweave_policy::{ DataClassification, DisclosureDecision, DisclosureScope, ModelDisclosureDecision, - ModelInvocationDecision, ModelInvocationRequest, ModelInvocationScope, ModelRouteDecision, - ModelRouteRequest, ModelRouteScope, SensitiveDataAuthority, SensitiveDataRequest, - evaluate_full_field_model_disclosure, + ModelDisclosureNecessity, ModelInvocationDecision, ModelInvocationRequest, ModelInvocationScope, + ModelRouteDecision, ModelRouteRequest, ModelRouteScope, SensitiveDataAuthority, + SensitiveDataRequest, evaluate_full_field_model_disclosure, }; fn authority(task_id: &str) -> SensitiveDataAuthority { @@ -90,6 +91,7 @@ fn full_field_disclosure_and_exact_reviewed_invocation_are_authorized() { evaluate_full_field_model_disclosure( &disclosure_request, &disclosure_scope, + ModelDisclosureNecessity::NoLowerDisclosurePath, &invocation_request, &invocation_scope, 999, @@ -118,6 +120,7 @@ fn every_non_full_field_outcome_remains_non_authorizing_for_raw_model_input() { evaluate_full_field_model_disclosure( &disclosure_request, &disclosure_scope, + ModelDisclosureNecessity::NoLowerDisclosurePath, &invocation_request, &invocation_scope, 999, @@ -141,6 +144,7 @@ fn disclosure_authority_cannot_be_composed_with_another_tasks_valid_invocation() evaluate_full_field_model_disclosure( &disclosure_request, &disclosure_scope, + ModelDisclosureNecessity::NoLowerDisclosurePath, &invocation_request, &invocation_scope, 999, @@ -164,6 +168,7 @@ fn invocation_denial_is_preserved_after_full_field_disclosure_authority() { evaluate_full_field_model_disclosure( &disclosure_request, &disclosure_scope, + ModelDisclosureNecessity::NoLowerDisclosurePath, &invocation_request, &invocation_scope, 999, From 4e34e65289c8548663c9b1034e663ae730796ec0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 02:15:31 +0900 Subject: [PATCH 3/4] style(sensitive): apply canonical rustfmt --- crates/originweave-policy/src/model_disclosure.rs | 4 +++- .../originweave-policy/tests/sensitive_model_disclosure.rs | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/originweave-policy/src/model_disclosure.rs b/crates/originweave-policy/src/model_disclosure.rs index 5a14cfc6..12f081be 100644 --- a/crates/originweave-policy/src/model_disclosure.rs +++ b/crates/originweave-policy/src/model_disclosure.rs @@ -97,7 +97,9 @@ pub fn evaluate_full_field_model_disclosure( InvocationDisclosureDecision::DisclosureNotAuthorized(decision) => { ModelDisclosureDecision::DisclosureNotAuthorized(decision) } - InvocationDisclosureDecision::AuthorityMismatch => ModelDisclosureDecision::AuthorityMismatch, + InvocationDisclosureDecision::AuthorityMismatch => { + ModelDisclosureDecision::AuthorityMismatch + } InvocationDisclosureDecision::InvocationDenied(decision) => { ModelDisclosureDecision::InvocationDenied(decision) } diff --git a/crates/originweave-policy/tests/sensitive_model_disclosure.rs b/crates/originweave-policy/tests/sensitive_model_disclosure.rs index 48ceaa9b..c3943491 100644 --- a/crates/originweave-policy/tests/sensitive_model_disclosure.rs +++ b/crates/originweave-policy/tests/sensitive_model_disclosure.rs @@ -11,9 +11,9 @@ use originweave_core::Origin; use originweave_policy::{ DataClassification, DisclosureDecision, DisclosureScope, ModelDisclosureDecision, - ModelDisclosureNecessity, ModelInvocationDecision, ModelInvocationRequest, ModelInvocationScope, - ModelRouteDecision, ModelRouteRequest, ModelRouteScope, SensitiveDataAuthority, - SensitiveDataRequest, evaluate_full_field_model_disclosure, + ModelDisclosureNecessity, ModelInvocationDecision, ModelInvocationRequest, + ModelInvocationScope, ModelRouteDecision, ModelRouteRequest, ModelRouteScope, + SensitiveDataAuthority, SensitiveDataRequest, evaluate_full_field_model_disclosure, }; fn authority(task_id: &str) -> SensitiveDataAuthority { From 166d68bc8e42f41fcd21965609af222e69fd3d4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 02:20:10 +0900 Subject: [PATCH 4/4] docs(sensitive): document necessity-gated disclosure --- CHANGELOG.md | 1 + docs/API_CONTRACT.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11a3e19b..8f6efa96 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 +- Necessity-gated full-field sensitive-model disclosure composition that requires trusted broker-supplied `NoLowerDisclosurePath` only after exact sensitive-data and reviewed invocation authorization; available opaque-handle, deterministic-transform, local-rule, structured-tool, or approved-derived-value paths return typed `FullFieldNotNecessary` denial, and the pure policy boundary does not attest caller truth or release protected bytes. - Route-bound sensitive-model fallback availability evidence that retains the exact reviewed primary `ModelRouteRequest` and fails closed with `PrimaryAvailabilityRouteMismatch` before lifetime or state can trigger fallback when evidence belongs to another provider/model/region/policy route; primary route authorization remains the first boundary, and this policy primitive does not attest route identity, provider health, or clock provenance. - Freshness-bound sensitive-model fallback availability evidence with an exclusive caller-supplied validity horizon and trusted evaluation time; exact primary-route authorization remains the first boundary, zero or expired availability lifetimes fail closed, unknown fresh availability remains denied, and only fresh explicit unavailability can enter the separately reviewed fallback path without claiming provider-health or clock attestation. - 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. diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index f750922f..4410ca58 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -270,6 +270,10 @@ The response contains only access/evidence metadata and post-condition state. Where policy permits, a caller may request a derived safe representation rather than the original value. The derivation and retention policy are typed and versioned; no generic “unmask” method exists. +### Selective model disclosure + +A full protected field is not eligible for model input merely because sensitive-data policy and a reviewed model route both authorize it. The trusted broker/orchestrator must first derive current necessity from executable alternatives. If an opaque handle, deterministic transform, local rule, structured tool, or approved derived value can satisfy the task, the policy boundary returns a typed lower-disclosure-path denial rather than full-field model authorization. A `no lower disclosure path` assertion is metadata, not self-authenticating proof: untrusted page/model content cannot mint it, and protected bytes remain resolved only inside the trusted value boundary after policy and lifecycle revalidation. + ## 16. Extraction operations ### `browser.extract`