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/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..12f081be --- /dev/null +++ b/crates/originweave-policy/src/model_disclosure.rs @@ -0,0 +1,113 @@ +//! 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..c3943491 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, 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) + ); +} 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`