Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
40515a2
test(sensitive): require exact model route authority
seonghobae Aug 11, 2026
2bc00e1
feat(sensitive): add exact model route policy primitive
seonghobae Aug 11, 2026
bc85284
feat(sensitive): expose model route authority
seonghobae Aug 11, 2026
0d137a8
style(sensitive): apply canonical rustfmt exports
seonghobae Aug 11, 2026
c33e258
style(sensitive): apply canonical rustfmt model route
seonghobae Aug 11, 2026
762009a
test(sensitive): cover malformed route policy scope
seonghobae Aug 11, 2026
3e04503
docs(sensitive): record exact model route authority
seonghobae Aug 11, 2026
9413041
test(sensitive): separate model training route authority
seonghobae Aug 11, 2026
901fa3f
feat(sensitive): bind model training policy separately
seonghobae Aug 11, 2026
83a3dba
docs(sensitive): record distinct training route policy
seonghobae Aug 11, 2026
7a7f441
test(sensitive): bind reviewed model subprocessors
seonghobae Aug 11, 2026
0d67501
feat(sensitive): bind reviewed model subprocessors
seonghobae Aug 11, 2026
2489a60
docs(sensitive): record reviewed subprocessor route policy
seonghobae Aug 11, 2026
90bde2d
test(sensitive): require explicit model export policy
seonghobae Aug 11, 2026
88b1fd4
feat(sensitive): bind model route to export policy
seonghobae Aug 11, 2026
e0527ff
fix(sensitive): keep export routing within clippy API bounds
seonghobae Aug 11, 2026
a07e3eb
test(sensitive): exercise bounded export-policy builder
seonghobae Aug 11, 2026
286f92a
docs(sensitive): record export-bound model route
seonghobae Aug 11, 2026
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

- 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.
- Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors.
Expand Down
4 changes: 4 additions & 0 deletions crates/originweave-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@
#![forbid(unsafe_code)]
#![deny(missing_docs)]

mod model_route;
mod sensitive_data;

pub use model_route::{
ModelRouteDecision, ModelRouteRequest, ModelRouteScope, evaluate_model_route,
};
pub use sensitive_data::{
DataClassification, DisclosureDecision, DisclosureScope, HandleRevocationReason,
HandleUseDecision, HandleUseRequest, SensitiveDataAuthority, SensitiveDataRequest,
Expand Down
201 changes: 201 additions & 0 deletions crates/originweave-policy/src/model_route.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
//! Exact provider/model/region/retention/training/subprocessor/export route admission for sensitive-data workflows.
//!
//! This module evaluates route metadata only. An [`ModelRouteDecision::Authorized`] result does
//! not authorize disclosure of a protected value, authenticate a provider, prove the provider's
//! physical region, invoke a model, or choose a fallback. A trusted broker/orchestrator must
//! independently authorize the permitted value form and derive the actual route identity from
//! trusted runtime configuration.

use crate::sensitive_data::{
DisclosureDecision, DisclosureScope, SensitiveDataAuthority, SensitiveDataRequest,
evaluate_disclosure,
};

const MAX_ROUTE_IDENTIFIER_BYTES: usize = 128;
const DEFAULT_EXPORT_POLICY_ID: &str = "no-export";

/// Result of comparing one requested model route with its exact sensitive-data route authority.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelRouteDecision {
/// The exact sensitive-data authority and every route identifier match.
Authorized,
/// The sensitive-data authority is malformed or does not match the route scope.
AuthorityMismatch,
/// Provider/model/region/retention/training/subprocessor/export metadata is malformed or mismatched.
RouteMismatch,
}

/// One proposed model route for an already classified sensitive field.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelRouteRequest {
authority: SensitiveDataAuthority,
provider_id: String,
model_id: String,
region_id: String,
retention_policy_id: String,
training_policy_id: String,
subprocessor_policy_id: String,
export_policy_id: String,
}

