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
5 changes: 3 additions & 2 deletions crates/originweave-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ pub use model_output::{
evaluate_model_output,
};
pub use model_route::{
ModelInvocationDecision, ModelInvocationRequest, ModelInvocationScope, ModelRouteDecision,
ModelRouteRequest, ModelRouteScope, evaluate_model_invocation, evaluate_model_route,
ModelDisclosureDecision, ModelInvocationDecision, ModelInvocationRequest, ModelInvocationScope,
ModelRouteDecision, ModelRouteRequest, ModelRouteScope, evaluate_full_field_model_disclosure,
evaluate_model_invocation, evaluate_model_route,
};
pub use sensitive_data::{
DataClassification, DisclosureDecision, DisclosureScope, HandleRevocationReason,
Expand Down
64 changes: 64 additions & 0 deletions crates/originweave-policy/src/model_route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,67 @@ pub fn evaluate_model_invocation(
ModelInvocationDecision::Authorized
}
}

/// Result of composing an explicit full-field disclosure with a reviewed model invocation.
///
/// This decision authorizes only the metadata composition boundary. It carries no protected field
/// bytes and does not authenticate or invoke a provider, resolve an opaque handle, validate model
/// output, enforce retention, or prove that a non-model execution path was unavailable.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelDisclosureDecision {
/// The exact full-field disclosure and reviewed model invocation both authorize the same field authority.
Authorized,
/// Sensitive-data policy did not explicitly authorize complete-field disclosure.
DisclosureNotAuthorized(DisclosureDecision),
/// The disclosure authority and model invocation belong to different exact authority tuples.
AuthorityMismatch,
/// Model-route or invocation policy denied the otherwise full-field-authorized request.
InvocationDenied(ModelInvocationDecision),
}

/// Compose full-field sensitive-data disclosure with one exact reviewed model invocation.
///
/// Full-field disclosure is evaluated first so every weaker, approval-pending, malformed, or denied
/// sensitive-data outcome remains non-authorizing for raw model input. The disclosure request must
/// then carry the same complete [`SensitiveDataAuthority`] as the model invocation request; this
/// prevents independently valid policy metadata for different tenant/task/field/purpose/destination/
/// classification authorities from being combined. Only after those boundaries pass is the existing
/// route/context/prompt/schema/token/expiry evaluator consulted. `trusted_time` must come from the
/// same authoritative time domain as the reviewed invocation policy expiry.
///
/// An [`ModelDisclosureDecision::Authorized`] result still does not carry or disclose protected
/// bytes. A trusted broker must independently prove that full-field model disclosure is necessary,
/// authenticate the runtime/provider, resolve the value inside its transaction boundary, execute only
/// the authorized route, validate output, and enforce retention/export policy.
#[must_use]
pub fn evaluate_full_field_model_disclosure(
disclosure_request: &SensitiveDataRequest,
disclosure_scope: &DisclosureScope,
invocation_request: &ModelInvocationRequest,
invocation_scope: &ModelInvocationScope,
trusted_time: u64,
) -> ModelDisclosureDecision {
let disclosure_decision = evaluate_disclosure(disclosure_request, disclosure_scope);
if disclosure_decision != DisclosureDecision::FullFieldDisclosure {
return ModelDisclosureDecision::DisclosureNotAuthorized(disclosure_decision);
}

let invocation_authority_decision = evaluate_disclosure(
disclosure_request,
&DisclosureScope::new(
invocation_request.route.authority.clone(),
DisclosureDecision::FullFieldDisclosure,
),
);
if invocation_authority_decision != DisclosureDecision::FullFieldDisclosure {
return ModelDisclosureDecision::AuthorityMismatch;
}

let invocation_decision =
evaluate_model_invocation(invocation_request, invocation_scope, trusted_time);
if invocation_decision == ModelInvocationDecision::Authorized {
ModelDisclosureDecision::Authorized
} else {
ModelDisclosureDecision::InvocationDenied(invocation_decision)
}
}
175 changes: 175 additions & 0 deletions crates/originweave-policy/tests/sensitive_model_disclosure.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#![allow(clippy::expect_used)]

//! Fail-closed composition contract for one full protected field entering a reviewed model request.
//!
//! 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.