impl ModelRouteRequest {
/// Build a requested model route that is explicitly non-exporting.
///
/// Call [`Self::with_export_policy`] when a workflow requests a separately governed export
/// behavior. Neither constructor nor export-policy selection grants disclosure authority.
#[must_use]
pub fn new(
authority: SensitiveDataAuthority,
provider_id: &str,
model_id: &str,
region_id: &str,
retention_policy_id: &str,
training_policy_id: &str,
subprocessor_policy_id: &str,
) -> Self {
Self {
authority,
provider_id: provider_id.to_owned(),
model_id: model_id.to_owned(),
region_id: region_id.to_owned(),
retention_policy_id: retention_policy_id.to_owned(),
training_policy_id: training_policy_id.to_owned(),
subprocessor_policy_id: subprocessor_policy_id.to_owned(),
export_policy_id: DEFAULT_EXPORT_POLICY_ID.to_owned(),
}
}

/// Select one explicit export-policy identifier for this requested route.
///
/// The identifier describes policy intent only. Matching an export-policy identifier does not
/// execute an export or grant access to protected bytes; the later broker/export boundary must
/// independently authorize and enforce the actual destination and value form.
#[must_use]
pub fn with_export_policy(mut self, export_policy_id: &str) -> Self {
self.export_policy_id = export_policy_id.to_owned();
self
}

fn route_identifiers_are_valid(&self) -> bool {
route_identifier_is_valid(&self.provider_id)
&& route_identifier_is_valid(&self.model_id)
&& route_identifier_is_valid(&self.region_id)
&& route_identifier_is_valid(&self.retention_policy_id)
&& route_identifier_is_valid(&self.training_policy_id)
&& route_identifier_is_valid(&self.subprocessor_policy_id)
&& route_identifier_is_valid(&self.export_policy_id)
}
}

/// Exact model-route authority for one existing sensitive-data authority tuple.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelRouteScope {
authority: SensitiveDataAuthority,
provider_id: String,
model_id: String,
region_id: String,
retention_policy_id: String,
training_policy_id: String,
subprocessor_policy_id: String,
export_policy_id: String,
}

impl ModelRouteScope {
/// Build a non-exporting route scope from trusted policy metadata.
///
/// Route identifiers are validated when the scope is evaluated so malformed policy state
/// remains fail-closed rather than becoming authority merely because request and scope match.
/// Call [`Self::with_export_policy`] when policy explicitly governs another export mode.
#[must_use]
pub fn new(
authority: SensitiveDataAuthority,
provider_id: &str,
model_id: &str,
region_id: &str,
retention_policy_id: &str,
training_policy_id: &str,
subprocessor_policy_id: &str,
) -> Self {
Self {
authority,
provider_id: provider_id.to_owned(),
model_id: model_id.to_owned(),
region_id: region_id.to_owned(),
retention_policy_id: retention_policy_id.to_owned(),
training_policy_id: training_policy_id.to_owned(),
subprocessor_policy_id: subprocessor_policy_id.to_owned(),
export_policy_id: DEFAULT_EXPORT_POLICY_ID.to_owned(),
}
}

/// Select one explicit export-policy identifier for this trusted route scope.
///
/// This is route metadata only. The eventual export path must separately verify protected-value
/// disclosure, destination authority, retention, and any required human or dual-control approval.
#[must_use]
pub fn with_export_policy(mut self, export_policy_id: &str) -> Self {
self.export_policy_id = export_policy_id.to_owned();
self
}

fn route_identifiers_are_valid(&self) -> bool {
route_identifier_is_valid(&self.provider_id)
&& route_identifier_is_valid(&self.model_id)
&& route_identifier_is_valid(&self.region_id)
&& route_identifier_is_valid(&self.retention_policy_id)
&& route_identifier_is_valid(&self.training_policy_id)
&& route_identifier_is_valid(&self.subprocessor_policy_id)
&& route_identifier_is_valid(&self.export_policy_id)
}
}

fn route_identifier_is_valid(identifier: &str) -> bool {
!identifier.is_empty()
&& identifier.len() <= MAX_ROUTE_IDENTIFIER_BYTES
&& identifier.bytes().any(|byte| byte.is_ascii_alphanumeric())
&& identifier
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'))
}

/// Evaluate exact model-route admission without authorizing protected-value disclosure.
///
/// The existing sensitive-data disclosure comparator is reused only to validate that request and
/// scope carry the same complete [`SensitiveDataAuthority`]. Route admission then independently
/// requires valid, exact provider, model, region, retention-policy, training-policy, reviewed
/// subprocessor-policy, and export-policy identifiers. Keeping retention, training, subprocessor,
/// and export authority distinct prevents one permitted provider contract dimension from silently
/// authorizing another. The compatibility constructor defaults export policy to `no-export`, so an
/// omitted export choice never widens route authority. All route identifiers use bounded 1–128 byte
/// ASCII policy tokens containing alphanumeric characters plus `.`, `_`, `:`, and `-`.
#[must_use]
pub fn evaluate_model_route(
request: &ModelRouteRequest,
scope: &ModelRouteScope,
) -> ModelRouteDecision {
let authority_decision = evaluate_disclosure(
&SensitiveDataRequest::new(request.authority.clone()),
&DisclosureScope::new(
scope.authority.clone(),
DisclosureDecision::OpaqueHandleOnly,
),
);
if authority_decision == DisclosureDecision::DenyAccess {
return ModelRouteDecision::AuthorityMismatch;
}

if !request.route_identifiers_are_valid()
|| !scope.route_identifiers_are_valid()
|| request.provider_id != scope.provider_id
|| request.model_id != scope.model_id
|| request.region_id != scope.region_id
|| request.retention_policy_id != scope.retention_policy_id
|| request.training_policy_id != scope.training_policy_id
|| request.subprocessor_policy_id != scope.subprocessor_policy_id
|| request.export_policy_id != scope.export_policy_id
{
ModelRouteDecision::RouteMismatch
} else {
ModelRouteDecision::Authorized
}
}
78 changes: 78 additions & 0 deletions crates/originweave-policy/tests/sensitive_model_export_route.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#![allow(clippy::expect_used)]

//! Export-policy binding regressions for sensitive model-route admission.
//!
//! Retention, training, reviewed subprocessors, and export are independent policy
//! dimensions. A route that is otherwise identical must not inherit export
//! authority merely because its provider/model/region tuple is approved.

use originweave_core::Origin;
use originweave_policy::{
DataClassification, ModelRouteDecision, ModelRouteRequest, ModelRouteScope,
SensitiveDataAuthority, evaluate_model_route,
};

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 request(export_policy_id: &str) -> ModelRouteRequest {
ModelRouteRequest::new(
authority(),
"provider-private",
"model-reviewed-v1",
"kr-central",
"ephemeral-retention",
"no-training",
"subprocessors-reviewed-v1",
)
.with_export_policy(export_policy_id)
}

fn scope(export_policy_id: &str) -> ModelRouteScope {
ModelRouteScope::new(
authority(),
"provider-private",
"model-reviewed-v1",
"kr-central",
"ephemeral-retention",
"no-training",
"subprocessors-reviewed-v1",
)
.with_export_policy(export_policy_id)
}

#[test]
fn exact_export_policy_is_part_of_model_route_authority() {
assert_eq!(
evaluate_model_route(&request("no-export"), &scope("no-export")),
ModelRouteDecision::Authorized
);
assert_eq!(
evaluate_model_route(&request("approved-export"), &scope("no-export")),
ModelRouteDecision::RouteMismatch
);
}

#[test]
fn malformed_requested_export_policy_fails_closed() {
assert_eq!(
evaluate_model_route(&request("invalid export"), &scope("no-export")),
ModelRouteDecision::RouteMismatch
);
}

#[test]
fn malformed_scope_export_policy_fails_closed_against_valid_request() {
assert_eq!(
evaluate_model_route(&request("no-export"), &scope("invalid export")),
ModelRouteDecision::RouteMismatch
);
}
Loading
Loading