use originweave_core::Origin;
use originweave_policy::{
DataClassification, DisclosureDecision, DisclosureScope, ModelDisclosureDecision,
ModelInvocationDecision, ModelInvocationRequest, ModelInvocationScope, ModelRouteDecision,
ModelRouteRequest, ModelRouteScope, SensitiveDataAuthority, SensitiveDataRequest,
evaluate_full_field_model_disclosure,
};

fn authority(task_id: &str) -> SensitiveDataAuthority {
SensitiveDataAuthority::new(
"tenant-alpha",
task_id,
"customer-email",
"case-resolution",
Origin::parse("https://model-gateway.example").expect("valid destination origin"),
DataClassification::PersonalData,
)
}

fn route_request(authority: SensitiveDataAuthority, provider_id: &str) -> ModelRouteRequest {
ModelRouteRequest::new(
authority,
provider_id,
"model-reviewed-v1",
"kr-central",
"ephemeral-retention",
"no-training",
"subprocessors-reviewed-v1",
)
}

fn route_scope(authority: SensitiveDataAuthority) -> ModelRouteScope {
ModelRouteScope::new(
authority,
"provider-private",
"model-reviewed-v1",
"kr-central",
"ephemeral-retention",
"no-training",
"subprocessors-reviewed-v1",
)
}

fn invocation_request(
authority: SensitiveDataAuthority,
provider_id: &str,
) -> ModelInvocationRequest {
ModelInvocationRequest::new(
route_request(authority, provider_id),
"case-resolution-prompt-v1",
"customer-email-summary-v1",
4_096,
1_024,
0,
)
}

fn invocation_scope(authority: SensitiveDataAuthority) -> ModelInvocationScope {
ModelInvocationScope::new(
route_scope(authority),
"case-resolution-prompt-v1",
"customer-email-summary-v1",
8_192,
2_048,
1_000,
)
}

#[test]
fn full_field_disclosure_and_exact_reviewed_invocation_are_authorized() {
let exact_authority = authority("task-42");
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(), "provider-private");
let invocation_scope = invocation_scope(exact_authority);

assert_eq!(
evaluate_full_field_model_disclosure(
&disclosure_request,
&disclosure_scope,
&invocation_request,
&invocation_scope,
999,
),
ModelDisclosureDecision::Authorized
);
}

#[test]
fn every_non_full_field_outcome_remains_non_authorizing_for_raw_model_input() {
for decision in [
DisclosureDecision::DenyAccess,
DisclosureDecision::OpaqueHandleOnly,
DisclosureDecision::DerivedValueOnly,
DisclosureDecision::PartialFieldDisclosure,
DisclosureDecision::HumanApprovalRequired,
DisclosureDecision::DualControlRequired,
] {
let exact_authority = authority("task-42");
let disclosure_request = SensitiveDataRequest::new(exact_authority.clone());
let disclosure_scope = DisclosureScope::new(exact_authority.clone(), decision);
let invocation_request = invocation_request(exact_authority.clone(), "provider-private");
let invocation_scope = invocation_scope(exact_authority);

assert_eq!(
evaluate_full_field_model_disclosure(
&disclosure_request,
&disclosure_scope,
&invocation_request,
&invocation_scope,
999,
),
ModelDisclosureDecision::DisclosureNotAuthorized(decision)
);
}
}

#[test]
fn disclosure_authority_cannot_be_composed_with_another_tasks_valid_invocation() {
let disclosed_authority = authority("task-42");
let invocation_authority = authority("task-99");
let disclosure_request = SensitiveDataRequest::new(disclosed_authority.clone());
let disclosure_scope =
DisclosureScope::new(disclosed_authority, DisclosureDecision::FullFieldDisclosure);
let invocation_request = invocation_request(invocation_authority.clone(), "provider-private");
let invocation_scope = invocation_scope(invocation_authority);

assert_eq!(
evaluate_full_field_model_disclosure(
&disclosure_request,
&disclosure_scope,
&invocation_request,
&invocation_scope,
999,
),
ModelDisclosureDecision::AuthorityMismatch
);
}

#[test]
fn invocation_denial_is_preserved_after_full_field_disclosure_authority() {
let exact_authority = authority("task-42");
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(), "provider-other");
let invocation_scope = invocation_scope(exact_authority);

assert_eq!(
evaluate_full_field_model_disclosure(
&disclosure_request,
&disclosure_scope,
&invocation_request,
&invocation_scope,
999,
),
ModelDisclosureDecision::InvocationDenied(ModelInvocationDecision::RouteDenied(
ModelRouteDecision::RouteMismatch
))
);
}
Loading