From e9a931cfd6a99c22b5431918225c9befa8e6d38f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:11:48 -0700 Subject: [PATCH 01/54] test(core): specify enterprise approval lifecycle --- .../tests/enterprise_approval_lifecycle.rs | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 crates/originweave-core/tests/enterprise_approval_lifecycle.rs diff --git a/crates/originweave-core/tests/enterprise_approval_lifecycle.rs b/crates/originweave-core/tests/enterprise_approval_lifecycle.rs new file mode 100644 index 000000000..4684cb5c1 --- /dev/null +++ b/crates/originweave-core/tests/enterprise_approval_lifecycle.rs @@ -0,0 +1,318 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ApprovalEvidence, ApprovalLifecycleError, + ApprovalLifecycleState, ApprovalPrincipalRef, ApprovalPrincipalRefError, ApprovalScope, + EnterpriseApprovalRequest, Origin, +}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn approval_scope(action: ActionKind) -> ApprovalScope { + ApprovalScope::new( + action, + Origin::parse("https://app.example").expect("test origin must be valid"), + ActionIntentDigest::parse(VALID_INTENT).expect("test intent digest must be valid"), + ) +} + +fn principal(issuer: &str, subject: &str) -> ApprovalPrincipalRef { + ApprovalPrincipalRef::new(issuer, subject).expect("test principal must be valid") +} + +#[test] +fn principal_identity_is_exact_issuer_subject_tuple() { + let first = principal("https://id.example", "user-123"); + let same = principal("https://id.example", "user-123"); + let other_issuer = principal("https://other-id.example", "user-123"); + + assert_eq!(first, same); + assert_ne!(first, other_issuer); + assert_eq!(first.issuer(), "https://id.example"); + assert_eq!(first.subject(), "user-123"); +} + +#[test] +fn principal_rejects_empty_ambiguous_or_oversized_references() { + assert_eq!( + ApprovalPrincipalRef::new("", "user-123"), + Err(ApprovalPrincipalRefError::InvalidIssuer) + ); + assert_eq!( + ApprovalPrincipalRef::new(" https://id.example", "user-123"), + Err(ApprovalPrincipalRefError::InvalidIssuer) + ); + assert_eq!( + ApprovalPrincipalRef::new("https://id.example", "user\n123"), + Err(ApprovalPrincipalRefError::InvalidSubject) + ); + assert_eq!( + ApprovalPrincipalRef::new("https://id.example", &"x".repeat(257)), + Err(ApprovalPrincipalRefError::InvalidSubject) + ); +} + +#[test] +fn constructor_rejects_invalid_lifetime_use_limit_and_non_delegable_consent() { + let requester = principal("https://id.example", "maker"); + let scope = approval_scope(ActionKind::Purchase); + + assert_eq!( + EnterpriseApprovalRequest::new(scope.clone(), requester.clone(), 100, 100, 1), + Err(ApprovalLifecycleError::InvalidValidityWindow) + ); + assert_eq!( + EnterpriseApprovalRequest::new(scope, requester.clone(), 100, 200, 0), + Err(ApprovalLifecycleError::InvalidUseLimit) + ); + assert_eq!( + EnterpriseApprovalRequest::new( + approval_scope(ActionKind::LegalConsent), + requester, + 100, + 200, + 1, + ), + Err(ApprovalLifecycleError::NonDelegableAction) + ); +} + +#[test] +fn distinct_checker_approves_exact_intent_and_single_use_consumes_it() { + let requester = principal("https://id.example", "maker"); + let checker = principal("https://id.example", "checker"); + let scope = approval_scope(ActionKind::Purchase); + let mut request = EnterpriseApprovalRequest::new(scope.clone(), requester.clone(), 100, 200, 1) + .expect("approval request must be valid"); + + assert_eq!(request.state(), ApprovalLifecycleState::ApprovalRequested); + assert_eq!(request.scope(), &scope); + assert_eq!(request.requester(), &requester); + assert_eq!(request.requested_at_epoch_seconds(), 100); + assert_eq!(request.expires_at_epoch_seconds(), 200); + assert_eq!(request.max_uses(), 1); + assert_eq!(request.uses_consumed(), 0); + assert_eq!(request.decision_actor(), None); + + request + .approve(checker.clone(), 110) + .expect("distinct checker must be able to approve"); + assert_eq!(request.state(), ApprovalLifecycleState::Approved); + assert_eq!(request.decision_actor(), Some(&checker)); + + let evidence = request + .consume(&scope, 120) + .expect("approved exact scope must be consumable"); + assert_eq!(evidence, ApprovalEvidence::UserConfirmed(scope)); + assert_eq!(request.uses_consumed(), 1); + assert_eq!(request.state(), ApprovalLifecycleState::Consumed); +} + +#[test] +fn maker_checker_rejects_self_approval_without_mutation() { + let maker = principal("https://id.example", "maker"); + let mut request = EnterpriseApprovalRequest::new( + approval_scope(ActionKind::Delete), + maker.clone(), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + + assert_eq!( + request.approve(maker, 110), + Err(ApprovalLifecycleError::SelfApproval) + ); + assert_eq!(request.state(), ApprovalLifecycleState::ApprovalRequested); + assert_eq!(request.decision_actor(), None); +} + +#[test] +fn scope_mutation_fails_closed_without_consuming_approval() { + let mut request = EnterpriseApprovalRequest::new( + approval_scope(ActionKind::Purchase), + principal("https://id.example", "maker"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + request + .approve(principal("https://id.example", "checker"), 110) + .expect("approval must succeed"); + let mutated_scope = ApprovalScope::new( + ActionKind::Purchase, + Origin::parse("https://other.example").expect("test origin must be valid"), + ActionIntentDigest::parse(VALID_INTENT).expect("test digest must be valid"), + ); + + assert_eq!( + request.consume(&mutated_scope, 120), + Err(ApprovalLifecycleError::ScopeMismatch) + ); + assert_eq!(request.uses_consumed(), 0); + assert_eq!(request.state(), ApprovalLifecycleState::Approved); +} + +#[test] +fn expiry_is_strict_and_transitions_fail_closed_at_deadline() { + let checker = principal("https://id.example", "checker"); + let scope = approval_scope(ActionKind::Submit); + let mut not_yet_approved = EnterpriseApprovalRequest::new( + scope.clone(), + principal("https://id.example", "maker-a"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + + assert_eq!( + not_yet_approved.approve(checker.clone(), 200), + Err(ApprovalLifecycleError::Expired) + ); + assert_eq!(not_yet_approved.state(), ApprovalLifecycleState::Expired); + + let mut approved = EnterpriseApprovalRequest::new( + scope.clone(), + principal("https://id.example", "maker-b"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + approved + .approve(checker, 150) + .expect("approval before deadline must succeed"); + + assert_eq!( + approved.consume(&scope, 200), + Err(ApprovalLifecycleError::Expired) + ); + assert_eq!(approved.state(), ApprovalLifecycleState::Expired); + assert_eq!(approved.uses_consumed(), 0); +} + +#[test] +fn bounded_multi_use_approval_consumes_exactly_the_configured_count() { + let scope = approval_scope(ActionKind::Upload); + let mut request = EnterpriseApprovalRequest::new( + scope.clone(), + principal("https://id.example", "maker"), + 100, + 300, + 2, + ) + .expect("approval request must be valid"); + request + .approve(principal("https://id.example", "checker"), 110) + .expect("approval must succeed"); + + assert!(matches!( + request.consume(&scope, 120), + Ok(ApprovalEvidence::UserConfirmed(_)) + )); + assert_eq!(request.state(), ApprovalLifecycleState::Approved); + assert_eq!(request.uses_consumed(), 1); + assert!(matches!( + request.consume(&scope, 130), + Ok(ApprovalEvidence::UserConfirmed(_)) + )); + assert_eq!(request.state(), ApprovalLifecycleState::Consumed); + assert_eq!(request.uses_consumed(), 2); + assert_eq!( + request.consume(&scope, 140), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Consumed + )) + ); +} + +#[test] +fn denial_withdrawal_and_revocation_are_terminal_and_role_bound() { + let maker = principal("https://id.example", "maker"); + let checker = principal("https://id.example", "checker"); + let stranger = principal("https://id.example", "stranger"); + let scope = approval_scope(ActionKind::ManagePermission); + + let mut denied = EnterpriseApprovalRequest::new(scope.clone(), maker.clone(), 100, 300, 1) + .expect("approval request must be valid"); + assert_eq!( + denied.deny(maker.clone(), 110), + Err(ApprovalLifecycleError::SelfApproval) + ); + denied + .deny(checker.clone(), 110) + .expect("distinct checker must be able to deny"); + assert_eq!(denied.state(), ApprovalLifecycleState::Denied); + assert_eq!(denied.decision_actor(), Some(&checker)); + assert_eq!( + denied.approve(checker.clone(), 120), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Denied + )) + ); + + let mut withdrawn = EnterpriseApprovalRequest::new(scope.clone(), maker.clone(), 100, 300, 1) + .expect("approval request must be valid"); + assert_eq!( + withdrawn.withdraw(&stranger, 110), + Err(ApprovalLifecycleError::RequesterMismatch) + ); + withdrawn + .withdraw(&maker, 110) + .expect("requester must be able to withdraw pending request"); + assert_eq!(withdrawn.state(), ApprovalLifecycleState::Withdrawn); + + let mut revoked = EnterpriseApprovalRequest::new(scope.clone(), maker, 100, 300, 1) + .expect("approval request must be valid"); + revoked + .approve(checker.clone(), 110) + .expect("approval must succeed"); + assert_eq!( + revoked.revoke(&stranger, 120), + Err(ApprovalLifecycleError::DecisionActorMismatch) + ); + assert_eq!(revoked.state(), ApprovalLifecycleState::Approved); + revoked + .revoke(&checker, 120) + .expect("approving checker must be able to revoke"); + assert_eq!(revoked.state(), ApprovalLifecycleState::Revoked); + assert_eq!( + revoked.consume(&scope, 130), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Revoked + )) + ); +} + +#[test] +fn lifecycle_errors_have_stable_display_and_no_hidden_sources() { + let errors = [ + ApprovalLifecycleError::InvalidValidityWindow, + ApprovalLifecycleError::InvalidUseLimit, + ApprovalLifecycleError::NonDelegableAction, + ApprovalLifecycleError::SelfApproval, + ApprovalLifecycleError::RequesterMismatch, + ApprovalLifecycleError::DecisionActorMismatch, + ApprovalLifecycleError::ScopeMismatch, + ApprovalLifecycleError::Expired, + ApprovalLifecycleError::InvalidState(ApprovalLifecycleState::Consumed), + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } + for error in [ + ApprovalPrincipalRefError::InvalidIssuer, + ApprovalPrincipalRefError::InvalidSubject, + ] { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} From fc8dd103e2b3f7dcfe51aee80ea926bc28c3d569 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:16:05 -0700 Subject: [PATCH 02/54] test(policy): locate enterprise approval lifecycle boundary --- .../tests/enterprise_approval_lifecycle.rs | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 crates/originweave-policy/tests/enterprise_approval_lifecycle.rs diff --git a/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs b/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs new file mode 100644 index 000000000..8b0f7b5a3 --- /dev/null +++ b/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs @@ -0,0 +1,320 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ApprovalEvidence, ApprovalScope, Origin, +}; +use originweave_policy::{ + ApprovalLifecycleError, ApprovalLifecycleState, ApprovalPrincipalRef, + ApprovalPrincipalRefError, EnterpriseApprovalRequest, +}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn approval_scope(action: ActionKind) -> ApprovalScope { + ApprovalScope::new( + action, + Origin::parse("https://app.example").expect("test origin must be valid"), + ActionIntentDigest::parse(VALID_INTENT).expect("test intent digest must be valid"), + ) +} + +fn principal(issuer: &str, subject: &str) -> ApprovalPrincipalRef { + ApprovalPrincipalRef::new(issuer, subject).expect("test principal must be valid") +} + +#[test] +fn principal_identity_is_exact_issuer_subject_tuple() { + let first = principal("https://id.example", "user-123"); + let same = principal("https://id.example", "user-123"); + let other_issuer = principal("https://other-id.example", "user-123"); + + assert_eq!(first, same); + assert_ne!(first, other_issuer); + assert_eq!(first.issuer(), "https://id.example"); + assert_eq!(first.subject(), "user-123"); +} + +#[test] +fn principal_rejects_empty_ambiguous_or_oversized_references() { + assert_eq!( + ApprovalPrincipalRef::new("", "user-123"), + Err(ApprovalPrincipalRefError::InvalidIssuer) + ); + assert_eq!( + ApprovalPrincipalRef::new(" https://id.example", "user-123"), + Err(ApprovalPrincipalRefError::InvalidIssuer) + ); + assert_eq!( + ApprovalPrincipalRef::new("https://id.example", "user\n123"), + Err(ApprovalPrincipalRefError::InvalidSubject) + ); + assert_eq!( + ApprovalPrincipalRef::new("https://id.example", &"x".repeat(257)), + Err(ApprovalPrincipalRefError::InvalidSubject) + ); +} + +#[test] +fn constructor_rejects_invalid_lifetime_use_limit_and_non_delegable_consent() { + let requester = principal("https://id.example", "maker"); + let scope = approval_scope(ActionKind::Purchase); + + assert_eq!( + EnterpriseApprovalRequest::new(scope.clone(), requester.clone(), 100, 100, 1), + Err(ApprovalLifecycleError::InvalidValidityWindow) + ); + assert_eq!( + EnterpriseApprovalRequest::new(scope, requester.clone(), 100, 200, 0), + Err(ApprovalLifecycleError::InvalidUseLimit) + ); + assert_eq!( + EnterpriseApprovalRequest::new( + approval_scope(ActionKind::LegalConsent), + requester, + 100, + 200, + 1, + ), + Err(ApprovalLifecycleError::NonDelegableAction) + ); +} + +#[test] +fn distinct_checker_approves_exact_intent_and_single_use_consumes_it() { + let requester = principal("https://id.example", "maker"); + let checker = principal("https://id.example", "checker"); + let scope = approval_scope(ActionKind::Purchase); + let mut request = EnterpriseApprovalRequest::new(scope.clone(), requester.clone(), 100, 200, 1) + .expect("approval request must be valid"); + + assert_eq!(request.state(), ApprovalLifecycleState::ApprovalRequested); + assert_eq!(request.scope(), &scope); + assert_eq!(request.requester(), &requester); + assert_eq!(request.requested_at_epoch_seconds(), 100); + assert_eq!(request.expires_at_epoch_seconds(), 200); + assert_eq!(request.max_uses(), 1); + assert_eq!(request.uses_consumed(), 0); + assert_eq!(request.decision_actor(), None); + + request + .approve(checker.clone(), 110) + .expect("distinct checker must be able to approve"); + assert_eq!(request.state(), ApprovalLifecycleState::Approved); + assert_eq!(request.decision_actor(), Some(&checker)); + + let evidence = request + .consume(&scope, 120) + .expect("approved exact scope must be consumable"); + assert_eq!(evidence, ApprovalEvidence::UserConfirmed(scope)); + assert_eq!(request.uses_consumed(), 1); + assert_eq!(request.state(), ApprovalLifecycleState::Consumed); +} + +#[test] +fn maker_checker_rejects_self_approval_without_mutation() { + let maker = principal("https://id.example", "maker"); + let mut request = EnterpriseApprovalRequest::new( + approval_scope(ActionKind::Delete), + maker.clone(), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + + assert_eq!( + request.approve(maker, 110), + Err(ApprovalLifecycleError::SelfApproval) + ); + assert_eq!(request.state(), ApprovalLifecycleState::ApprovalRequested); + assert_eq!(request.decision_actor(), None); +} + +#[test] +fn scope_mutation_fails_closed_without_consuming_approval() { + let mut request = EnterpriseApprovalRequest::new( + approval_scope(ActionKind::Purchase), + principal("https://id.example", "maker"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + request + .approve(principal("https://id.example", "checker"), 110) + .expect("approval must succeed"); + let mutated_scope = ApprovalScope::new( + ActionKind::Purchase, + Origin::parse("https://other.example").expect("test origin must be valid"), + ActionIntentDigest::parse(VALID_INTENT).expect("test digest must be valid"), + ); + + assert_eq!( + request.consume(&mutated_scope, 120), + Err(ApprovalLifecycleError::ScopeMismatch) + ); + assert_eq!(request.uses_consumed(), 0); + assert_eq!(request.state(), ApprovalLifecycleState::Approved); +} + +#[test] +fn expiry_is_strict_and_transitions_fail_closed_at_deadline() { + let checker = principal("https://id.example", "checker"); + let scope = approval_scope(ActionKind::Submit); + let mut not_yet_approved = EnterpriseApprovalRequest::new( + scope.clone(), + principal("https://id.example", "maker-a"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + + assert_eq!( + not_yet_approved.approve(checker.clone(), 200), + Err(ApprovalLifecycleError::Expired) + ); + assert_eq!(not_yet_approved.state(), ApprovalLifecycleState::Expired); + + let mut approved = EnterpriseApprovalRequest::new( + scope.clone(), + principal("https://id.example", "maker-b"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + approved + .approve(checker, 150) + .expect("approval before deadline must succeed"); + + assert_eq!( + approved.consume(&scope, 200), + Err(ApprovalLifecycleError::Expired) + ); + assert_eq!(approved.state(), ApprovalLifecycleState::Expired); + assert_eq!(approved.uses_consumed(), 0); +} + +#[test] +fn bounded_multi_use_approval_consumes_exactly_the_configured_count() { + let scope = approval_scope(ActionKind::Upload); + let mut request = EnterpriseApprovalRequest::new( + scope.clone(), + principal("https://id.example", "maker"), + 100, + 300, + 2, + ) + .expect("approval request must be valid"); + request + .approve(principal("https://id.example", "checker"), 110) + .expect("approval must succeed"); + + assert!(matches!( + request.consume(&scope, 120), + Ok(ApprovalEvidence::UserConfirmed(_)) + )); + assert_eq!(request.state(), ApprovalLifecycleState::Approved); + assert_eq!(request.uses_consumed(), 1); + assert!(matches!( + request.consume(&scope, 130), + Ok(ApprovalEvidence::UserConfirmed(_)) + )); + assert_eq!(request.state(), ApprovalLifecycleState::Consumed); + assert_eq!(request.uses_consumed(), 2); + assert_eq!( + request.consume(&scope, 140), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Consumed + )) + ); +} + +#[test] +fn denial_withdrawal_and_revocation_are_terminal_and_role_bound() { + let maker = principal("https://id.example", "maker"); + let checker = principal("https://id.example", "checker"); + let stranger = principal("https://id.example", "stranger"); + let scope = approval_scope(ActionKind::ManagePermission); + + let mut denied = EnterpriseApprovalRequest::new(scope.clone(), maker.clone(), 100, 300, 1) + .expect("approval request must be valid"); + assert_eq!( + denied.deny(maker.clone(), 110), + Err(ApprovalLifecycleError::SelfApproval) + ); + denied + .deny(checker.clone(), 110) + .expect("distinct checker must be able to deny"); + assert_eq!(denied.state(), ApprovalLifecycleState::Denied); + assert_eq!(denied.decision_actor(), Some(&checker)); + assert_eq!( + denied.approve(checker.clone(), 120), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Denied + )) + ); + + let mut withdrawn = EnterpriseApprovalRequest::new(scope.clone(), maker.clone(), 100, 300, 1) + .expect("approval request must be valid"); + assert_eq!( + withdrawn.withdraw(&stranger, 110), + Err(ApprovalLifecycleError::RequesterMismatch) + ); + withdrawn + .withdraw(&maker, 110) + .expect("requester must be able to withdraw pending request"); + assert_eq!(withdrawn.state(), ApprovalLifecycleState::Withdrawn); + + let mut revoked = EnterpriseApprovalRequest::new(scope.clone(), maker, 100, 300, 1) + .expect("approval request must be valid"); + revoked + .approve(checker.clone(), 110) + .expect("approval must succeed"); + assert_eq!( + revoked.revoke(&stranger, 120), + Err(ApprovalLifecycleError::DecisionActorMismatch) + ); + assert_eq!(revoked.state(), ApprovalLifecycleState::Approved); + revoked + .revoke(&checker, 120) + .expect("approving checker must be able to revoke"); + assert_eq!(revoked.state(), ApprovalLifecycleState::Revoked); + assert_eq!( + revoked.consume(&scope, 130), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Revoked + )) + ); +} + +#[test] +fn lifecycle_errors_have_stable_display_and_no_hidden_sources() { + let errors = [ + ApprovalLifecycleError::InvalidValidityWindow, + ApprovalLifecycleError::InvalidUseLimit, + ApprovalLifecycleError::NonDelegableAction, + ApprovalLifecycleError::SelfApproval, + ApprovalLifecycleError::RequesterMismatch, + ApprovalLifecycleError::DecisionActorMismatch, + ApprovalLifecycleError::ScopeMismatch, + ApprovalLifecycleError::Expired, + ApprovalLifecycleError::InvalidState(ApprovalLifecycleState::Consumed), + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } + for error in [ + ApprovalPrincipalRefError::InvalidIssuer, + ApprovalPrincipalRefError::InvalidSubject, + ] { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} From e55aa1594f73d3cbd6354def5de21ee885a11b35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:16:18 -0700 Subject: [PATCH 03/54] test(policy): move enterprise approval lifecycle regression --- .../tests/enterprise_approval_lifecycle.rs | 318 ------------------ 1 file changed, 318 deletions(-) delete mode 100644 crates/originweave-core/tests/enterprise_approval_lifecycle.rs diff --git a/crates/originweave-core/tests/enterprise_approval_lifecycle.rs b/crates/originweave-core/tests/enterprise_approval_lifecycle.rs deleted file mode 100644 index 4684cb5c1..000000000 --- a/crates/originweave-core/tests/enterprise_approval_lifecycle.rs +++ /dev/null @@ -1,318 +0,0 @@ -#![allow(clippy::expect_used)] - -use std::error::Error; - -use originweave_core::{ - ActionIntentDigest, ActionKind, ApprovalEvidence, ApprovalLifecycleError, - ApprovalLifecycleState, ApprovalPrincipalRef, ApprovalPrincipalRefError, ApprovalScope, - EnterpriseApprovalRequest, Origin, -}; - -const VALID_INTENT: &str = - "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - -fn approval_scope(action: ActionKind) -> ApprovalScope { - ApprovalScope::new( - action, - Origin::parse("https://app.example").expect("test origin must be valid"), - ActionIntentDigest::parse(VALID_INTENT).expect("test intent digest must be valid"), - ) -} - -fn principal(issuer: &str, subject: &str) -> ApprovalPrincipalRef { - ApprovalPrincipalRef::new(issuer, subject).expect("test principal must be valid") -} - -#[test] -fn principal_identity_is_exact_issuer_subject_tuple() { - let first = principal("https://id.example", "user-123"); - let same = principal("https://id.example", "user-123"); - let other_issuer = principal("https://other-id.example", "user-123"); - - assert_eq!(first, same); - assert_ne!(first, other_issuer); - assert_eq!(first.issuer(), "https://id.example"); - assert_eq!(first.subject(), "user-123"); -} - -#[test] -fn principal_rejects_empty_ambiguous_or_oversized_references() { - assert_eq!( - ApprovalPrincipalRef::new("", "user-123"), - Err(ApprovalPrincipalRefError::InvalidIssuer) - ); - assert_eq!( - ApprovalPrincipalRef::new(" https://id.example", "user-123"), - Err(ApprovalPrincipalRefError::InvalidIssuer) - ); - assert_eq!( - ApprovalPrincipalRef::new("https://id.example", "user\n123"), - Err(ApprovalPrincipalRefError::InvalidSubject) - ); - assert_eq!( - ApprovalPrincipalRef::new("https://id.example", &"x".repeat(257)), - Err(ApprovalPrincipalRefError::InvalidSubject) - ); -} - -#[test] -fn constructor_rejects_invalid_lifetime_use_limit_and_non_delegable_consent() { - let requester = principal("https://id.example", "maker"); - let scope = approval_scope(ActionKind::Purchase); - - assert_eq!( - EnterpriseApprovalRequest::new(scope.clone(), requester.clone(), 100, 100, 1), - Err(ApprovalLifecycleError::InvalidValidityWindow) - ); - assert_eq!( - EnterpriseApprovalRequest::new(scope, requester.clone(), 100, 200, 0), - Err(ApprovalLifecycleError::InvalidUseLimit) - ); - assert_eq!( - EnterpriseApprovalRequest::new( - approval_scope(ActionKind::LegalConsent), - requester, - 100, - 200, - 1, - ), - Err(ApprovalLifecycleError::NonDelegableAction) - ); -} - -#[test] -fn distinct_checker_approves_exact_intent_and_single_use_consumes_it() { - let requester = principal("https://id.example", "maker"); - let checker = principal("https://id.example", "checker"); - let scope = approval_scope(ActionKind::Purchase); - let mut request = EnterpriseApprovalRequest::new(scope.clone(), requester.clone(), 100, 200, 1) - .expect("approval request must be valid"); - - assert_eq!(request.state(), ApprovalLifecycleState::ApprovalRequested); - assert_eq!(request.scope(), &scope); - assert_eq!(request.requester(), &requester); - assert_eq!(request.requested_at_epoch_seconds(), 100); - assert_eq!(request.expires_at_epoch_seconds(), 200); - assert_eq!(request.max_uses(), 1); - assert_eq!(request.uses_consumed(), 0); - assert_eq!(request.decision_actor(), None); - - request - .approve(checker.clone(), 110) - .expect("distinct checker must be able to approve"); - assert_eq!(request.state(), ApprovalLifecycleState::Approved); - assert_eq!(request.decision_actor(), Some(&checker)); - - let evidence = request - .consume(&scope, 120) - .expect("approved exact scope must be consumable"); - assert_eq!(evidence, ApprovalEvidence::UserConfirmed(scope)); - assert_eq!(request.uses_consumed(), 1); - assert_eq!(request.state(), ApprovalLifecycleState::Consumed); -} - -#[test] -fn maker_checker_rejects_self_approval_without_mutation() { - let maker = principal("https://id.example", "maker"); - let mut request = EnterpriseApprovalRequest::new( - approval_scope(ActionKind::Delete), - maker.clone(), - 100, - 200, - 1, - ) - .expect("approval request must be valid"); - - assert_eq!( - request.approve(maker, 110), - Err(ApprovalLifecycleError::SelfApproval) - ); - assert_eq!(request.state(), ApprovalLifecycleState::ApprovalRequested); - assert_eq!(request.decision_actor(), None); -} - -#[test] -fn scope_mutation_fails_closed_without_consuming_approval() { - let mut request = EnterpriseApprovalRequest::new( - approval_scope(ActionKind::Purchase), - principal("https://id.example", "maker"), - 100, - 200, - 1, - ) - .expect("approval request must be valid"); - request - .approve(principal("https://id.example", "checker"), 110) - .expect("approval must succeed"); - let mutated_scope = ApprovalScope::new( - ActionKind::Purchase, - Origin::parse("https://other.example").expect("test origin must be valid"), - ActionIntentDigest::parse(VALID_INTENT).expect("test digest must be valid"), - ); - - assert_eq!( - request.consume(&mutated_scope, 120), - Err(ApprovalLifecycleError::ScopeMismatch) - ); - assert_eq!(request.uses_consumed(), 0); - assert_eq!(request.state(), ApprovalLifecycleState::Approved); -} - -#[test] -fn expiry_is_strict_and_transitions_fail_closed_at_deadline() { - let checker = principal("https://id.example", "checker"); - let scope = approval_scope(ActionKind::Submit); - let mut not_yet_approved = EnterpriseApprovalRequest::new( - scope.clone(), - principal("https://id.example", "maker-a"), - 100, - 200, - 1, - ) - .expect("approval request must be valid"); - - assert_eq!( - not_yet_approved.approve(checker.clone(), 200), - Err(ApprovalLifecycleError::Expired) - ); - assert_eq!(not_yet_approved.state(), ApprovalLifecycleState::Expired); - - let mut approved = EnterpriseApprovalRequest::new( - scope.clone(), - principal("https://id.example", "maker-b"), - 100, - 200, - 1, - ) - .expect("approval request must be valid"); - approved - .approve(checker, 150) - .expect("approval before deadline must succeed"); - - assert_eq!( - approved.consume(&scope, 200), - Err(ApprovalLifecycleError::Expired) - ); - assert_eq!(approved.state(), ApprovalLifecycleState::Expired); - assert_eq!(approved.uses_consumed(), 0); -} - -#[test] -fn bounded_multi_use_approval_consumes_exactly_the_configured_count() { - let scope = approval_scope(ActionKind::Upload); - let mut request = EnterpriseApprovalRequest::new( - scope.clone(), - principal("https://id.example", "maker"), - 100, - 300, - 2, - ) - .expect("approval request must be valid"); - request - .approve(principal("https://id.example", "checker"), 110) - .expect("approval must succeed"); - - assert!(matches!( - request.consume(&scope, 120), - Ok(ApprovalEvidence::UserConfirmed(_)) - )); - assert_eq!(request.state(), ApprovalLifecycleState::Approved); - assert_eq!(request.uses_consumed(), 1); - assert!(matches!( - request.consume(&scope, 130), - Ok(ApprovalEvidence::UserConfirmed(_)) - )); - assert_eq!(request.state(), ApprovalLifecycleState::Consumed); - assert_eq!(request.uses_consumed(), 2); - assert_eq!( - request.consume(&scope, 140), - Err(ApprovalLifecycleError::InvalidState( - ApprovalLifecycleState::Consumed - )) - ); -} - -#[test] -fn denial_withdrawal_and_revocation_are_terminal_and_role_bound() { - let maker = principal("https://id.example", "maker"); - let checker = principal("https://id.example", "checker"); - let stranger = principal("https://id.example", "stranger"); - let scope = approval_scope(ActionKind::ManagePermission); - - let mut denied = EnterpriseApprovalRequest::new(scope.clone(), maker.clone(), 100, 300, 1) - .expect("approval request must be valid"); - assert_eq!( - denied.deny(maker.clone(), 110), - Err(ApprovalLifecycleError::SelfApproval) - ); - denied - .deny(checker.clone(), 110) - .expect("distinct checker must be able to deny"); - assert_eq!(denied.state(), ApprovalLifecycleState::Denied); - assert_eq!(denied.decision_actor(), Some(&checker)); - assert_eq!( - denied.approve(checker.clone(), 120), - Err(ApprovalLifecycleError::InvalidState( - ApprovalLifecycleState::Denied - )) - ); - - let mut withdrawn = EnterpriseApprovalRequest::new(scope.clone(), maker.clone(), 100, 300, 1) - .expect("approval request must be valid"); - assert_eq!( - withdrawn.withdraw(&stranger, 110), - Err(ApprovalLifecycleError::RequesterMismatch) - ); - withdrawn - .withdraw(&maker, 110) - .expect("requester must be able to withdraw pending request"); - assert_eq!(withdrawn.state(), ApprovalLifecycleState::Withdrawn); - - let mut revoked = EnterpriseApprovalRequest::new(scope.clone(), maker, 100, 300, 1) - .expect("approval request must be valid"); - revoked - .approve(checker.clone(), 110) - .expect("approval must succeed"); - assert_eq!( - revoked.revoke(&stranger, 120), - Err(ApprovalLifecycleError::DecisionActorMismatch) - ); - assert_eq!(revoked.state(), ApprovalLifecycleState::Approved); - revoked - .revoke(&checker, 120) - .expect("approving checker must be able to revoke"); - assert_eq!(revoked.state(), ApprovalLifecycleState::Revoked); - assert_eq!( - revoked.consume(&scope, 130), - Err(ApprovalLifecycleError::InvalidState( - ApprovalLifecycleState::Revoked - )) - ); -} - -#[test] -fn lifecycle_errors_have_stable_display_and_no_hidden_sources() { - let errors = [ - ApprovalLifecycleError::InvalidValidityWindow, - ApprovalLifecycleError::InvalidUseLimit, - ApprovalLifecycleError::NonDelegableAction, - ApprovalLifecycleError::SelfApproval, - ApprovalLifecycleError::RequesterMismatch, - ApprovalLifecycleError::DecisionActorMismatch, - ApprovalLifecycleError::ScopeMismatch, - ApprovalLifecycleError::Expired, - ApprovalLifecycleError::InvalidState(ApprovalLifecycleState::Consumed), - ]; - - for error in errors { - assert!(!error.to_string().is_empty()); - assert!(error.source().is_none()); - } - for error in [ - ApprovalPrincipalRefError::InvalidIssuer, - ApprovalPrincipalRefError::InvalidSubject, - ] { - assert!(!error.to_string().is_empty()); - assert!(error.source().is_none()); - } -} From bee1d34d4131aec9b36696f79a8588a11af102b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:19:10 -0700 Subject: [PATCH 04/54] test(policy): format enterprise approval lifecycle regression --- .../originweave-policy/tests/enterprise_approval_lifecycle.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs b/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs index 8b0f7b5a3..a695c117a 100644 --- a/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs +++ b/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs @@ -2,9 +2,7 @@ use std::error::Error; -use originweave_core::{ - ActionIntentDigest, ActionKind, ApprovalEvidence, ApprovalScope, Origin, -}; +use originweave_core::{ActionIntentDigest, ActionKind, ApprovalEvidence, ApprovalScope, Origin}; use originweave_policy::{ ApprovalLifecycleError, ApprovalLifecycleState, ApprovalPrincipalRef, ApprovalPrincipalRefError, EnterpriseApprovalRequest, From 0431c4cef32c3554ad51a3d761b916e42f25fc25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:21:33 -0700 Subject: [PATCH 05/54] feat(policy): implement enterprise approval lifecycle --- .../src/enterprise_approval.rs | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 crates/originweave-policy/src/enterprise_approval.rs diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs new file mode 100644 index 000000000..c1640bb19 --- /dev/null +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -0,0 +1,363 @@ +//! Deterministic enterprise maker-checker approval lifecycle. +//! +//! This module deliberately stores only opaque identity references and exact +//! [`ApprovalScope`] values. Authentication, wall-clock acquisition, durable +//! persistence, signatures, and external identity resolution belong to trusted +//! control-plane boundaries outside this crate. + +use std::fmt; + +use originweave_core::{ActionKind, ApprovalEvidence, ApprovalScope}; + +const MAX_PRINCIPAL_REFERENCE_BYTES: usize = 256; + +/// An opaque, already-authenticated enterprise principal reference. +/// +/// Identity is the exact `(issuer, subject)` tuple. In particular, callers must +/// not merge principals by email address or another mutable display attribute. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ApprovalPrincipalRef { + issuer: String, + subject: String, +} + +impl ApprovalPrincipalRef { + /// Construct an opaque principal reference from trusted identity metadata. + /// + /// This validates only a bounded canonical representation. It does not + /// authenticate the issuer or subject. + pub fn new(issuer: &str, subject: &str) -> Result { + if !principal_component_is_valid(issuer) { + return Err(ApprovalPrincipalRefError::InvalidIssuer); + } + if !principal_component_is_valid(subject) { + return Err(ApprovalPrincipalRefError::InvalidSubject); + } + Ok(Self { + issuer: issuer.to_owned(), + subject: subject.to_owned(), + }) + } + + /// Return the exact trusted issuer reference. + #[must_use] + pub fn issuer(&self) -> &str { + &self.issuer + } + + /// Return the exact issuer-scoped subject reference. + #[must_use] + pub fn subject(&self) -> &str { + &self.subject + } +} + +fn principal_component_is_valid(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_PRINCIPAL_REFERENCE_BYTES + && value.trim() == value + && !value.chars().any(char::is_control) +} + +/// A validation error for an enterprise principal reference. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApprovalPrincipalRefError { + /// The issuer reference was empty, non-canonical, contained controls, or was oversized. + InvalidIssuer, + /// The subject reference was empty, non-canonical, contained controls, or was oversized. + InvalidSubject, +} + +impl fmt::Display for ApprovalPrincipalRefError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidIssuer => formatter.write_str("approval principal issuer is invalid"), + Self::InvalidSubject => formatter.write_str("approval principal subject is invalid"), + } + } +} + +impl std::error::Error for ApprovalPrincipalRefError {} + +/// The fail-closed state of one bounded enterprise approval request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApprovalLifecycleState { + /// A maker requested approval and no checker decision exists yet. + ApprovalRequested, + /// A distinct checker approved the exact immutable scope. + Approved, + /// A distinct checker denied the request. + Denied, + /// The trusted validity deadline was reached before a permitted transition. + Expired, + /// The requesting maker withdrew the pending request. + Withdrawn, + /// Every configured bounded use of the approval has been consumed. + Consumed, + /// The approving checker revoked an approved, not-yet-exhausted request. + Revoked, +} + +/// A deterministic enterprise approval request bound to one immutable action intent. +/// +/// The caller supplies trusted control-plane epoch seconds to transition methods. +/// Model output, page content, or another untrusted source must never supply that +/// time value. This type performs no I/O and does not persist or authenticate data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnterpriseApprovalRequest { + scope: ApprovalScope, + requester: ApprovalPrincipalRef, + decision_actor: Option, + requested_at_epoch_seconds: u64, + expires_at_epoch_seconds: u64, + max_uses: u32, + uses_consumed: u32, + state: ApprovalLifecycleState, +} + +impl EnterpriseApprovalRequest { + /// Create one pending request for an exact scope and bounded validity/use window. + /// + /// `requested_at_epoch_seconds` and `expires_at_epoch_seconds` must come from + /// the same trusted control-plane clock. Legal consent is intentionally + /// non-delegable and cannot enter this approval lifecycle. + pub fn new( + scope: ApprovalScope, + requester: ApprovalPrincipalRef, + requested_at_epoch_seconds: u64, + expires_at_epoch_seconds: u64, + max_uses: u32, + ) -> Result { + if expires_at_epoch_seconds <= requested_at_epoch_seconds { + return Err(ApprovalLifecycleError::InvalidValidityWindow); + } + if max_uses == 0 { + return Err(ApprovalLifecycleError::InvalidUseLimit); + } + if scope.action() == ActionKind::LegalConsent { + return Err(ApprovalLifecycleError::NonDelegableAction); + } + Ok(Self { + scope, + requester, + decision_actor: None, + requested_at_epoch_seconds, + expires_at_epoch_seconds, + max_uses, + uses_consumed: 0, + state: ApprovalLifecycleState::ApprovalRequested, + }) + } + + /// Return the exact action/origin/intent scope covered by the request. + #[must_use] + pub const fn scope(&self) -> &ApprovalScope { + &self.scope + } + + /// Return the maker that created the request. + #[must_use] + pub const fn requester(&self) -> &ApprovalPrincipalRef { + &self.requester + } + + /// Return the checker that approved or denied the request, when present. + #[must_use] + pub const fn decision_actor(&self) -> Option<&ApprovalPrincipalRef> { + self.decision_actor.as_ref() + } + + /// Return the trusted request creation time in Unix epoch seconds. + #[must_use] + pub const fn requested_at_epoch_seconds(&self) -> u64 { + self.requested_at_epoch_seconds + } + + /// Return the exclusive trusted expiry deadline in Unix epoch seconds. + #[must_use] + pub const fn expires_at_epoch_seconds(&self) -> u64 { + self.expires_at_epoch_seconds + } + + /// Return the maximum number of exact-scope consumptions permitted. + #[must_use] + pub const fn max_uses(&self) -> u32 { + self.max_uses + } + + /// Return how many exact-scope consumptions have already occurred. + #[must_use] + pub const fn uses_consumed(&self) -> u32 { + self.uses_consumed + } + + /// Return the current lifecycle state. + #[must_use] + pub const fn state(&self) -> ApprovalLifecycleState { + self.state + } + + /// Approve a pending request as a distinct checker. + /// + /// `now_epoch_seconds` must be trusted control-plane time. Expiry is + /// exclusive: a transition at the deadline fails closed. + pub fn approve( + &mut self, + approver: ApprovalPrincipalRef, + now_epoch_seconds: u64, + ) -> Result<(), ApprovalLifecycleError> { + if self.state != ApprovalLifecycleState::ApprovalRequested { + return Err(ApprovalLifecycleError::InvalidState(self.state)); + } + if now_epoch_seconds >= self.expires_at_epoch_seconds { + self.state = ApprovalLifecycleState::Expired; + return Err(ApprovalLifecycleError::Expired); + } + if approver == self.requester { + return Err(ApprovalLifecycleError::SelfApproval); + } + self.decision_actor = Some(approver); + self.state = ApprovalLifecycleState::Approved; + Ok(()) + } + + /// Deny a pending request as a distinct checker. + /// + /// `now_epoch_seconds` must be trusted control-plane time. + pub fn deny( + &mut self, + actor: ApprovalPrincipalRef, + now_epoch_seconds: u64, + ) -> Result<(), ApprovalLifecycleError> { + if self.state != ApprovalLifecycleState::ApprovalRequested { + return Err(ApprovalLifecycleError::InvalidState(self.state)); + } + if now_epoch_seconds >= self.expires_at_epoch_seconds { + self.state = ApprovalLifecycleState::Expired; + return Err(ApprovalLifecycleError::Expired); + } + if actor == self.requester { + return Err(ApprovalLifecycleError::SelfApproval); + } + self.decision_actor = Some(actor); + self.state = ApprovalLifecycleState::Denied; + Ok(()) + } + + /// Withdraw a pending request as the exact requesting maker. + /// + /// `now_epoch_seconds` must be trusted control-plane time. + pub fn withdraw( + &mut self, + actor: &ApprovalPrincipalRef, + now_epoch_seconds: u64, + ) -> Result<(), ApprovalLifecycleError> { + if self.state != ApprovalLifecycleState::ApprovalRequested { + return Err(ApprovalLifecycleError::InvalidState(self.state)); + } + if now_epoch_seconds >= self.expires_at_epoch_seconds { + self.state = ApprovalLifecycleState::Expired; + return Err(ApprovalLifecycleError::Expired); + } + if actor != &self.requester { + return Err(ApprovalLifecycleError::RequesterMismatch); + } + self.state = ApprovalLifecycleState::Withdrawn; + Ok(()) + } + + /// Consume one use of an approved request for the exact immutable scope. + /// + /// `now_epoch_seconds` must be trusted control-plane time. Scope mismatch + /// does not consume a use. Successful consumption emits user-confirmation + /// evidence only for the request's exact scope. + pub fn consume( + &mut self, + required_scope: &ApprovalScope, + now_epoch_seconds: u64, + ) -> Result { + if self.state != ApprovalLifecycleState::Approved { + return Err(ApprovalLifecycleError::InvalidState(self.state)); + } + if now_epoch_seconds >= self.expires_at_epoch_seconds { + self.state = ApprovalLifecycleState::Expired; + return Err(ApprovalLifecycleError::Expired); + } + if required_scope != &self.scope { + return Err(ApprovalLifecycleError::ScopeMismatch); + } + self.uses_consumed += 1; + if self.uses_consumed == self.max_uses { + self.state = ApprovalLifecycleState::Consumed; + } + Ok(ApprovalEvidence::UserConfirmed(self.scope.clone())) + } + + /// Revoke an approved request as the exact checker that approved it. + /// + /// `now_epoch_seconds` must be trusted control-plane time. + pub fn revoke( + &mut self, + actor: &ApprovalPrincipalRef, + now_epoch_seconds: u64, + ) -> Result<(), ApprovalLifecycleError> { + if self.state != ApprovalLifecycleState::Approved { + return Err(ApprovalLifecycleError::InvalidState(self.state)); + } + if now_epoch_seconds >= self.expires_at_epoch_seconds { + self.state = ApprovalLifecycleState::Expired; + return Err(ApprovalLifecycleError::Expired); + } + if self.decision_actor.as_ref() != Some(actor) { + return Err(ApprovalLifecycleError::DecisionActorMismatch); + } + self.state = ApprovalLifecycleState::Revoked; + Ok(()) + } +} + +/// A fail-closed error produced by an enterprise approval lifecycle transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApprovalLifecycleError { + /// The expiry deadline was not strictly later than the request time. + InvalidValidityWindow, + /// The configured maximum number of uses was zero. + InvalidUseLimit, + /// The requested action is intentionally non-delegable. + NonDelegableAction, + /// The requested transition is not valid from the current terminal or pending state. + InvalidState(ApprovalLifecycleState), + /// The requester attempted to act as their own checker. + SelfApproval, + /// A withdrawal actor did not match the original requester. + RequesterMismatch, + /// A revocation actor did not match the checker that approved the request. + DecisionActorMismatch, + /// The requested action/origin/intent scope did not exactly match the approval. + ScopeMismatch, + /// The trusted exclusive expiry deadline was reached. + Expired, +} + +impl fmt::Display for ApprovalLifecycleError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidValidityWindow => formatter.write_str("approval validity window is invalid"), + Self::InvalidUseLimit => formatter.write_str("approval use limit must be nonzero"), + Self::NonDelegableAction => formatter.write_str("action is not delegable by approval"), + Self::InvalidState(state) => write!( + formatter, + "approval transition is invalid from state {state:?}" + ), + Self::SelfApproval => formatter.write_str("maker and checker must be distinct"), + Self::RequesterMismatch => formatter.write_str("approval requester does not match"), + Self::DecisionActorMismatch => { + formatter.write_str("approval decision actor does not match") + } + Self::ScopeMismatch => formatter.write_str("approval scope does not match"), + Self::Expired => formatter.write_str("approval request has expired"), + } + } +} + +impl std::error::Error for ApprovalLifecycleError {} From 5ca0d7373cea96ede8f0a85b9797690edb3f5c16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:22:12 -0700 Subject: [PATCH 06/54] feat(policy): expose enterprise approval lifecycle --- 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 243ae8ce7..d2ecc6f71 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -7,8 +7,13 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod enterprise_approval; mod sensitive_data; +pub use enterprise_approval::{ + ApprovalLifecycleError, ApprovalLifecycleState, ApprovalPrincipalRef, + ApprovalPrincipalRefError, EnterpriseApprovalRequest, +}; pub use sensitive_data::{ DataClassification, DisclosureDecision, DisclosureScope, HandleUseDecision, HandleUseRequest, SensitiveDataAuthority, SensitiveDataRequest, SensitiveValueHandleScope, evaluate_disclosure, From c63ba3c3f1e40ad6b1df9f0ea649085885a5fe08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:23:54 -0700 Subject: [PATCH 07/54] style(policy): apply canonical Rust formatting --- crates/originweave-policy/src/enterprise_approval.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index c1640bb19..18de396ea 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -342,7 +342,9 @@ pub enum ApprovalLifecycleError { impl fmt::Display for ApprovalLifecycleError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidValidityWindow => formatter.write_str("approval validity window is invalid"), + Self::InvalidValidityWindow => { + formatter.write_str("approval validity window is invalid") + } Self::InvalidUseLimit => formatter.write_str("approval use limit must be nonzero"), Self::NonDelegableAction => formatter.write_str("action is not delegable by approval"), Self::InvalidState(state) => write!( From 0d32899a99a9bcae978264d47454d8a50e882715 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:32:41 -0700 Subject: [PATCH 08/54] test(policy): cover approval terminal transition guards --- .../enterprise_approval_transition_guards.rs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 crates/originweave-policy/tests/enterprise_approval_transition_guards.rs diff --git a/crates/originweave-policy/tests/enterprise_approval_transition_guards.rs b/crates/originweave-policy/tests/enterprise_approval_transition_guards.rs new file mode 100644 index 000000000..c66522080 --- /dev/null +++ b/crates/originweave-policy/tests/enterprise_approval_transition_guards.rs @@ -0,0 +1,100 @@ +#![allow(clippy::expect_used)] + +use originweave_core::{ActionIntentDigest, ActionKind, ApprovalScope, Origin}; +use originweave_policy::{ + ApprovalLifecycleError, ApprovalLifecycleState, ApprovalPrincipalRef, EnterpriseApprovalRequest, +}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn approval_scope() -> ApprovalScope { + ApprovalScope::new( + ActionKind::ManagePermission, + Origin::parse("https://app.example").expect("test origin must be valid"), + ActionIntentDigest::parse(VALID_INTENT).expect("test intent digest must be valid"), + ) +} + +fn principal(subject: &str) -> ApprovalPrincipalRef { + ApprovalPrincipalRef::new("https://id.example", subject) + .expect("test principal must be valid") +} + +#[test] +fn deny_expires_at_deadline_and_then_rejects_further_transitions() { + let mut request = EnterpriseApprovalRequest::new( + approval_scope(), + principal("maker"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + let checker = principal("checker"); + + assert_eq!( + request.deny(checker.clone(), 200), + Err(ApprovalLifecycleError::Expired) + ); + assert_eq!(request.state(), ApprovalLifecycleState::Expired); + assert_eq!( + request.deny(checker, 199), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Expired + )) + ); +} + +#[test] +fn withdraw_expires_at_deadline_and_then_rejects_further_transitions() { + let maker = principal("maker"); + let mut request = EnterpriseApprovalRequest::new( + approval_scope(), + maker.clone(), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + + assert_eq!( + request.withdraw(&maker, 200), + Err(ApprovalLifecycleError::Expired) + ); + assert_eq!(request.state(), ApprovalLifecycleState::Expired); + assert_eq!( + request.withdraw(&maker, 199), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Expired + )) + ); +} + +#[test] +fn revoke_expires_at_deadline_and_then_rejects_further_transitions() { + let checker = principal("checker"); + let mut request = EnterpriseApprovalRequest::new( + approval_scope(), + principal("maker"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + request + .approve(checker.clone(), 150) + .expect("approval before deadline must succeed"); + + assert_eq!( + request.revoke(&checker, 200), + Err(ApprovalLifecycleError::Expired) + ); + assert_eq!(request.state(), ApprovalLifecycleState::Expired); + assert_eq!( + request.revoke(&checker, 199), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Expired + )) + ); +} From 88a50867b2c2699fefabfe4843a2ba53b888dea8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:33:46 -0700 Subject: [PATCH 09/54] style(policy): apply canonical Rust formatting --- .../enterprise_approval_transition_guards.rs | 35 +++++-------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/crates/originweave-policy/tests/enterprise_approval_transition_guards.rs b/crates/originweave-policy/tests/enterprise_approval_transition_guards.rs index c66522080..1778616e3 100644 --- a/crates/originweave-policy/tests/enterprise_approval_transition_guards.rs +++ b/crates/originweave-policy/tests/enterprise_approval_transition_guards.rs @@ -17,20 +17,14 @@ fn approval_scope() -> ApprovalScope { } fn principal(subject: &str) -> ApprovalPrincipalRef { - ApprovalPrincipalRef::new("https://id.example", subject) - .expect("test principal must be valid") + ApprovalPrincipalRef::new("https://id.example", subject).expect("test principal must be valid") } #[test] fn deny_expires_at_deadline_and_then_rejects_further_transitions() { - let mut request = EnterpriseApprovalRequest::new( - approval_scope(), - principal("maker"), - 100, - 200, - 1, - ) - .expect("approval request must be valid"); + let mut request = + EnterpriseApprovalRequest::new(approval_scope(), principal("maker"), 100, 200, 1) + .expect("approval request must be valid"); let checker = principal("checker"); assert_eq!( @@ -49,14 +43,8 @@ fn deny_expires_at_deadline_and_then_rejects_further_transitions() { #[test] fn withdraw_expires_at_deadline_and_then_rejects_further_transitions() { let maker = principal("maker"); - let mut request = EnterpriseApprovalRequest::new( - approval_scope(), - maker.clone(), - 100, - 200, - 1, - ) - .expect("approval request must be valid"); + let mut request = EnterpriseApprovalRequest::new(approval_scope(), maker.clone(), 100, 200, 1) + .expect("approval request must be valid"); assert_eq!( request.withdraw(&maker, 200), @@ -74,14 +62,9 @@ fn withdraw_expires_at_deadline_and_then_rejects_further_transitions() { #[test] fn revoke_expires_at_deadline_and_then_rejects_further_transitions() { let checker = principal("checker"); - let mut request = EnterpriseApprovalRequest::new( - approval_scope(), - principal("maker"), - 100, - 200, - 1, - ) - .expect("approval request must be valid"); + let mut request = + EnterpriseApprovalRequest::new(approval_scope(), principal("maker"), 100, 200, 1) + .expect("approval request must be valid"); request .approve(checker.clone(), 150) .expect("approval before deadline must succeed"); From f1432f5955a4f767eabd77b9e187cf57eedaab5e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:15:26 -0700 Subject: [PATCH 10/54] test(core): reject approval clock rollback --- .../enterprise_approval_time_integrity.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 crates/originweave-policy/tests/enterprise_approval_time_integrity.rs diff --git a/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs b/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs new file mode 100644 index 000000000..355e6b1bb --- /dev/null +++ b/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs @@ -0,0 +1,59 @@ +#![allow(clippy::expect_used)] + +use originweave_core::{ActionIntentDigest, ActionKind, ApprovalScope, Origin}; +use originweave_policy::{ApprovalLifecycleState, ApprovalPrincipalRef, EnterpriseApprovalRequest}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn approval_scope() -> ApprovalScope { + ApprovalScope::new( + ActionKind::ManagePermission, + Origin::parse("https://app.example").expect("test origin must be valid"), + ActionIntentDigest::parse(VALID_INTENT).expect("test intent digest must be valid"), + ) +} + +fn principal(subject: &str) -> ApprovalPrincipalRef { + ApprovalPrincipalRef::new("https://id.example", subject).expect("test principal must be valid") +} + +#[test] +fn approval_cannot_predate_the_request_creation_time() { + let mut request = + EnterpriseApprovalRequest::new(approval_scope(), principal("maker"), 100, 200, 1) + .expect("approval request must be valid"); + + assert!(request.approve(principal("checker"), 99).is_err()); + assert_eq!(request.state(), ApprovalLifecycleState::ApprovalRequested); + assert_eq!(request.decision_actor(), None); +} + +#[test] +fn approved_use_cannot_move_trusted_lifecycle_time_backward() { + let scope = approval_scope(); + let mut request = + EnterpriseApprovalRequest::new(scope.clone(), principal("maker"), 100, 200, 2) + .expect("approval request must be valid"); + request + .approve(principal("checker"), 150) + .expect("approval at monotonic trusted time must succeed"); + + assert!(request.consume(&scope, 149).is_err()); + assert_eq!(request.state(), ApprovalLifecycleState::Approved); + assert_eq!(request.uses_consumed(), 0); +} + +#[test] +fn approved_revocation_cannot_move_trusted_lifecycle_time_backward() { + let checker = principal("checker"); + let mut request = + EnterpriseApprovalRequest::new(approval_scope(), principal("maker"), 100, 200, 1) + .expect("approval request must be valid"); + request + .approve(checker.clone(), 150) + .expect("approval at monotonic trusted time must succeed"); + + assert!(request.revoke(&checker, 149).is_err()); + assert_eq!(request.state(), ApprovalLifecycleState::Approved); +} From c80b55234d5565cbc142a6228ca4aaca9ed9a323 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:17:16 -0700 Subject: [PATCH 11/54] fix(core): enforce monotonic approval lifecycle time --- .../src/enterprise_approval.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index 18de396ea..e89c0f7f2 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -110,6 +110,7 @@ pub struct EnterpriseApprovalRequest { decision_actor: Option, requested_at_epoch_seconds: u64, expires_at_epoch_seconds: u64, + last_transition_at_epoch_seconds: u64, max_uses: u32, uses_consumed: u32, state: ApprovalLifecycleState, @@ -143,6 +144,7 @@ impl EnterpriseApprovalRequest { decision_actor: None, requested_at_epoch_seconds, expires_at_epoch_seconds, + last_transition_at_epoch_seconds: requested_at_epoch_seconds, max_uses, uses_consumed: 0, state: ApprovalLifecycleState::ApprovalRequested, @@ -197,6 +199,16 @@ impl EnterpriseApprovalRequest { self.state } + fn ensure_monotonic_transition_time( + &self, + now_epoch_seconds: u64, + ) -> Result<(), ApprovalLifecycleError> { + if now_epoch_seconds < self.last_transition_at_epoch_seconds { + return Err(ApprovalLifecycleError::NonMonotonicTime); + } + Ok(()) + } + /// Approve a pending request as a distinct checker. /// /// `now_epoch_seconds` must be trusted control-plane time. Expiry is @@ -209,7 +221,9 @@ impl EnterpriseApprovalRequest { if self.state != ApprovalLifecycleState::ApprovalRequested { return Err(ApprovalLifecycleError::InvalidState(self.state)); } + self.ensure_monotonic_transition_time(now_epoch_seconds)?; if now_epoch_seconds >= self.expires_at_epoch_seconds { + self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Expired; return Err(ApprovalLifecycleError::Expired); } @@ -217,6 +231,7 @@ impl EnterpriseApprovalRequest { return Err(ApprovalLifecycleError::SelfApproval); } self.decision_actor = Some(approver); + self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Approved; Ok(()) } @@ -232,7 +247,9 @@ impl EnterpriseApprovalRequest { if self.state != ApprovalLifecycleState::ApprovalRequested { return Err(ApprovalLifecycleError::InvalidState(self.state)); } + self.ensure_monotonic_transition_time(now_epoch_seconds)?; if now_epoch_seconds >= self.expires_at_epoch_seconds { + self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Expired; return Err(ApprovalLifecycleError::Expired); } @@ -240,6 +257,7 @@ impl EnterpriseApprovalRequest { return Err(ApprovalLifecycleError::SelfApproval); } self.decision_actor = Some(actor); + self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Denied; Ok(()) } @@ -255,13 +273,16 @@ impl EnterpriseApprovalRequest { if self.state != ApprovalLifecycleState::ApprovalRequested { return Err(ApprovalLifecycleError::InvalidState(self.state)); } + self.ensure_monotonic_transition_time(now_epoch_seconds)?; if now_epoch_seconds >= self.expires_at_epoch_seconds { + self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Expired; return Err(ApprovalLifecycleError::Expired); } if actor != &self.requester { return Err(ApprovalLifecycleError::RequesterMismatch); } + self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Withdrawn; Ok(()) } @@ -279,7 +300,9 @@ impl EnterpriseApprovalRequest { if self.state != ApprovalLifecycleState::Approved { return Err(ApprovalLifecycleError::InvalidState(self.state)); } + self.ensure_monotonic_transition_time(now_epoch_seconds)?; if now_epoch_seconds >= self.expires_at_epoch_seconds { + self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Expired; return Err(ApprovalLifecycleError::Expired); } @@ -287,6 +310,7 @@ impl EnterpriseApprovalRequest { return Err(ApprovalLifecycleError::ScopeMismatch); } self.uses_consumed += 1; + self.last_transition_at_epoch_seconds = now_epoch_seconds; if self.uses_consumed == self.max_uses { self.state = ApprovalLifecycleState::Consumed; } @@ -304,13 +328,16 @@ impl EnterpriseApprovalRequest { if self.state != ApprovalLifecycleState::Approved { return Err(ApprovalLifecycleError::InvalidState(self.state)); } + self.ensure_monotonic_transition_time(now_epoch_seconds)?; if now_epoch_seconds >= self.expires_at_epoch_seconds { + self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Expired; return Err(ApprovalLifecycleError::Expired); } if self.decision_actor.as_ref() != Some(actor) { return Err(ApprovalLifecycleError::DecisionActorMismatch); } + self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Revoked; Ok(()) } @@ -327,6 +354,8 @@ pub enum ApprovalLifecycleError { NonDelegableAction, /// The requested transition is not valid from the current terminal or pending state. InvalidState(ApprovalLifecycleState), + /// Trusted transition time moved backward relative to the last accepted lifecycle event. + NonMonotonicTime, /// The requester attempted to act as their own checker. SelfApproval, /// A withdrawal actor did not match the original requester. @@ -351,6 +380,9 @@ impl fmt::Display for ApprovalLifecycleError { formatter, "approval transition is invalid from state {state:?}" ), + Self::NonMonotonicTime => { + formatter.write_str("approval transition time moved backward") + } Self::SelfApproval => formatter.write_str("maker and checker must be distinct"), Self::RequesterMismatch => formatter.write_str("approval requester does not match"), Self::DecisionActorMismatch => { From f12036798270a7b154c71ec65e0c91252e562c33 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:17:38 -0700 Subject: [PATCH 12/54] test(core): pin approval rollback error contract --- .../enterprise_approval_time_integrity.rs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs b/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs index 355e6b1bb..bae3b337c 100644 --- a/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs +++ b/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs @@ -1,7 +1,10 @@ #![allow(clippy::expect_used)] use originweave_core::{ActionIntentDigest, ActionKind, ApprovalScope, Origin}; -use originweave_policy::{ApprovalLifecycleState, ApprovalPrincipalRef, EnterpriseApprovalRequest}; +use originweave_policy::{ + ApprovalLifecycleError, ApprovalLifecycleState, ApprovalPrincipalRef, + EnterpriseApprovalRequest, +}; const VALID_INTENT: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -24,7 +27,11 @@ fn approval_cannot_predate_the_request_creation_time() { EnterpriseApprovalRequest::new(approval_scope(), principal("maker"), 100, 200, 1) .expect("approval request must be valid"); - assert!(request.approve(principal("checker"), 99).is_err()); + let error = request + .approve(principal("checker"), 99) + .expect_err("approval before request creation must fail closed"); + assert_eq!(error, ApprovalLifecycleError::NonMonotonicTime); + assert_eq!(error.to_string(), "approval transition time moved backward"); assert_eq!(request.state(), ApprovalLifecycleState::ApprovalRequested); assert_eq!(request.decision_actor(), None); } @@ -39,7 +46,10 @@ fn approved_use_cannot_move_trusted_lifecycle_time_backward() { .approve(principal("checker"), 150) .expect("approval at monotonic trusted time must succeed"); - assert!(request.consume(&scope, 149).is_err()); + assert_eq!( + request.consume(&scope, 149), + Err(ApprovalLifecycleError::NonMonotonicTime) + ); assert_eq!(request.state(), ApprovalLifecycleState::Approved); assert_eq!(request.uses_consumed(), 0); } @@ -54,6 +64,9 @@ fn approved_revocation_cannot_move_trusted_lifecycle_time_backward() { .approve(checker.clone(), 150) .expect("approval at monotonic trusted time must succeed"); - assert!(request.revoke(&checker, 149).is_err()); + assert_eq!( + request.revoke(&checker, 149), + Err(ApprovalLifecycleError::NonMonotonicTime) + ); assert_eq!(request.state(), ApprovalLifecycleState::Approved); } From 369aa6c2607ef4f9cf7881805a2218f82da02ae2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:19:38 -0700 Subject: [PATCH 13/54] style(core): apply canonical approval time formatting --- .../tests/enterprise_approval_time_integrity.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs b/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs index bae3b337c..cf1bd9ee2 100644 --- a/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs +++ b/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs @@ -2,8 +2,7 @@ use originweave_core::{ActionIntentDigest, ActionKind, ApprovalScope, Origin}; use originweave_policy::{ - ApprovalLifecycleError, ApprovalLifecycleState, ApprovalPrincipalRef, - EnterpriseApprovalRequest, + ApprovalLifecycleError, ApprovalLifecycleState, ApprovalPrincipalRef, EnterpriseApprovalRequest, }; const VALID_INTENT: &str = From e634a63b72380a2bf95dc29b939842f0c6446b2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:20:04 -0700 Subject: [PATCH 14/54] test(core): forbid cloning approval accounting state --- .../tests/enterprise_approval_clone_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 crates/originweave-policy/tests/enterprise_approval_clone_contract.rs diff --git a/crates/originweave-policy/tests/enterprise_approval_clone_contract.rs b/crates/originweave-policy/tests/enterprise_approval_clone_contract.rs new file mode 100644 index 000000000..ecd6684fc --- /dev/null +++ b/crates/originweave-policy/tests/enterprise_approval_clone_contract.rs @@ -0,0 +1,15 @@ +use originweave_policy::EnterpriseApprovalRequest; + +#[test] +fn approval_accounting_state_is_not_cloneable() { + trait AmbiguousIfClone { + fn marker() {} + } + + impl AmbiguousIfClone<()> for T {} + + struct CloneImplemented; + impl AmbiguousIfClone for T {} + + let _ = >::marker; +} From f9b5937d8fa0dee400d6d0724fd59289e8dc6add Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:21:39 -0700 Subject: [PATCH 15/54] fix(core): make approval accounting state non-cloneable --- crates/originweave-policy/src/enterprise_approval.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index e89c0f7f2..bd7eb6002 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -103,7 +103,7 @@ pub enum ApprovalLifecycleState { /// The caller supplies trusted control-plane epoch seconds to transition methods. /// Model output, page content, or another untrusted source must never supply that /// time value. This type performs no I/O and does not persist or authenticate data. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub struct EnterpriseApprovalRequest { scope: ApprovalScope, requester: ApprovalPrincipalRef, From 08390f850e0806ae053db97ec726d5c3237a8269 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:24:01 -0700 Subject: [PATCH 16/54] style(core): satisfy strict clone-contract clippy --- .../tests/enterprise_approval_clone_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-policy/tests/enterprise_approval_clone_contract.rs b/crates/originweave-policy/tests/enterprise_approval_clone_contract.rs index ecd6684fc..deb8a9424 100644 --- a/crates/originweave-policy/tests/enterprise_approval_clone_contract.rs +++ b/crates/originweave-policy/tests/enterprise_approval_clone_contract.rs @@ -9,7 +9,7 @@ fn approval_accounting_state_is_not_cloneable() { impl AmbiguousIfClone<()> for T {} struct CloneImplemented; - impl AmbiguousIfClone for T {} + impl AmbiguousIfClone for T {} let _ = >::marker; } From 44947cdc9fb49ec86c36562986943bb711762e36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:30:48 -0700 Subject: [PATCH 17/54] test(policy): cover monotonic pending transitions --- .../enterprise_approval_time_integrity.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs b/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs index cf1bd9ee2..3c0be0c1d 100644 --- a/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs +++ b/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs @@ -35,6 +35,36 @@ fn approval_cannot_predate_the_request_creation_time() { assert_eq!(request.decision_actor(), None); } +#[test] +fn denial_cannot_predate_the_request_creation_time() { + let mut request = + EnterpriseApprovalRequest::new(approval_scope(), principal("maker"), 100, 200, 1) + .expect("approval request must be valid"); + + assert_eq!( + request.deny(principal("checker"), 99), + Err(ApprovalLifecycleError::NonMonotonicTime) + ); + assert_eq!(request.state(), ApprovalLifecycleState::ApprovalRequested); + assert_eq!(request.decision_actor(), None); + assert_eq!(request.uses_consumed(), 0); +} + +#[test] +fn withdrawal_cannot_predate_the_request_creation_time() { + let maker = principal("maker"); + let mut request = EnterpriseApprovalRequest::new(approval_scope(), maker.clone(), 100, 200, 1) + .expect("approval request must be valid"); + + assert_eq!( + request.withdraw(&maker, 99), + Err(ApprovalLifecycleError::NonMonotonicTime) + ); + assert_eq!(request.state(), ApprovalLifecycleState::ApprovalRequested); + assert_eq!(request.decision_actor(), None); + assert_eq!(request.uses_consumed(), 0); +} + #[test] fn approved_use_cannot_move_trusted_lifecycle_time_backward() { let scope = approval_scope(); From e54ce8fa6204b64755d5a2fd9e9d3d36d3619109 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:34:13 -0700 Subject: [PATCH 18/54] docs(changelog): record enterprise approval lifecycle --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d17419927..7ea35235b 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 +- Added a bounded enterprise maker-checker approval lifecycle with opaque principal references, exact immutable-scope approval, distinct maker/checker enforcement, bounded expiry and use counts, monotonic trusted-time transitions, and fail-closed terminal states. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. @@ -75,4 +76,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file From fb6113e52267df0c08003f4e9fe10257a5014026 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:56:16 -0700 Subject: [PATCH 19/54] test(policy): prevent enterprise approval replay --- .../tests/enterprise_approval_single_use.rs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 crates/originweave-policy/tests/enterprise_approval_single_use.rs diff --git a/crates/originweave-policy/tests/enterprise_approval_single_use.rs b/crates/originweave-policy/tests/enterprise_approval_single_use.rs new file mode 100644 index 000000000..13aebaabb --- /dev/null +++ b/crates/originweave-policy/tests/enterprise_approval_single_use.rs @@ -0,0 +1,136 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, Capability, + ExecutionPurpose, InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, + SessionMode, +}; +use originweave_policy::{ + ApprovalLifecycleError, ApprovalLifecycleState, ApprovalPrincipalRef, Decision, DenialReason, + EnterpriseApprovalRequest, EnterpriseApprovalUse, +}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn site() -> Origin { + Origin::parse("https://shop.example").expect("test origin must be valid") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("test intent digest must be valid") +} + +fn scope() -> ApprovalScope { + ApprovalScope::new(ActionKind::Purchase, site(), intent()) +} + +fn principal(subject: &str) -> ApprovalPrincipalRef { + ApprovalPrincipalRef::new("https://id.example", subject).expect("test principal must be valid") +} + +fn purchase_request() -> ActionRequest { + let origin = site(); + ActionRequest::new( + ActionKind::Purchase, + origin.clone(), + origin, + InstructionSource::User, + SecretDelivery::None, + intent(), + ) +} + +fn policy_context(capabilities: BTreeSet) -> PolicyContext { + let origin = site(); + PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + capabilities, + BTreeSet::from([origin.clone()]), + BTreeSet::from([origin]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ) +} + +#[test] +fn consumed_enterprise_approval_is_one_shot_policy_input() { + let approval_scope = scope(); + let mut approval = EnterpriseApprovalRequest::new( + approval_scope.clone(), + principal("maker"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + approval + .approve(principal("checker"), 110) + .expect("distinct checker must approve"); + + let approval_use = approval + .consume(&approval_scope, 120) + .expect("approved exact scope must yield one bounded use"); + assert_eq!(approval.state(), ApprovalLifecycleState::Consumed); + assert_eq!(approval.uses_consumed(), 1); + + let decision = approval_use.evaluate( + &purchase_request(), + &policy_context(BTreeSet::from([Capability::Purchase])), + ); + assert_eq!(decision, Decision::Allow); + assert_eq!( + approval.consume(&approval_scope, 130), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Consumed + )) + ); +} + +#[test] +fn policy_denial_burns_the_already_consumed_approval_use() { + let approval_scope = scope(); + let mut approval = EnterpriseApprovalRequest::new( + approval_scope.clone(), + principal("maker"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + approval + .approve(principal("checker"), 110) + .expect("distinct checker must approve"); + + let approval_use = approval + .consume(&approval_scope, 120) + .expect("approved exact scope must yield one bounded use"); + assert_eq!( + approval_use.evaluate(&purchase_request(), &policy_context(BTreeSet::new())), + Decision::Deny(DenialReason::MissingCapability(Capability::Purchase)) + ); + assert_eq!(approval.state(), ApprovalLifecycleState::Consumed); + assert_eq!( + approval.consume(&approval_scope, 130), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Consumed + )) + ); +} + +#[test] +fn enterprise_approval_use_is_not_cloneable() { + trait AmbiguousIfClone { + fn marker() {} + } + + impl AmbiguousIfClone<()> for T {} + + struct CloneImplemented; + impl AmbiguousIfClone for T {} + + let _ = >::marker; +} From 98ea5c7b37f5dd0b7a5686e9908601764965a21c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:57:59 -0700 Subject: [PATCH 20/54] test(policy): format approval replay regression --- .../tests/enterprise_approval_single_use.rs | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/crates/originweave-policy/tests/enterprise_approval_single_use.rs b/crates/originweave-policy/tests/enterprise_approval_single_use.rs index 13aebaabb..52287c916 100644 --- a/crates/originweave-policy/tests/enterprise_approval_single_use.rs +++ b/crates/originweave-policy/tests/enterprise_approval_single_use.rs @@ -59,14 +59,9 @@ fn policy_context(capabilities: BTreeSet) -> PolicyContext { #[test] fn consumed_enterprise_approval_is_one_shot_policy_input() { let approval_scope = scope(); - let mut approval = EnterpriseApprovalRequest::new( - approval_scope.clone(), - principal("maker"), - 100, - 200, - 1, - ) - .expect("approval request must be valid"); + let mut approval = + EnterpriseApprovalRequest::new(approval_scope.clone(), principal("maker"), 100, 200, 1) + .expect("approval request must be valid"); approval .approve(principal("checker"), 110) .expect("distinct checker must approve"); @@ -93,14 +88,9 @@ fn consumed_enterprise_approval_is_one_shot_policy_input() { #[test] fn policy_denial_burns_the_already_consumed_approval_use() { let approval_scope = scope(); - let mut approval = EnterpriseApprovalRequest::new( - approval_scope.clone(), - principal("maker"), - 100, - 200, - 1, - ) - .expect("approval request must be valid"); + let mut approval = + EnterpriseApprovalRequest::new(approval_scope.clone(), principal("maker"), 100, 200, 1) + .expect("approval request must be valid"); approval .approve(principal("checker"), 110) .expect("distinct checker must approve"); From 618ad1e56b1709910d518b1e27252a3a996664b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 03:03:38 -0700 Subject: [PATCH 21/54] fix(policy): make consumed enterprise approvals one-shot --- .../src/enterprise_approval.rs | 54 +++++++++++++++++-- crates/originweave-policy/src/lib.rs | 2 +- .../tests/enterprise_approval_lifecycle.rs | 19 +++---- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index bd7eb6002..cba298f3d 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -7,7 +7,9 @@ use std::fmt; -use originweave_core::{ActionKind, ApprovalEvidence, ApprovalScope}; +use originweave_core::{ + ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, PolicyContext, +}; const MAX_PRINCIPAL_REFERENCE_BYTES: usize = 256; @@ -98,6 +100,46 @@ pub enum ApprovalLifecycleState { Revoked, } +/// One consumed, non-replayable enterprise approval use. +/// +/// This value is intentionally not [`Clone`]. It is created only by +/// [`EnterpriseApprovalRequest::consume`] after exact-scope, trusted-time, and +/// use-count checks succeed. [`Self::evaluate`] consumes the value, injects the +/// approved scope into a private copy of the supplied policy context, and then +/// delegates to the normal fail-closed policy evaluator. The use is burned even +/// when policy evaluation denies the action or requires a different approval. +/// +/// ```compile_fail +/// # use originweave_core::{ActionRequest, PolicyContext}; +/// # use originweave_policy::EnterpriseApprovalUse; +/// # fn replay_is_rejected( +/// # approval_use: EnterpriseApprovalUse, +/// # request: &ActionRequest, +/// # context: &PolicyContext, +/// # ) { +/// let _ = approval_use.evaluate(request, context); +/// let _ = approval_use.evaluate(request, context); +/// # } +/// ``` +#[derive(Debug, PartialEq, Eq)] +pub struct EnterpriseApprovalUse { + scope: ApprovalScope, +} + +impl EnterpriseApprovalUse { + /// Evaluate exactly one action using this already-consumed approval use. + /// + /// The caller-provided context is cloned so the reusable caller context is + /// never upgraded with replayable approval evidence. This value itself is + /// consumed regardless of the resulting decision. + #[must_use] + pub fn evaluate(self, request: &ActionRequest, context: &PolicyContext) -> crate::Decision { + let mut one_shot_context = context.clone(); + one_shot_context.set_approval(ApprovalEvidence::UserConfirmed(self.scope)); + crate::evaluate(request, &one_shot_context) + } +} + /// A deterministic enterprise approval request bound to one immutable action intent. /// /// The caller supplies trusted control-plane epoch seconds to transition methods. @@ -290,13 +332,13 @@ impl EnterpriseApprovalRequest { /// Consume one use of an approved request for the exact immutable scope. /// /// `now_epoch_seconds` must be trusted control-plane time. Scope mismatch - /// does not consume a use. Successful consumption emits user-confirmation - /// evidence only for the request's exact scope. + /// does not consume a use. Successful consumption returns a non-cloneable + /// [`EnterpriseApprovalUse`] rather than replayable approval evidence. pub fn consume( &mut self, required_scope: &ApprovalScope, now_epoch_seconds: u64, - ) -> Result { + ) -> Result { if self.state != ApprovalLifecycleState::Approved { return Err(ApprovalLifecycleError::InvalidState(self.state)); } @@ -314,7 +356,9 @@ impl EnterpriseApprovalRequest { if self.uses_consumed == self.max_uses { self.state = ApprovalLifecycleState::Consumed; } - Ok(ApprovalEvidence::UserConfirmed(self.scope.clone())) + Ok(EnterpriseApprovalUse { + scope: self.scope.clone(), + }) } /// Revoke an approved request as the exact checker that approved it. diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index d2ecc6f71..e8e67674a 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -12,7 +12,7 @@ mod sensitive_data; pub use enterprise_approval::{ ApprovalLifecycleError, ApprovalLifecycleState, ApprovalPrincipalRef, - ApprovalPrincipalRefError, EnterpriseApprovalRequest, + ApprovalPrincipalRefError, EnterpriseApprovalRequest, EnterpriseApprovalUse, }; pub use sensitive_data::{ DataClassification, DisclosureDecision, DisclosureScope, HandleUseDecision, HandleUseRequest, diff --git a/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs b/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs index a695c117a..f89916c91 100644 --- a/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs +++ b/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs @@ -2,7 +2,7 @@ use std::error::Error; -use originweave_core::{ActionIntentDigest, ActionKind, ApprovalEvidence, ApprovalScope, Origin}; +use originweave_core::{ActionIntentDigest, ActionKind, ApprovalScope, Origin}; use originweave_policy::{ ApprovalLifecycleError, ApprovalLifecycleState, ApprovalPrincipalRef, ApprovalPrincipalRefError, EnterpriseApprovalRequest, @@ -103,10 +103,9 @@ fn distinct_checker_approves_exact_intent_and_single_use_consumes_it() { assert_eq!(request.state(), ApprovalLifecycleState::Approved); assert_eq!(request.decision_actor(), Some(&checker)); - let evidence = request + let _approval_use = request .consume(&scope, 120) .expect("approved exact scope must be consumable"); - assert_eq!(evidence, ApprovalEvidence::UserConfirmed(scope)); assert_eq!(request.uses_consumed(), 1); assert_eq!(request.state(), ApprovalLifecycleState::Consumed); } @@ -212,16 +211,14 @@ fn bounded_multi_use_approval_consumes_exactly_the_configured_count() { .approve(principal("https://id.example", "checker"), 110) .expect("approval must succeed"); - assert!(matches!( - request.consume(&scope, 120), - Ok(ApprovalEvidence::UserConfirmed(_)) - )); + let _first_use = request + .consume(&scope, 120) + .expect("first configured approval use must succeed"); assert_eq!(request.state(), ApprovalLifecycleState::Approved); assert_eq!(request.uses_consumed(), 1); - assert!(matches!( - request.consume(&scope, 130), - Ok(ApprovalEvidence::UserConfirmed(_)) - )); + let _second_use = request + .consume(&scope, 130) + .expect("second configured approval use must succeed"); assert_eq!(request.state(), ApprovalLifecycleState::Consumed); assert_eq!(request.uses_consumed(), 2); assert_eq!( From 2a95e1e2571e14bb8e7f447e82c6cfaf98d856bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 03:06:05 -0700 Subject: [PATCH 22/54] fix(policy): apply canonical approval-use formatting --- crates/originweave-policy/src/enterprise_approval.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index cba298f3d..4a334603f 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -7,9 +7,7 @@ use std::fmt; -use originweave_core::{ - ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, PolicyContext, -}; +use originweave_core::{ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, PolicyContext}; const MAX_PRINCIPAL_REFERENCE_BYTES: usize = 256; From 8015ae6d49058d1c93ee879dcc2bcfd33979835f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 03:09:19 -0700 Subject: [PATCH 23/54] docs(policy): record one-shot enterprise approval authority --- CHANGELOG.md | 5 +- .../0017-enterprise-maker-checker-approval.md | 114 ++++++++++++++++++ docs/adr/README.md | 10 +- 3 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0017-enterprise-maker-checker-approval.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea35235b..3e1729445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added -- Added a bounded enterprise maker-checker approval lifecycle with opaque principal references, exact immutable-scope approval, distinct maker/checker enforcement, bounded expiry and use counts, monotonic trusted-time transitions, and fail-closed terminal states. +- Added a bounded enterprise maker-checker approval lifecycle with opaque principal references, exact immutable-scope approval, distinct maker/checker enforcement, bounded expiry and use counts, monotonic trusted-time transitions, fail-closed terminal states, and non-cloneable one-shot policy-evaluation uses so consumed enterprise authority cannot be replayed as reusable approval evidence. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. @@ -52,6 +52,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. - State-changing actions are same-origin by default. - R3 and R4 approvals are bound to the exact action, target origin, and immutable digest of the complete canonical action intent; R5 legal consent is non-delegable. +- Consumed enterprise maker-checker approvals expose only a non-cloneable one-shot policy-evaluation use; denial still burns the consumed use and the reusable caller policy context is not upgraded with enterprise approval evidence. - Shortened, integer, hexadecimal, and legacy octal-looking IPv4 host spellings are rejected so the policy origin cannot diverge from Chromium host interpretation. - IPv4-mapped IPv6 is canonicalized before destination classification and pin comparison so mapped private or loopback addresses cannot bypass IPv4 policy. - The default destination policy permits only public addresses and denies unspecified, loopback, private, shared, link-local, metadata, documentation, benchmarking, multicast, broadcast, transition, and protocol-reserved destinations. @@ -76,4 +77,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/docs/adr/0017-enterprise-maker-checker-approval.md b/docs/adr/0017-enterprise-maker-checker-approval.md new file mode 100644 index 000000000..2929e0865 --- /dev/null +++ b/docs/adr/0017-enterprise-maker-checker-approval.md @@ -0,0 +1,114 @@ +# ADR 0017: Enterprise maker-checker approval lifecycle + +- Status: Proposed +- Date: 2026-08-23 +- Supersedes: none +- Superseded by: none + +## Context + +OriginWeave already binds approval policy to an immutable `ApprovalScope` containing the action kind, canonical target origin, and complete canonical action-intent digest. Enterprise operation additionally needs a maker-checker lifecycle that can express who requested a bounded approval, who independently decided it, when that decision is valid, how many uses it permits, and when it becomes terminal. + +A lifecycle counter alone is insufficient if successful consumption returns ordinary reusable approval evidence. `ApprovalEvidence` is intentionally a reusable policy-context value for other authority sources; returning it directly from a bounded enterprise request would allow a caller to retain or clone that evidence and evaluate the same approved scope again after the lifecycle has consumed its configured use count or expired. That would separate the recorded lifecycle state from effective execution authority. + +This decision extends, but does not replace, the Accepted agent-safety model in ADR 0002. It defines a branch-local proposed enterprise authority primitive. Protected-main source and live repository policy remain authoritative until this proposal is reviewed and integrated. + +## Decision drivers + +- Bind every delegated enterprise approval to the exact immutable action/origin/intent identity that will be evaluated. +- Enforce separation of duties between the requesting maker and deciding checker. +- Make expiry and transition ordering deterministic under a trusted control-plane clock. +- Make denial, withdrawal, expiry, exhaustion, and revocation fail-closed terminal states. +- Enforce the configured bounded-use count at the same authority boundary that produces executable policy authority. +- Prevent a successfully consumed use from becoming replayable merely because surrounding policy context or generic approval evidence is cloneable. +- Keep R5 legal consent non-delegable. +- Avoid introducing authentication, persistence, signing, workflow, release, or ambient authority into the policy crate. + +## Assumptions and authority boundaries + +`ApprovalPrincipalRef` is an opaque `(issuer, subject)` tuple supplied by an already trusted identity boundary. This crate validates only bounded canonical representation and does not authenticate principals, merge identities by mutable attributes such as email address, or discover tenant membership. + +All lifecycle timestamps are supplied by a trusted control-plane clock. Model output, page content, browser content, or other untrusted inputs must not supply authoritative lifecycle time. Accepted transitions require non-decreasing trusted time; the expiry deadline is exclusive. + +The lifecycle does not persist state, acquire clocks, deliver approvals, render UI, sign evidence, resolve external identity, grant release authority, or authorize any action by itself. Normal `originweave-policy` capability, origin, mode, purpose, robots, secret, and risk gates still apply. + +## Options considered + +### Return reusable `ApprovalEvidence` from `consume` + +Rejected. Even when the lifecycle request itself is non-cloneable, a caller could retain or clone the returned evidence and reuse effective approval after lifecycle exhaustion. The accounting state and executable authority would no longer be coupled. + +### Store approval evidence permanently in the caller's `PolicyContext` + +Rejected. `PolicyContext` is a reusable policy input and is cloneable by design. Mutating it with enterprise approval evidence would make the bounded enterprise use replayable and would implicitly widen the lifetime of authority. + +### Return a linear, non-cloneable approval-use value + +Selected. A successful lifecycle consumption produces exactly one `EnterpriseApprovalUse`. Its policy-evaluation operation consumes `self`, injects the exact approved scope only into a private cloned context for that one evaluation, and delegates to the ordinary fail-closed evaluator. + +## Decision + +`EnterpriseApprovalRequest` is non-cloneable and owns the mutable lifecycle accounting state. It is created for exactly one immutable `ApprovalScope`, requester, trusted validity window, and nonzero `max_uses`. R5 `LegalConsent` is rejected at construction. + +A pending request may be approved or denied only by a principal distinct from the maker. The maker alone may withdraw a pending request. An approved request may be revoked only by the checker that approved it. State validation occurs before transition-specific mutation; trusted transition time must not move backward; and a transition at or after the exclusive expiry deadline moves the live request to `Expired` and fails closed. + +`consume` is permitted only from `Approved`, before expiry, and for an exactly equal `ApprovalScope`. A scope mismatch does not spend a use. A successful consume increments lifecycle accounting immediately and returns a non-cloneable `EnterpriseApprovalUse`. The request becomes `Consumed` when the configured use count is exhausted. + +`EnterpriseApprovalUse::evaluate(self, request, context)` consumes the approval-use value. It clones the supplied policy context privately, installs `ApprovalEvidence::UserConfirmed` for the retained exact scope only in that private copy, and delegates to the normal deterministic policy evaluator. The caller's reusable context is not upgraded. The approval use is burned regardless of whether the evaluator returns `Allow`, `Deny`, or `RequireApproval`. + +No public API converts `EnterpriseApprovalUse` back into reusable `ApprovalEvidence`, exposes its retained scope for later reinjection, or implements `Clone`/`Copy` for it. + +## Consequences + +Enterprise callers receive a capability-like one-shot policy input rather than reusable approval evidence. This aligns effective execution authority with lifecycle accounting: each successful consumption can authorize at most one policy evaluation, and a denied evaluation cannot be retried by replaying the same consumed value. + +Callers that previously expected `consume` to return `ApprovalEvidence` must instead pass the returned `EnterpriseApprovalUse` directly to its consuming `evaluate` method together with the intended request and ordinary policy context. + +The policy crate remains deterministic and I/O-free. Authentication, durable state, distributed concurrency control, operator workflows, signatures, and tenant authority remain outside this ADR. + +## Failure and degraded behavior + +The lifecycle fails closed on invalid validity windows, zero use limits, non-delegable actions, invalid state transitions, trusted-time regression, self-approval, requester/checker role mismatch, exact-scope mismatch, and expiry. + +Once a successful consume occurs, that use is spent even if downstream policy evaluation denies the action. This deliberately prefers loss of a delegated use over replay ambiguity. A caller needing another attempt must obtain another bounded lifecycle use through the authoritative request state rather than recover authority from a failed evaluation. + +If process failure occurs after `consume` but before the one-shot evaluation completes, the use remains consumed. Durable crash-recovery and transactional delivery are separate control-plane concerns and must not be approximated by making the approval use cloneable or replayable. + +## Security / privacy / governance impact + +The decision narrows enterprise approval authority by coupling each configured use to one non-replayable evaluation attempt. It prevents cloning of lifecycle state or consumed execution authority from bypassing `max_uses`, expiry, terminal-state, or revocation semantics. + +The decision does not put credentials, secrets, mutable identity attributes, or raw identity-provider tokens into model context. Principal references remain opaque. Legal consent remains non-delegable. Existing origin, capability, secret-broker, and risk gates are unchanged and continue to fail closed independently of enterprise approval. + +## Tests and acceptance evidence + +The owning PR must retain realistic executable evidence for: + +- distinct maker/checker approval of an exact immutable scope; +- rejection of self-approval, role mismatch, scope mutation, expiry, clock regression, and invalid terminal transitions; +- exact bounded multi-use accounting; +- a single configured use yielding exactly one policy evaluation and rejecting subsequent lifecycle consumption; +- a policy denial burning the already consumed one-shot use; +- compile-time proof that `EnterpriseApprovalRequest` and `EnterpriseApprovalUse` are not cloneable; and +- exact-head repository contracts, Rust 1.97.1 formatting/check/tests/strict Clippy/rustdoc, security scanning where applicable, and exact owned-production function/line/region/branch coverage. + +Historical or predecessor-head results do not establish acceptance for a changed head. + +## Migration and rollback + +Call sites must migrate from storing or passing raw enterprise-produced `ApprovalEvidence` to consuming `EnterpriseApprovalUse::evaluate`. No persistence migration is introduced by this branch. + +A rollback must revert the lifecycle/use API coherently. Reintroducing a direct `consume -> ApprovalEvidence` path, adding `Clone`/`Copy` to lifecycle accounting or consumed-use types, or mutating a reusable caller policy context with enterprise approval evidence is not an acceptable partial rollback because it reopens replay authority. + +## Open follow-ups + +Issue #202 remains the owner for the broader enterprise control plane, including trusted principal authentication, tenant identity, durable state, workflow delivery, operator UI, signed/auditable evidence, and crash-safe/distributed consumption semantics. Those additions must preserve the exact-scope, separation-of-duties, monotonic-time, terminal-state, and one-shot-use invariants defined here. + +## Supersession / reversal conditions + +Supersede this ADR if OriginWeave adopts a different formally bounded authority object that can prove, under concurrency and crash recovery, that one enterprise approval use cannot authorize more policy evaluations than the authoritative lifecycle permits. Any replacement must retain or strengthen exact intent binding, maker-checker separation, trusted-time ordering, fail-closed terminal states, R5 non-delegability, and replay resistance. + +## References + +- [ADR 0002: Agent safety kernel](0002-agent-safety-kernel.md). +- OriginWeave issue #202, enterprise policy and approval control-plane completion criteria. diff --git a/docs/adr/README.md b/docs/adr/README.md index 416231b1c..b8d2f3980 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,6 +57,14 @@ Proposed ADR files are reviewable target architecture without becoming Accepted ADR 0013 and ADR 0014 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; both decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. +### Proposed decision introduced by enterprise approval development + +| ADR | Decision | Status | Governs | +|---|---|---|---| +| [0017](0017-enterprise-maker-checker-approval.md) | Enterprise maker-checker approval lifecycle | Proposed | immutable approval scope, role separation, trusted-time transitions, terminal states and bounded one-shot use | + +ADR 0017 is branch-local reviewable architecture until its owning enterprise approval change integrates. It remains Proposed and does not override Accepted ADR 0002 or protected-main implementation truth. + Other active feature PRs may contain additional Proposed ADRs. Those files are not part of this canonical documentation line until integrated or deliberately reconciled here. Historical PR checks, stale branch state, or chat decisions never transfer ADR acceptance across a changed head. ## Index completeness rule @@ -131,4 +139,4 @@ Material external standards or research belong in APA 7th format in [`../doctori - [`../traceability/README.md`](../traceability/README.md) maps requirements and decisions to implementation and evidence. - [`../DOCUMENTATION_FITNESS.md`](../DOCUMENTATION_FITNESS.md) records semantic completeness and stale/current findings across the graph. -If these artifacts disagree about current implementation, protected-main source, executable tests, built/released artifacts, configuration/migrations, and protected-main operational evidence appropriate to the claim define implementation truth. Accepted ADRs explain governing design decisions; they do not upgrade missing behavior into shipped behavior. The disagreement is a documentation or implementation defect that must be repaired rather than silently rationalized from conversation history. \ No newline at end of file +If these artifacts disagree about current implementation, protected-main source, executable tests, built/released artifacts, configuration/migrations, and protected-main operational evidence appropriate to the claim define implementation truth. Accepted ADRs explain governing design decisions; they do not upgrade missing behavior into shipped behavior. The disagreement is a documentation or implementation defect that must be repaired rather than silently rationalized from conversation history. From ed4cab16cf88c76ce1c145a22d0a274ef2d57263 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 03:12:09 -0700 Subject: [PATCH 24/54] docs(policy): index enterprise approval ADR --- docs/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/README.md b/docs/README.md index 03b573c54..738d210e6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -86,4 +86,10 @@ Proposed ADRs are reviewable architecture memory, not shipped behavior and not a The second group exists only on this documentation branch until the branch integrates. After integration, the heading remains useful historical provenance; it does not promote either ADR from Proposed to Accepted and it does not claim that the described runtime capability is implemented. +### Proposed decision introduced by enterprise approval development + +- [ADR 0017: Enterprise maker-checker approval lifecycle](adr/0017-enterprise-maker-checker-approval.md) + +ADR 0017 is branch-local reviewable architecture until its owning enterprise approval change integrates. It remains Proposed and does not override Accepted ADR 0002 or protected-main implementation truth. + See the [ADR index](adr/README.md) for status rules, required decision structure, supersession rules, and active feature ADRs. The index and each ADR's own status metadata must agree; a PR body, chat transcript, automation prompt, or stale issue reference cannot change ADR status. From ee3cac4ae5d5b4e18b0a14b703c652a1983014b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 21:05:05 -0700 Subject: [PATCH 25/54] test(policy): pin approval-use expiry at evaluation --- .../tests/enterprise_approval_single_use.rs | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/originweave-policy/tests/enterprise_approval_single_use.rs b/crates/originweave-policy/tests/enterprise_approval_single_use.rs index 52287c916..997b626ca 100644 --- a/crates/originweave-policy/tests/enterprise_approval_single_use.rs +++ b/crates/originweave-policy/tests/enterprise_approval_single_use.rs @@ -72,11 +72,12 @@ fn consumed_enterprise_approval_is_one_shot_policy_input() { assert_eq!(approval.state(), ApprovalLifecycleState::Consumed); assert_eq!(approval.uses_consumed(), 1); - let decision = approval_use.evaluate( + let decision = approval_use.evaluate_at( &purchase_request(), &policy_context(BTreeSet::from([Capability::Purchase])), + 130, ); - assert_eq!(decision, Decision::Allow); + assert_eq!(decision, Ok(Decision::Allow)); assert_eq!( approval.consume(&approval_scope, 130), Err(ApprovalLifecycleError::InvalidState( @@ -99,8 +100,10 @@ fn policy_denial_burns_the_already_consumed_approval_use() { .consume(&approval_scope, 120) .expect("approved exact scope must yield one bounded use"); assert_eq!( - approval_use.evaluate(&purchase_request(), &policy_context(BTreeSet::new())), - Decision::Deny(DenialReason::MissingCapability(Capability::Purchase)) + approval_use.evaluate_at(&purchase_request(), &policy_context(BTreeSet::new()), 130), + Ok(Decision::Deny(DenialReason::MissingCapability( + Capability::Purchase + ))) ); assert_eq!(approval.state(), ApprovalLifecycleState::Consumed); assert_eq!( @@ -111,6 +114,53 @@ fn policy_denial_burns_the_already_consumed_approval_use() { ); } +#[test] +fn consumed_approval_use_expires_before_policy_evaluation() { + let approval_scope = scope(); + let mut approval = + EnterpriseApprovalRequest::new(approval_scope.clone(), principal("maker"), 100, 200, 1) + .expect("approval request must be valid"); + approval + .approve(principal("checker"), 110) + .expect("distinct checker must approve"); + + let approval_use = approval + .consume(&approval_scope, 199) + .expect("pre-deadline consumption must succeed"); + assert_eq!(approval.state(), ApprovalLifecycleState::Consumed); + assert_eq!( + approval_use.evaluate_at( + &purchase_request(), + &policy_context(BTreeSet::from([Capability::Purchase])), + 200, + ), + Err(ApprovalLifecycleError::Expired) + ); +} + +#[test] +fn consumed_approval_use_rejects_trusted_time_rollback() { + let approval_scope = scope(); + let mut approval = + EnterpriseApprovalRequest::new(approval_scope.clone(), principal("maker"), 100, 200, 1) + .expect("approval request must be valid"); + approval + .approve(principal("checker"), 110) + .expect("distinct checker must approve"); + + let approval_use = approval + .consume(&approval_scope, 120) + .expect("approved exact scope must yield one bounded use"); + assert_eq!( + approval_use.evaluate_at( + &purchase_request(), + &policy_context(BTreeSet::from([Capability::Purchase])), + 119, + ), + Err(ApprovalLifecycleError::NonMonotonicTime) + ); +} + #[test] fn enterprise_approval_use_is_not_cloneable() { trait AmbiguousIfClone { From 09df6e0d4216fd6668044d59fa7eefcabf3b283c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 21:08:44 -0700 Subject: [PATCH 26/54] fix(policy): revalidate approval expiry at evaluation --- .../src/enterprise_approval.rs | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index 4a334603f..10ccbbdb3 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -102,10 +102,12 @@ pub enum ApprovalLifecycleState { /// /// This value is intentionally not [`Clone`]. It is created only by /// [`EnterpriseApprovalRequest::consume`] after exact-scope, trusted-time, and -/// use-count checks succeed. [`Self::evaluate`] consumes the value, injects the -/// approved scope into a private copy of the supplied policy context, and then -/// delegates to the normal fail-closed policy evaluator. The use is burned even -/// when policy evaluation denies the action or requires a different approval. +/// use-count checks succeed. [`Self::evaluate_at`] consumes the value and first +/// revalidates trusted time against both the consumption time and retained +/// exclusive expiry deadline. Only a still-valid use injects the approved scope +/// into a private copy of the supplied policy context and delegates to the +/// normal fail-closed policy evaluator. The use is burned even when evaluation +/// is denied for expiry, time rollback, policy, or a different approval need. /// /// ```compile_fail /// # use originweave_core::{ActionRequest, PolicyContext}; @@ -114,27 +116,43 @@ pub enum ApprovalLifecycleState { /// # approval_use: EnterpriseApprovalUse, /// # request: &ActionRequest, /// # context: &PolicyContext, +/// # trusted_now: u64, /// # ) { -/// let _ = approval_use.evaluate(request, context); -/// let _ = approval_use.evaluate(request, context); +/// let _ = approval_use.evaluate_at(request, context, trusted_now); +/// let _ = approval_use.evaluate_at(request, context, trusted_now); /// # } /// ``` #[derive(Debug, PartialEq, Eq)] pub struct EnterpriseApprovalUse { scope: ApprovalScope, + consumed_at_epoch_seconds: u64, + expires_at_epoch_seconds: u64, } impl EnterpriseApprovalUse { /// Evaluate exactly one action using this already-consumed approval use. /// - /// The caller-provided context is cloned so the reusable caller context is - /// never upgraded with replayable approval evidence. This value itself is - /// consumed regardless of the resulting decision. - #[must_use] - pub fn evaluate(self, request: &ActionRequest, context: &PolicyContext) -> crate::Decision { + /// `now_epoch_seconds` must come from the same trusted control-plane clock + /// used by the approval lifecycle. Evaluation fails closed if trusted time + /// moves backward before the consumption time or reaches the retained + /// exclusive expiry deadline. The caller-provided context is cloned so the + /// reusable caller context is never upgraded with replayable approval + /// evidence. This value itself is consumed regardless of the result. + pub fn evaluate_at( + self, + request: &ActionRequest, + context: &PolicyContext, + now_epoch_seconds: u64, + ) -> Result { + if now_epoch_seconds < self.consumed_at_epoch_seconds { + return Err(ApprovalLifecycleError::NonMonotonicTime); + } + if now_epoch_seconds >= self.expires_at_epoch_seconds { + return Err(ApprovalLifecycleError::Expired); + } let mut one_shot_context = context.clone(); one_shot_context.set_approval(ApprovalEvidence::UserConfirmed(self.scope)); - crate::evaluate(request, &one_shot_context) + Ok(crate::evaluate(request, &one_shot_context)) } } @@ -331,7 +349,9 @@ impl EnterpriseApprovalRequest { /// /// `now_epoch_seconds` must be trusted control-plane time. Scope mismatch /// does not consume a use. Successful consumption returns a non-cloneable - /// [`EnterpriseApprovalUse`] rather than replayable approval evidence. + /// [`EnterpriseApprovalUse`] that retains the consumption time and expiry + /// deadline for a second trusted-time check immediately before policy + /// evaluation rather than replayable approval evidence. pub fn consume( &mut self, required_scope: &ApprovalScope, @@ -356,6 +376,8 @@ impl EnterpriseApprovalRequest { } Ok(EnterpriseApprovalUse { scope: self.scope.clone(), + consumed_at_epoch_seconds: now_epoch_seconds, + expires_at_epoch_seconds: self.expires_at_epoch_seconds, }) } From ad2b515377ded4493b103a4540bc9a5c1aeea7be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 21:09:21 -0700 Subject: [PATCH 27/54] docs(policy): bound approval use lifetime and crash claims --- .../0017-enterprise-maker-checker-approval.md | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/adr/0017-enterprise-maker-checker-approval.md b/docs/adr/0017-enterprise-maker-checker-approval.md index 2929e0865..db2f88139 100644 --- a/docs/adr/0017-enterprise-maker-checker-approval.md +++ b/docs/adr/0017-enterprise-maker-checker-approval.md @@ -21,6 +21,7 @@ This decision extends, but does not replace, the Accepted agent-safety model in - Make denial, withdrawal, expiry, exhaustion, and revocation fail-closed terminal states. - Enforce the configured bounded-use count at the same authority boundary that produces executable policy authority. - Prevent a successfully consumed use from becoming replayable merely because surrounding policy context or generic approval evidence is cloneable. +- Revalidate approval lifetime immediately before policy evaluation so a pre-expiry consume cannot authorize after the deadline. - Keep R5 legal consent non-delegable. - Avoid introducing authentication, persistence, signing, workflow, release, or ambient authority into the policy crate. @@ -28,7 +29,7 @@ This decision extends, but does not replace, the Accepted agent-safety model in `ApprovalPrincipalRef` is an opaque `(issuer, subject)` tuple supplied by an already trusted identity boundary. This crate validates only bounded canonical representation and does not authenticate principals, merge identities by mutable attributes such as email address, or discover tenant membership. -All lifecycle timestamps are supplied by a trusted control-plane clock. Model output, page content, browser content, or other untrusted inputs must not supply authoritative lifecycle time. Accepted transitions require non-decreasing trusted time; the expiry deadline is exclusive. +All lifecycle timestamps are supplied by a trusted control-plane clock. Model output, page content, browser content, or other untrusted inputs must not supply authoritative lifecycle time. Accepted transitions require non-decreasing trusted time; the expiry deadline is exclusive. A consumed approval use retains its consumption time and the same exclusive expiry deadline so the consuming policy evaluation can revalidate trusted time immediately before introducing approval evidence. The lifecycle does not persist state, acquire clocks, deliver approvals, render UI, sign evidence, resolve external identity, grant release authority, or authorize any action by itself. Normal `originweave-policy` capability, origin, mode, purpose, robots, secret, and risk gates still apply. @@ -44,7 +45,7 @@ Rejected. `PolicyContext` is a reusable policy input and is cloneable by design. ### Return a linear, non-cloneable approval-use value -Selected. A successful lifecycle consumption produces exactly one `EnterpriseApprovalUse`. Its policy-evaluation operation consumes `self`, injects the exact approved scope only into a private cloned context for that one evaluation, and delegates to the ordinary fail-closed evaluator. +Selected. A successful lifecycle consumption produces exactly one `EnterpriseApprovalUse`. Its policy-evaluation operation consumes `self`, requires current trusted time, rejects time rollback or expiry, injects the exact approved scope only into a private cloned context for that one evaluation, and delegates to the ordinary fail-closed evaluator. ## Decision @@ -52,31 +53,31 @@ Selected. A successful lifecycle consumption produces exactly one `EnterpriseApp A pending request may be approved or denied only by a principal distinct from the maker. The maker alone may withdraw a pending request. An approved request may be revoked only by the checker that approved it. State validation occurs before transition-specific mutation; trusted transition time must not move backward; and a transition at or after the exclusive expiry deadline moves the live request to `Expired` and fails closed. -`consume` is permitted only from `Approved`, before expiry, and for an exactly equal `ApprovalScope`. A scope mismatch does not spend a use. A successful consume increments lifecycle accounting immediately and returns a non-cloneable `EnterpriseApprovalUse`. The request becomes `Consumed` when the configured use count is exhausted. +`consume` is permitted only from `Approved`, before expiry, and for an exactly equal `ApprovalScope`. A scope mismatch does not spend a use. A successful consume increments lifecycle accounting immediately and returns a non-cloneable `EnterpriseApprovalUse` that retains the exact scope, consumption time, and exclusive expiry deadline. The request becomes `Consumed` when the configured use count is exhausted. -`EnterpriseApprovalUse::evaluate(self, request, context)` consumes the approval-use value. It clones the supplied policy context privately, installs `ApprovalEvidence::UserConfirmed` for the retained exact scope only in that private copy, and delegates to the normal deterministic policy evaluator. The caller's reusable context is not upgraded. The approval use is burned regardless of whether the evaluator returns `Allow`, `Deny`, or `RequireApproval`. +`EnterpriseApprovalUse::evaluate_at(self, request, context, now_epoch_seconds)` consumes the approval-use value. It first rejects trusted time earlier than the recorded consumption time with `NonMonotonicTime` and rejects evaluation at or after the retained exclusive deadline with `Expired`. Only then does it clone the supplied policy context privately, install `ApprovalEvidence::UserConfirmed` for the retained exact scope in that private copy, and delegate to the normal deterministic policy evaluator. The caller's reusable context is not upgraded. The approval use is burned regardless of whether evaluation returns a policy decision or fails the evaluation-time validity checks. -No public API converts `EnterpriseApprovalUse` back into reusable `ApprovalEvidence`, exposes its retained scope for later reinjection, or implements `Clone`/`Copy` for it. +No public API converts `EnterpriseApprovalUse` back into reusable `ApprovalEvidence`, exposes its retained scope for later reinjection, or implements `Clone`/`Copy` for it. There is no untimed evaluation entry point that can bypass the retained expiry boundary. ## Consequences -Enterprise callers receive a capability-like one-shot policy input rather than reusable approval evidence. This aligns effective execution authority with lifecycle accounting: each successful consumption can authorize at most one policy evaluation, and a denied evaluation cannot be retried by replaying the same consumed value. +Enterprise callers receive a capability-like one-shot policy input rather than reusable approval evidence. This aligns effective execution authority with lifecycle accounting: each successful consumption can authorize at most one still-valid policy evaluation, and a denied or expired evaluation cannot be retried by replaying the same consumed value. -Callers that previously expected `consume` to return `ApprovalEvidence` must instead pass the returned `EnterpriseApprovalUse` directly to its consuming `evaluate` method together with the intended request and ordinary policy context. +Callers that previously expected `consume` to return `ApprovalEvidence` must instead pass the returned `EnterpriseApprovalUse` directly to its consuming `evaluate_at` method together with the intended request, ordinary policy context, and trusted current epoch seconds. -The policy crate remains deterministic and I/O-free. Authentication, durable state, distributed concurrency control, operator workflows, signatures, and tenant authority remain outside this ADR. +The policy crate remains deterministic and I/O-free. Authentication, clock acquisition, durable state, distributed concurrency control, operator workflows, signatures, and tenant authority remain outside this ADR. ## Failure and degraded behavior -The lifecycle fails closed on invalid validity windows, zero use limits, non-delegable actions, invalid state transitions, trusted-time regression, self-approval, requester/checker role mismatch, exact-scope mismatch, and expiry. +The lifecycle fails closed on invalid validity windows, zero use limits, non-delegable actions, invalid state transitions, trusted-time regression, self-approval, requester/checker role mismatch, exact-scope mismatch, and expiry. The consumed-use evaluation repeats the trusted-time regression and expiry checks before it can introduce approval evidence. -Once a successful consume occurs, that use is spent even if downstream policy evaluation denies the action. This deliberately prefers loss of a delegated use over replay ambiguity. A caller needing another attempt must obtain another bounded lifecycle use through the authoritative request state rather than recover authority from a failed evaluation. +Within the live `EnterpriseApprovalRequest` instance, a successful consume spends that use even if downstream policy evaluation denies the action or the resulting one-shot value later fails its evaluation-time validity check. This deliberately prefers loss of a delegated use over replay ambiguity. A caller needing another attempt must obtain another bounded lifecycle use through the authoritative request state rather than recover authority from a failed evaluation. -If process failure occurs after `consume` but before the one-shot evaluation completes, the use remains consumed. Durable crash-recovery and transactional delivery are separate control-plane concerns and must not be approximated by making the approval use cloneable or replayable. +If process failure occurs after `consume` but before the one-shot evaluation completes, the in-memory request has advanced, but this crate does not persist that state across restart. Crash-safe replay prevention requires an external durable control plane that atomically preserves authoritative consumption state and recovery evidence. It must not be approximated by making the approval use cloneable or replayable. ## Security / privacy / governance impact -The decision narrows enterprise approval authority by coupling each configured use to one non-replayable evaluation attempt. It prevents cloning of lifecycle state or consumed execution authority from bypassing `max_uses`, expiry, terminal-state, or revocation semantics. +The decision narrows enterprise approval authority by coupling each configured use to one non-replayable, still-valid evaluation attempt. It prevents cloning of lifecycle state or consumed execution authority from bypassing `max_uses`, expiry, terminal-state, or revocation semantics, and prevents a token created immediately before expiry from being exercised after its approval deadline. The decision does not put credentials, secrets, mutable identity attributes, or raw identity-provider tokens into model context. Principal references remain opaque. Legal consent remains non-delegable. Existing origin, capability, secret-broker, and risk gates are unchanged and continue to fail closed independently of enterprise approval. @@ -89,6 +90,7 @@ The owning PR must retain realistic executable evidence for: - exact bounded multi-use accounting; - a single configured use yielding exactly one policy evaluation and rejecting subsequent lifecycle consumption; - a policy denial burning the already consumed one-shot use; +- evaluation at the retained expiry deadline and trusted-time rollback after consumption both failing closed before approval evidence is applied; - compile-time proof that `EnterpriseApprovalRequest` and `EnterpriseApprovalUse` are not cloneable; and - exact-head repository contracts, Rust 1.97.1 formatting/check/tests/strict Clippy/rustdoc, security scanning where applicable, and exact owned-production function/line/region/branch coverage. @@ -96,17 +98,17 @@ Historical or predecessor-head results do not establish acceptance for a changed ## Migration and rollback -Call sites must migrate from storing or passing raw enterprise-produced `ApprovalEvidence` to consuming `EnterpriseApprovalUse::evaluate`. No persistence migration is introduced by this branch. +Call sites must migrate from storing or passing raw enterprise-produced `ApprovalEvidence` to consuming `EnterpriseApprovalUse::evaluate_at` with trusted current time. No persistence migration is introduced by this branch. -A rollback must revert the lifecycle/use API coherently. Reintroducing a direct `consume -> ApprovalEvidence` path, adding `Clone`/`Copy` to lifecycle accounting or consumed-use types, or mutating a reusable caller policy context with enterprise approval evidence is not an acceptable partial rollback because it reopens replay authority. +A rollback must revert the lifecycle/use API coherently. Reintroducing a direct `consume -> ApprovalEvidence` path, adding `Clone`/`Copy` to lifecycle accounting or consumed-use types, restoring an untimed evaluation path, or mutating a reusable caller policy context with enterprise approval evidence is not an acceptable partial rollback because it reopens replay or post-expiry authority. ## Open follow-ups -Issue #202 remains the owner for the broader enterprise control plane, including trusted principal authentication, tenant identity, durable state, workflow delivery, operator UI, signed/auditable evidence, and crash-safe/distributed consumption semantics. Those additions must preserve the exact-scope, separation-of-duties, monotonic-time, terminal-state, and one-shot-use invariants defined here. +Issue #202 remains the owner for the broader enterprise control plane, including trusted principal authentication, tenant identity, durable state, workflow delivery, operator UI, signed/auditable evidence, and crash-safe/distributed consumption semantics. Those additions must preserve the exact-scope, separation-of-duties, monotonic-time, terminal-state, evaluation-time expiry, and one-shot-use invariants defined here. ## Supersession / reversal conditions -Supersede this ADR if OriginWeave adopts a different formally bounded authority object that can prove, under concurrency and crash recovery, that one enterprise approval use cannot authorize more policy evaluations than the authoritative lifecycle permits. Any replacement must retain or strengthen exact intent binding, maker-checker separation, trusted-time ordering, fail-closed terminal states, R5 non-delegability, and replay resistance. +Supersede this ADR if OriginWeave adopts a different formally bounded authority object that can prove, under concurrency and crash recovery, that one enterprise approval use cannot authorize more policy evaluations than the authoritative lifecycle permits. Any replacement must retain or strengthen exact intent binding, maker-checker separation, trusted-time ordering, fail-closed terminal states, evaluation-time expiry, R5 non-delegability, and replay resistance. ## References From 158fe1c02a989a74f4439903041a19130fa0f7b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 21:13:59 -0700 Subject: [PATCH 28/54] docs(policy): make checker authorization boundary explicit --- docs/adr/0017-enterprise-maker-checker-approval.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/adr/0017-enterprise-maker-checker-approval.md b/docs/adr/0017-enterprise-maker-checker-approval.md index db2f88139..f1957bbb2 100644 --- a/docs/adr/0017-enterprise-maker-checker-approval.md +++ b/docs/adr/0017-enterprise-maker-checker-approval.md @@ -29,6 +29,8 @@ This decision extends, but does not replace, the Accepted agent-safety model in `ApprovalPrincipalRef` is an opaque `(issuer, subject)` tuple supplied by an already trusted identity boundary. This crate validates only bounded canonical representation and does not authenticate principals, merge identities by mutable attributes such as email address, or discover tenant membership. +Before calling `EnterpriseApprovalRequest::approve` or `EnterpriseApprovalRequest::deny`, the trusted identity or workflow boundary must verify that the proposed checker has the required checker role, belongs to the request's authoritative tenant, and is authorized for the exact approval scope. Those lifecycle methods enforce requester/checker identity separation and state/time invariants only; they do not establish checker eligibility, tenant membership, or policy scope by themselves. + All lifecycle timestamps are supplied by a trusted control-plane clock. Model output, page content, browser content, or other untrusted inputs must not supply authoritative lifecycle time. Accepted transitions require non-decreasing trusted time; the expiry deadline is exclusive. A consumed approval use retains its consumption time and the same exclusive expiry deadline so the consuming policy evaluation can revalidate trusted time immediately before introducing approval evidence. The lifecycle does not persist state, acquire clocks, deliver approvals, render UI, sign evidence, resolve external identity, grant release authority, or authorize any action by itself. Normal `originweave-policy` capability, origin, mode, purpose, robots, secret, and risk gates still apply. From bc6c99f5190a50cf8f5a2e1e676fcf8190bccda5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:04:16 -0700 Subject: [PATCH 29/54] test(policy): pin revocation of outstanding enterprise approval use --- .../tests/enterprise_approval_single_use.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/originweave-policy/tests/enterprise_approval_single_use.rs b/crates/originweave-policy/tests/enterprise_approval_single_use.rs index 997b626ca..6d906818a 100644 --- a/crates/originweave-policy/tests/enterprise_approval_single_use.rs +++ b/crates/originweave-policy/tests/enterprise_approval_single_use.rs @@ -86,6 +86,39 @@ fn consumed_enterprise_approval_is_one_shot_policy_input() { ); } +#[test] +fn checker_revocation_invalidates_an_already_consumed_unexecuted_use() { + let approval_scope = scope(); + let checker = principal("checker"); + let mut approval = + EnterpriseApprovalRequest::new(approval_scope.clone(), principal("maker"), 100, 200, 2) + .expect("approval request must be valid"); + approval + .approve(checker.clone(), 110) + .expect("distinct checker must approve"); + + let approval_use = approval + .consume(&approval_scope, 120) + .expect("first bounded use must be issued while approval remains active"); + assert_eq!(approval.state(), ApprovalLifecycleState::Approved); + assert_eq!(approval.uses_consumed(), 1); + approval + .revoke(&checker, 125) + .expect("approving checker must revoke remaining delegated authority"); + assert_eq!(approval.state(), ApprovalLifecycleState::Revoked); + + assert_eq!( + approval_use.evaluate_at( + &purchase_request(), + &policy_context(BTreeSet::from([Capability::Purchase])), + 130, + ), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Revoked + )) + ); +} + #[test] fn policy_denial_burns_the_already_consumed_approval_use() { let approval_scope = scope(); From cc5974682db6845f8ea9d9c874a94a9ab4094958 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:07:08 -0700 Subject: [PATCH 30/54] fix(policy): invalidate outstanding uses after checker revocation --- .../src/enterprise_approval.rs | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index 10ccbbdb3..fd6baa2cb 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -5,7 +5,10 @@ //! persistence, signatures, and external identity resolution belong to trusted //! control-plane boundaries outside this crate. -use std::fmt; +use std::{ + fmt, + sync::{Arc, OnceLock}, +}; use originweave_core::{ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, PolicyContext}; @@ -104,10 +107,12 @@ pub enum ApprovalLifecycleState { /// [`EnterpriseApprovalRequest::consume`] after exact-scope, trusted-time, and /// use-count checks succeed. [`Self::evaluate_at`] consumes the value and first /// revalidates trusted time against both the consumption time and retained -/// exclusive expiry deadline. Only a still-valid use injects the approved scope -/// into a private copy of the supplied policy context and delegates to the -/// normal fail-closed policy evaluator. The use is burned even when evaluation -/// is denied for expiry, time rollback, policy, or a different approval need. +/// exclusive expiry deadline, then rejects checker revocation that occurred +/// after the use was issued but before evaluation. Only a still-valid use +/// injects the approved scope into a private copy of the supplied policy context +/// and delegates to the normal fail-closed policy evaluator. The use is burned +/// even when evaluation is denied for expiry, time rollback, revocation, policy, +/// or a different approval need. /// /// ```compile_fail /// # use originweave_core::{ActionRequest, PolicyContext}; @@ -127,6 +132,7 @@ pub struct EnterpriseApprovalUse { scope: ApprovalScope, consumed_at_epoch_seconds: u64, expires_at_epoch_seconds: u64, + revocation_signal: Arc>, } impl EnterpriseApprovalUse { @@ -134,9 +140,10 @@ impl EnterpriseApprovalUse { /// /// `now_epoch_seconds` must come from the same trusted control-plane clock /// used by the approval lifecycle. Evaluation fails closed if trusted time - /// moves backward before the consumption time or reaches the retained - /// exclusive expiry deadline. The caller-provided context is cloned so the - /// reusable caller context is never upgraded with replayable approval + /// moves backward before the consumption time, reaches the retained + /// exclusive expiry deadline, or the approving checker revoked the live + /// request after this use was issued. The caller-provided context is cloned + /// so the reusable caller context is never upgraded with replayable approval /// evidence. This value itself is consumed regardless of the result. pub fn evaluate_at( self, @@ -150,6 +157,11 @@ impl EnterpriseApprovalUse { if now_epoch_seconds >= self.expires_at_epoch_seconds { return Err(ApprovalLifecycleError::Expired); } + if self.revocation_signal.get().is_some() { + return Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Revoked, + )); + } let mut one_shot_context = context.clone(); one_shot_context.set_approval(ApprovalEvidence::UserConfirmed(self.scope)); Ok(crate::evaluate(request, &one_shot_context)) @@ -172,6 +184,7 @@ pub struct EnterpriseApprovalRequest { max_uses: u32, uses_consumed: u32, state: ApprovalLifecycleState, + revocation_signal: Arc>, } impl EnterpriseApprovalRequest { @@ -206,6 +219,7 @@ impl EnterpriseApprovalRequest { max_uses, uses_consumed: 0, state: ApprovalLifecycleState::ApprovalRequested, + revocation_signal: Arc::new(OnceLock::new()), }) } @@ -349,9 +363,10 @@ impl EnterpriseApprovalRequest { /// /// `now_epoch_seconds` must be trusted control-plane time. Scope mismatch /// does not consume a use. Successful consumption returns a non-cloneable - /// [`EnterpriseApprovalUse`] that retains the consumption time and expiry - /// deadline for a second trusted-time check immediately before policy - /// evaluation rather than replayable approval evidence. + /// [`EnterpriseApprovalUse`] that retains the consumption time, expiry + /// deadline, and a shared monotonic revocation signal for a second + /// validity check immediately before policy evaluation rather than + /// replayable approval evidence. pub fn consume( &mut self, required_scope: &ApprovalScope, @@ -378,12 +393,15 @@ impl EnterpriseApprovalRequest { scope: self.scope.clone(), consumed_at_epoch_seconds: now_epoch_seconds, expires_at_epoch_seconds: self.expires_at_epoch_seconds, + revocation_signal: Arc::clone(&self.revocation_signal), }) } /// Revoke an approved request as the exact checker that approved it. /// - /// `now_epoch_seconds` must be trusted control-plane time. + /// `now_epoch_seconds` must be trusted control-plane time. Revocation also + /// invalidates already-consumed one-shot uses that have not yet begun their + /// evaluation-time validity check. pub fn revoke( &mut self, actor: &ApprovalPrincipalRef, @@ -401,6 +419,7 @@ impl EnterpriseApprovalRequest { if self.decision_actor.as_ref() != Some(actor) { return Err(ApprovalLifecycleError::DecisionActorMismatch); } + self.revocation_signal.get_or_init(|| ()); self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Revoked; Ok(()) From 185cf67cc85efc98b1cc8bcf4379fd79d367ff55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:10:48 -0700 Subject: [PATCH 31/54] docs(policy): bind outstanding uses to checker revocation --- .../0017-enterprise-maker-checker-approval.md | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/adr/0017-enterprise-maker-checker-approval.md b/docs/adr/0017-enterprise-maker-checker-approval.md index f1957bbb2..b379cf0af 100644 --- a/docs/adr/0017-enterprise-maker-checker-approval.md +++ b/docs/adr/0017-enterprise-maker-checker-approval.md @@ -11,6 +11,8 @@ OriginWeave already binds approval policy to an immutable `ApprovalScope` contai A lifecycle counter alone is insufficient if successful consumption returns ordinary reusable approval evidence. `ApprovalEvidence` is intentionally a reusable policy-context value for other authority sources; returning it directly from a bounded enterprise request would allow a caller to retain or clone that evidence and evaluate the same approved scope again after the lifecycle has consumed its configured use count or expired. That would separate the recorded lifecycle state from effective execution authority. +A second split can occur when an approved multi-use request issues a one-shot use and the approving checker revokes the still-live request before that use is evaluated. If the issued use is detached from later revocation state, it can remain effective even though the authoritative in-memory request has entered the fail-closed `Revoked` state. Revocation therefore has to invalidate outstanding, not-yet-evaluated uses as well as prevent new consumption. + This decision extends, but does not replace, the Accepted agent-safety model in ADR 0002. It defines a branch-local proposed enterprise authority primitive. Protected-main source and live repository policy remain authoritative until this proposal is reviewed and integrated. ## Decision drivers @@ -22,6 +24,7 @@ This decision extends, but does not replace, the Accepted agent-safety model in - Enforce the configured bounded-use count at the same authority boundary that produces executable policy authority. - Prevent a successfully consumed use from becoming replayable merely because surrounding policy context or generic approval evidence is cloneable. - Revalidate approval lifetime immediately before policy evaluation so a pre-expiry consume cannot authorize after the deadline. +- Invalidate an outstanding one-shot use when its approving checker revokes the live request before evaluation begins. - Keep R5 legal consent non-delegable. - Avoid introducing authentication, persistence, signing, workflow, release, or ambient authority into the policy crate. @@ -33,6 +36,8 @@ Before calling `EnterpriseApprovalRequest::approve` or `EnterpriseApprovalReques All lifecycle timestamps are supplied by a trusted control-plane clock. Model output, page content, browser content, or other untrusted inputs must not supply authoritative lifecycle time. Accepted transitions require non-decreasing trusted time; the expiry deadline is exclusive. A consumed approval use retains its consumption time and the same exclusive expiry deadline so the consuming policy evaluation can revalidate trusted time immediately before introducing approval evidence. +The live request and its issued uses also share a monotonic in-memory revocation signal. A successful checker revocation sets that signal before the request enters `Revoked`; an issued use checks it before introducing approval evidence. This is process-local coordination only. It does not provide durable revocation, distributed consensus, crash recovery, or cross-process invalidation. + The lifecycle does not persist state, acquire clocks, deliver approvals, render UI, sign evidence, resolve external identity, grant release authority, or authorize any action by itself. Normal `originweave-policy` capability, origin, mode, purpose, robots, secret, and risk gates still apply. ## Options considered @@ -47,7 +52,7 @@ Rejected. `PolicyContext` is a reusable policy input and is cloneable by design. ### Return a linear, non-cloneable approval-use value -Selected. A successful lifecycle consumption produces exactly one `EnterpriseApprovalUse`. Its policy-evaluation operation consumes `self`, requires current trusted time, rejects time rollback or expiry, injects the exact approved scope only into a private cloned context for that one evaluation, and delegates to the ordinary fail-closed evaluator. +Selected. A successful lifecycle consumption produces exactly one `EnterpriseApprovalUse`. Its policy-evaluation operation consumes `self`, requires current trusted time, rejects time rollback, expiry, or a checker revocation observed before evaluation begins, injects the exact approved scope only into a private cloned context for that one evaluation, and delegates to the ordinary fail-closed evaluator. ## Decision @@ -55,15 +60,17 @@ Selected. A successful lifecycle consumption produces exactly one `EnterpriseApp A pending request may be approved or denied only by a principal distinct from the maker. The maker alone may withdraw a pending request. An approved request may be revoked only by the checker that approved it. State validation occurs before transition-specific mutation; trusted transition time must not move backward; and a transition at or after the exclusive expiry deadline moves the live request to `Expired` and fails closed. -`consume` is permitted only from `Approved`, before expiry, and for an exactly equal `ApprovalScope`. A scope mismatch does not spend a use. A successful consume increments lifecycle accounting immediately and returns a non-cloneable `EnterpriseApprovalUse` that retains the exact scope, consumption time, and exclusive expiry deadline. The request becomes `Consumed` when the configured use count is exhausted. +`consume` is permitted only from `Approved`, before expiry, and for an exactly equal `ApprovalScope`. A scope mismatch does not spend a use. A successful consume increments lifecycle accounting immediately and returns a non-cloneable `EnterpriseApprovalUse` that retains the exact scope, consumption time, exclusive expiry deadline, and a shared monotonic revocation signal. The request becomes `Consumed` when the configured use count is exhausted. + +`EnterpriseApprovalUse::evaluate_at(self, request, context, now_epoch_seconds)` consumes the approval-use value. It first rejects trusted time earlier than the recorded consumption time with `NonMonotonicTime`, rejects evaluation at or after the retained exclusive deadline with `Expired`, and rejects a checker revocation observed before approval evidence is introduced with `InvalidState(Revoked)`. Only then does it clone the supplied policy context privately, install `ApprovalEvidence::UserConfirmed` for the retained exact scope in that private copy, and delegate to the normal deterministic policy evaluator. The caller's reusable context is not upgraded. The approval use is burned regardless of whether evaluation returns a policy decision or fails the evaluation-time validity checks. -`EnterpriseApprovalUse::evaluate_at(self, request, context, now_epoch_seconds)` consumes the approval-use value. It first rejects trusted time earlier than the recorded consumption time with `NonMonotonicTime` and rejects evaluation at or after the retained exclusive deadline with `Expired`. Only then does it clone the supplied policy context privately, install `ApprovalEvidence::UserConfirmed` for the retained exact scope in that private copy, and delegate to the normal deterministic policy evaluator. The caller's reusable context is not upgraded. The approval use is burned regardless of whether evaluation returns a policy decision or fails the evaluation-time validity checks. +The revocation signal is intentionally one-way and process-local. Once set it cannot be cleared, and every outstanding use sharing it fails closed if its evaluation-time validity check begins after revocation. An evaluation that has already passed that validity check is considered in flight; stronger cross-process or transactional cancellation semantics belong to the durable enterprise control plane under issue #202. -No public API converts `EnterpriseApprovalUse` back into reusable `ApprovalEvidence`, exposes its retained scope for later reinjection, or implements `Clone`/`Copy` for it. There is no untimed evaluation entry point that can bypass the retained expiry boundary. +No public API converts `EnterpriseApprovalUse` back into reusable `ApprovalEvidence`, exposes its retained scope for later reinjection, or implements `Clone`/`Copy` for it. There is no untimed evaluation entry point that can bypass the retained expiry or revocation boundary. ## Consequences -Enterprise callers receive a capability-like one-shot policy input rather than reusable approval evidence. This aligns effective execution authority with lifecycle accounting: each successful consumption can authorize at most one still-valid policy evaluation, and a denied or expired evaluation cannot be retried by replaying the same consumed value. +Enterprise callers receive a capability-like one-shot policy input rather than reusable approval evidence. This aligns effective execution authority with lifecycle accounting: each successful consumption can authorize at most one still-valid policy evaluation, and a denied, expired, or revoked evaluation cannot be retried by replaying the same consumed value. Callers that previously expected `consume` to return `ApprovalEvidence` must instead pass the returned `EnterpriseApprovalUse` directly to its consuming `evaluate_at` method together with the intended request, ordinary policy context, and trusted current epoch seconds. @@ -71,15 +78,15 @@ The policy crate remains deterministic and I/O-free. Authentication, clock acqui ## Failure and degraded behavior -The lifecycle fails closed on invalid validity windows, zero use limits, non-delegable actions, invalid state transitions, trusted-time regression, self-approval, requester/checker role mismatch, exact-scope mismatch, and expiry. The consumed-use evaluation repeats the trusted-time regression and expiry checks before it can introduce approval evidence. +The lifecycle fails closed on invalid validity windows, zero use limits, non-delegable actions, invalid state transitions, trusted-time regression, self-approval, requester/checker role mismatch, exact-scope mismatch, and expiry. The consumed-use evaluation repeats the trusted-time regression and expiry checks and observes the shared revocation signal before it can introduce approval evidence. Within the live `EnterpriseApprovalRequest` instance, a successful consume spends that use even if downstream policy evaluation denies the action or the resulting one-shot value later fails its evaluation-time validity check. This deliberately prefers loss of a delegated use over replay ambiguity. A caller needing another attempt must obtain another bounded lifecycle use through the authoritative request state rather than recover authority from a failed evaluation. -If process failure occurs after `consume` but before the one-shot evaluation completes, the in-memory request has advanced, but this crate does not persist that state across restart. Crash-safe replay prevention requires an external durable control plane that atomically preserves authoritative consumption state and recovery evidence. It must not be approximated by making the approval use cloneable or replayable. +If process failure occurs after `consume` but before the one-shot evaluation completes, the in-memory request has advanced, but this crate does not persist that state or its revocation signal across restart. Crash-safe replay and revocation prevention require an external durable control plane that atomically preserves authoritative consumption/revocation state and recovery evidence. It must not be approximated by making the approval use cloneable or replayable. ## Security / privacy / governance impact -The decision narrows enterprise approval authority by coupling each configured use to one non-replayable, still-valid evaluation attempt. It prevents cloning of lifecycle state or consumed execution authority from bypassing `max_uses`, expiry, terminal-state, or revocation semantics, and prevents a token created immediately before expiry from being exercised after its approval deadline. +The decision narrows enterprise approval authority by coupling each configured use to one non-replayable, still-valid evaluation attempt. It prevents cloning of lifecycle state or consumed execution authority from bypassing `max_uses`, expiry, terminal-state, or revocation semantics, prevents a token created immediately before expiry from being exercised after its approval deadline, and prevents an already-issued but not-yet-evaluated token from surviving a successful checker revocation in the same live process. The decision does not put credentials, secrets, mutable identity attributes, or raw identity-provider tokens into model context. Principal references remain opaque. Legal consent remains non-delegable. Existing origin, capability, secret-broker, and risk gates are unchanged and continue to fail closed independently of enterprise approval. @@ -93,6 +100,7 @@ The owning PR must retain realistic executable evidence for: - a single configured use yielding exactly one policy evaluation and rejecting subsequent lifecycle consumption; - a policy denial burning the already consumed one-shot use; - evaluation at the retained expiry deadline and trusted-time rollback after consumption both failing closed before approval evidence is applied; +- checker revocation after one use was issued from a still-live multi-use request invalidating that unexecuted use before approval evidence is applied; - compile-time proof that `EnterpriseApprovalRequest` and `EnterpriseApprovalUse` are not cloneable; and - exact-head repository contracts, Rust 1.97.1 formatting/check/tests/strict Clippy/rustdoc, security scanning where applicable, and exact owned-production function/line/region/branch coverage. @@ -102,15 +110,15 @@ Historical or predecessor-head results do not establish acceptance for a changed Call sites must migrate from storing or passing raw enterprise-produced `ApprovalEvidence` to consuming `EnterpriseApprovalUse::evaluate_at` with trusted current time. No persistence migration is introduced by this branch. -A rollback must revert the lifecycle/use API coherently. Reintroducing a direct `consume -> ApprovalEvidence` path, adding `Clone`/`Copy` to lifecycle accounting or consumed-use types, restoring an untimed evaluation path, or mutating a reusable caller policy context with enterprise approval evidence is not an acceptable partial rollback because it reopens replay or post-expiry authority. +A rollback must revert the lifecycle/use API coherently. Reintroducing a direct `consume -> ApprovalEvidence` path, adding `Clone`/`Copy` to lifecycle accounting or consumed-use types, restoring an untimed evaluation path, detaching issued uses from live in-process checker revocation, or mutating a reusable caller policy context with enterprise approval evidence is not an acceptable partial rollback because it reopens replay, post-expiry, or post-revocation authority. ## Open follow-ups -Issue #202 remains the owner for the broader enterprise control plane, including trusted principal authentication, tenant identity, durable state, workflow delivery, operator UI, signed/auditable evidence, and crash-safe/distributed consumption semantics. Those additions must preserve the exact-scope, separation-of-duties, monotonic-time, terminal-state, evaluation-time expiry, and one-shot-use invariants defined here. +Issue #202 remains the owner for the broader enterprise control plane, including trusted principal authentication, tenant identity, durable state, workflow delivery, operator UI, signed/auditable evidence, and crash-safe/distributed consumption and revocation semantics. Those additions must preserve the exact-scope, separation-of-duties, monotonic-time, terminal-state, evaluation-time expiry, in-process outstanding-use revocation, and one-shot-use invariants defined here. ## Supersession / reversal conditions -Supersede this ADR if OriginWeave adopts a different formally bounded authority object that can prove, under concurrency and crash recovery, that one enterprise approval use cannot authorize more policy evaluations than the authoritative lifecycle permits. Any replacement must retain or strengthen exact intent binding, maker-checker separation, trusted-time ordering, fail-closed terminal states, evaluation-time expiry, R5 non-delegability, and replay resistance. +Supersede this ADR if OriginWeave adopts a different formally bounded authority object that can prove, under concurrency and crash recovery, that one enterprise approval use cannot authorize more policy evaluations than the authoritative lifecycle permits. Any replacement must retain or strengthen exact intent binding, maker-checker separation, trusted-time ordering, fail-closed terminal states, evaluation-time expiry and revocation, R5 non-delegability, and replay resistance. ## References From 6ad9c10e705f99be250eb63c4c65302c6fd3bf1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:12:25 -0700 Subject: [PATCH 32/54] test(core): revoke exhausted outstanding approval use --- .../tests/enterprise_approval_single_use.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/originweave-policy/tests/enterprise_approval_single_use.rs b/crates/originweave-policy/tests/enterprise_approval_single_use.rs index 6d906818a..353519c7b 100644 --- a/crates/originweave-policy/tests/enterprise_approval_single_use.rs +++ b/crates/originweave-policy/tests/enterprise_approval_single_use.rs @@ -119,6 +119,38 @@ fn checker_revocation_invalidates_an_already_consumed_unexecuted_use() { ); } +#[test] +fn checker_revocation_invalidates_an_exhausted_but_unexecuted_single_use() { + let approval_scope = scope(); + let checker = principal("checker"); + let mut approval = + EnterpriseApprovalRequest::new(approval_scope.clone(), principal("maker"), 100, 200, 1) + .expect("approval request must be valid"); + approval + .approve(checker.clone(), 110) + .expect("distinct checker must approve"); + + let approval_use = approval + .consume(&approval_scope, 120) + .expect("single bounded use must be issued"); + assert_eq!(approval.state(), ApprovalLifecycleState::Consumed); + approval + .revoke(&checker, 125) + .expect("checker revocation must invalidate an outstanding exhausted use"); + assert_eq!(approval.state(), ApprovalLifecycleState::Revoked); + + assert_eq!( + approval_use.evaluate_at( + &purchase_request(), + &policy_context(BTreeSet::from([Capability::Purchase])), + 130, + ), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Revoked + )) + ); +} + #[test] fn policy_denial_burns_the_already_consumed_approval_use() { let approval_scope = scope(); From 8e2491bad377ddf1184cce75753646b5d3de9dde Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:14:55 -0700 Subject: [PATCH 33/54] fix(core): revoke exhausted outstanding approval uses --- .../originweave-policy/src/enterprise_approval.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index fd6baa2cb..3cc2a7c1a 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -97,7 +97,7 @@ pub enum ApprovalLifecycleState { Withdrawn, /// Every configured bounded use of the approval has been consumed. Consumed, - /// The approving checker revoked an approved, not-yet-exhausted request. + /// The approving checker revoked a request after approval, including after all uses were issued. Revoked, } @@ -397,17 +397,22 @@ impl EnterpriseApprovalRequest { }) } - /// Revoke an approved request as the exact checker that approved it. + /// Revoke an approved or fully-issued request as the exact checker that approved it. /// /// `now_epoch_seconds` must be trusted control-plane time. Revocation also /// invalidates already-consumed one-shot uses that have not yet begun their - /// evaluation-time validity check. + /// evaluation-time validity check, including an outstanding final use after + /// the request entered [`ApprovalLifecycleState::Consumed`]. Revocation does + /// not undo policy evaluations that completed before the revocation signal. pub fn revoke( &mut self, actor: &ApprovalPrincipalRef, now_epoch_seconds: u64, ) -> Result<(), ApprovalLifecycleError> { - if self.state != ApprovalLifecycleState::Approved { + if !matches!( + self.state, + ApprovalLifecycleState::Approved | ApprovalLifecycleState::Consumed + ) { return Err(ApprovalLifecycleError::InvalidState(self.state)); } self.ensure_monotonic_transition_time(now_epoch_seconds)?; From 0ee005de743004537e64cee1ef87057bb37abaf6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:15:58 -0700 Subject: [PATCH 34/54] docs(adr): revoke final outstanding enterprise uses --- docs/adr/0017-enterprise-maker-checker-approval.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/adr/0017-enterprise-maker-checker-approval.md b/docs/adr/0017-enterprise-maker-checker-approval.md index b379cf0af..9af59a405 100644 --- a/docs/adr/0017-enterprise-maker-checker-approval.md +++ b/docs/adr/0017-enterprise-maker-checker-approval.md @@ -11,7 +11,7 @@ OriginWeave already binds approval policy to an immutable `ApprovalScope` contai A lifecycle counter alone is insufficient if successful consumption returns ordinary reusable approval evidence. `ApprovalEvidence` is intentionally a reusable policy-context value for other authority sources; returning it directly from a bounded enterprise request would allow a caller to retain or clone that evidence and evaluate the same approved scope again after the lifecycle has consumed its configured use count or expired. That would separate the recorded lifecycle state from effective execution authority. -A second split can occur when an approved multi-use request issues a one-shot use and the approving checker revokes the still-live request before that use is evaluated. If the issued use is detached from later revocation state, it can remain effective even though the authoritative in-memory request has entered the fail-closed `Revoked` state. Revocation therefore has to invalidate outstanding, not-yet-evaluated uses as well as prevent new consumption. +A second split can occur when an approved request issues a one-shot use and the approving checker revokes before that use is evaluated. That risk remains when the issued use is the final configured use and the live request has already entered `Consumed`: issuance exhaustion is not proof that execution finished. If the issued use is detached from later revocation state, it can remain effective even though the checker has withdrawn the delegated authority. Revocation therefore has to invalidate outstanding, not-yet-evaluated uses whether the request is still `Approved` or has become `Consumed` because all configured uses were issued. This decision extends, but does not replace, the Accepted agent-safety model in ADR 0002. It defines a branch-local proposed enterprise authority primitive. Protected-main source and live repository policy remain authoritative until this proposal is reviewed and integrated. @@ -24,7 +24,7 @@ This decision extends, but does not replace, the Accepted agent-safety model in - Enforce the configured bounded-use count at the same authority boundary that produces executable policy authority. - Prevent a successfully consumed use from becoming replayable merely because surrounding policy context or generic approval evidence is cloneable. - Revalidate approval lifetime immediately before policy evaluation so a pre-expiry consume cannot authorize after the deadline. -- Invalidate an outstanding one-shot use when its approving checker revokes the live request before evaluation begins. +- Invalidate an outstanding one-shot use when its approving checker revokes before evaluation begins, including after the final configured use has been issued. - Keep R5 legal consent non-delegable. - Avoid introducing authentication, persistence, signing, workflow, release, or ambient authority into the policy crate. @@ -58,7 +58,7 @@ Selected. A successful lifecycle consumption produces exactly one `EnterpriseApp `EnterpriseApprovalRequest` is non-cloneable and owns the mutable lifecycle accounting state. It is created for exactly one immutable `ApprovalScope`, requester, trusted validity window, and nonzero `max_uses`. R5 `LegalConsent` is rejected at construction. -A pending request may be approved or denied only by a principal distinct from the maker. The maker alone may withdraw a pending request. An approved request may be revoked only by the checker that approved it. State validation occurs before transition-specific mutation; trusted transition time must not move backward; and a transition at or after the exclusive expiry deadline moves the live request to `Expired` and fails closed. +A pending request may be approved or denied only by a principal distinct from the maker. The maker alone may withdraw a pending request. After approval, the exact approving checker may revoke while the request is `Approved` or after all configured uses have been issued and the request is `Consumed`. State validation occurs before transition-specific mutation; trusted transition time must not move backward; and a transition at or after the exclusive expiry deadline moves the live request to `Expired` and fails closed. A revocation after `Consumed` invalidates any issued use that has not yet begun its evaluation-time validity check; it does not retroactively undo policy evaluations completed before revocation. `consume` is permitted only from `Approved`, before expiry, and for an exactly equal `ApprovalScope`. A scope mismatch does not spend a use. A successful consume increments lifecycle accounting immediately and returns a non-cloneable `EnterpriseApprovalUse` that retains the exact scope, consumption time, exclusive expiry deadline, and a shared monotonic revocation signal. The request becomes `Consumed` when the configured use count is exhausted. @@ -86,7 +86,7 @@ If process failure occurs after `consume` but before the one-shot evaluation com ## Security / privacy / governance impact -The decision narrows enterprise approval authority by coupling each configured use to one non-replayable, still-valid evaluation attempt. It prevents cloning of lifecycle state or consumed execution authority from bypassing `max_uses`, expiry, terminal-state, or revocation semantics, prevents a token created immediately before expiry from being exercised after its approval deadline, and prevents an already-issued but not-yet-evaluated token from surviving a successful checker revocation in the same live process. +The decision narrows enterprise approval authority by coupling each configured use to one non-replayable, still-valid evaluation attempt. It prevents cloning of lifecycle state or consumed execution authority from bypassing `max_uses`, expiry, terminal-state, or revocation semantics, prevents a token created immediately before expiry from being exercised after its approval deadline, and prevents an already-issued but not-yet-evaluated token from surviving a successful checker revocation in the same live process even when that token was the final configured use. The decision does not put credentials, secrets, mutable identity attributes, or raw identity-provider tokens into model context. Principal references remain opaque. Legal consent remains non-delegable. Existing origin, capability, secret-broker, and risk gates are unchanged and continue to fail closed independently of enterprise approval. @@ -101,6 +101,7 @@ The owning PR must retain realistic executable evidence for: - a policy denial burning the already consumed one-shot use; - evaluation at the retained expiry deadline and trusted-time rollback after consumption both failing closed before approval evidence is applied; - checker revocation after one use was issued from a still-live multi-use request invalidating that unexecuted use before approval evidence is applied; +- checker revocation after the final configured use was issued invalidating that still-outstanding use before approval evidence is applied; - compile-time proof that `EnterpriseApprovalRequest` and `EnterpriseApprovalUse` are not cloneable; and - exact-head repository contracts, Rust 1.97.1 formatting/check/tests/strict Clippy/rustdoc, security scanning where applicable, and exact owned-production function/line/region/branch coverage. From 1a6d50c935d6926e8569928a04ce59508ffe8b57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 06:13:33 -0700 Subject: [PATCH 35/54] test(policy): protect approval scope privacy at expiry --- .../enterprise_approval_scope_privacy.rs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs diff --git a/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs b/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs new file mode 100644 index 000000000..2a597988b --- /dev/null +++ b/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs @@ -0,0 +1,46 @@ +#![allow(clippy::expect_used)] + +use originweave_core::{ActionIntentDigest, ActionKind, ApprovalScope, Origin}; +use originweave_policy::{ + ApprovalLifecycleError, ApprovalLifecycleState, ApprovalPrincipalRef, + EnterpriseApprovalRequest, +}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn approval_scope(origin: &str) -> ApprovalScope { + ApprovalScope::new( + ActionKind::Purchase, + Origin::parse(origin).expect("test origin must be valid"), + ActionIntentDigest::parse(VALID_INTENT).expect("test intent digest must be valid"), + ) +} + +fn principal(subject: &str) -> ApprovalPrincipalRef { + ApprovalPrincipalRef::new("https://id.example", subject).expect("test principal must be valid") +} + +#[test] +fn mismatched_scope_at_expiry_does_not_disclose_or_mutate_lifecycle() { + let authority_scope = approval_scope("https://app.example"); + let foreign_scope = approval_scope("https://other.example"); + let mut request = EnterpriseApprovalRequest::new( + authority_scope, + principal("maker"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + request + .approve(principal("checker"), 110) + .expect("approval must succeed"); + + assert_eq!( + request.consume(&foreign_scope, 200), + Err(ApprovalLifecycleError::ScopeMismatch) + ); + assert_eq!(request.state(), ApprovalLifecycleState::Approved); + assert_eq!(request.uses_consumed(), 0); +} From af251ad20c179fd4119c6b54459db49854251bcb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 06:15:23 -0700 Subject: [PATCH 36/54] test(policy): format approval scope privacy regression --- .../tests/enterprise_approval_scope_privacy.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs b/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs index 2a597988b..887a7e284 100644 --- a/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs +++ b/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs @@ -2,8 +2,7 @@ use originweave_core::{ActionIntentDigest, ActionKind, ApprovalScope, Origin}; use originweave_policy::{ - ApprovalLifecycleError, ApprovalLifecycleState, ApprovalPrincipalRef, - EnterpriseApprovalRequest, + ApprovalLifecycleError, ApprovalLifecycleState, ApprovalPrincipalRef, EnterpriseApprovalRequest, }; const VALID_INTENT: &str = @@ -25,14 +24,9 @@ fn principal(subject: &str) -> ApprovalPrincipalRef { fn mismatched_scope_at_expiry_does_not_disclose_or_mutate_lifecycle() { let authority_scope = approval_scope("https://app.example"); let foreign_scope = approval_scope("https://other.example"); - let mut request = EnterpriseApprovalRequest::new( - authority_scope, - principal("maker"), - 100, - 200, - 1, - ) - .expect("approval request must be valid"); + let mut request = + EnterpriseApprovalRequest::new(authority_scope, principal("maker"), 100, 200, 1) + .expect("approval request must be valid"); request .approve(principal("checker"), 110) .expect("approval must succeed"); From 4d8bad32b755394b3bf485d17c8585279ba9dd9c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 06:20:10 -0700 Subject: [PATCH 37/54] fix(policy): enforce approval scope before lifecycle state --- .../src/enterprise_approval.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index 3cc2a7c1a..b25ad7d76 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -361,17 +361,21 @@ impl EnterpriseApprovalRequest { /// Consume one use of an approved request for the exact immutable scope. /// - /// `now_epoch_seconds` must be trusted control-plane time. Scope mismatch - /// does not consume a use. Successful consumption returns a non-cloneable - /// [`EnterpriseApprovalUse`] that retains the consumption time, expiry - /// deadline, and a shared monotonic revocation signal for a second - /// validity check immediately before policy evaluation rather than + /// `now_epoch_seconds` must be trusted control-plane time. Exact scope is + /// validated before lifecycle or trusted-time state, so a mismatched scope + /// neither reveals nor mutates those states. Successful consumption returns + /// a non-cloneable [`EnterpriseApprovalUse`] that retains the consumption + /// time, expiry deadline, and a shared monotonic revocation signal for a + /// second validity check immediately before policy evaluation rather than /// replayable approval evidence. pub fn consume( &mut self, required_scope: &ApprovalScope, now_epoch_seconds: u64, ) -> Result { + if required_scope != &self.scope { + return Err(ApprovalLifecycleError::ScopeMismatch); + } if self.state != ApprovalLifecycleState::Approved { return Err(ApprovalLifecycleError::InvalidState(self.state)); } @@ -381,9 +385,6 @@ impl EnterpriseApprovalRequest { self.state = ApprovalLifecycleState::Expired; return Err(ApprovalLifecycleError::Expired); } - if required_scope != &self.scope { - return Err(ApprovalLifecycleError::ScopeMismatch); - } self.uses_consumed += 1; self.last_transition_at_epoch_seconds = now_epoch_seconds; if self.uses_consumed == self.max_uses { From 2059a6f5b3a5bb010bc37617ee1ae43cb6b2f526 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 06:42:14 -0700 Subject: [PATCH 38/54] test(policy): protect actor identity before expiry state --- .../enterprise_approval_scope_privacy.rs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs b/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs index 887a7e284..74e65eec2 100644 --- a/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs +++ b/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs @@ -38,3 +38,89 @@ fn mismatched_scope_at_expiry_does_not_disclose_or_mutate_lifecycle() { assert_eq!(request.state(), ApprovalLifecycleState::Approved); assert_eq!(request.uses_consumed(), 0); } + +#[test] +fn mismatched_requester_at_expiry_does_not_disclose_or_mutate_lifecycle() { + let mut request = EnterpriseApprovalRequest::new( + approval_scope("https://app.example"), + principal("maker"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + + assert_eq!( + request.withdraw(&principal("intruder"), 200), + Err(ApprovalLifecycleError::RequesterMismatch) + ); + assert_eq!( + request.state(), + ApprovalLifecycleState::ApprovalRequested + ); +} + +#[test] +fn mismatched_checker_at_expiry_does_not_disclose_or_mutate_lifecycle() { + let mut request = EnterpriseApprovalRequest::new( + approval_scope("https://app.example"), + principal("maker"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + request + .approve(principal("checker"), 110) + .expect("approval must succeed"); + + assert_eq!( + request.revoke(&principal("intruder"), 200), + Err(ApprovalLifecycleError::DecisionActorMismatch) + ); + assert_eq!(request.state(), ApprovalLifecycleState::Approved); +} + +#[test] +fn self_approval_at_expiry_does_not_disclose_or_mutate_lifecycle() { + let maker = principal("maker"); + let mut request = EnterpriseApprovalRequest::new( + approval_scope("https://app.example"), + maker.clone(), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + + assert_eq!( + request.approve(maker, 200), + Err(ApprovalLifecycleError::SelfApproval) + ); + assert_eq!( + request.state(), + ApprovalLifecycleState::ApprovalRequested + ); +} + +#[test] +fn self_denial_at_expiry_does_not_disclose_or_mutate_lifecycle() { + let maker = principal("maker"); + let mut request = EnterpriseApprovalRequest::new( + approval_scope("https://app.example"), + maker.clone(), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + + assert_eq!( + request.deny(maker, 200), + Err(ApprovalLifecycleError::SelfApproval) + ); + assert_eq!( + request.state(), + ApprovalLifecycleState::ApprovalRequested + ); +} From 6d461d985c4881895f4cc33cab2b935507990742 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 06:43:51 -0700 Subject: [PATCH 39/54] test(policy): apply canonical formatting --- .../tests/enterprise_approval_scope_privacy.rs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs b/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs index 74e65eec2..529ffd1ed 100644 --- a/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs +++ b/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs @@ -54,10 +54,7 @@ fn mismatched_requester_at_expiry_does_not_disclose_or_mutate_lifecycle() { request.withdraw(&principal("intruder"), 200), Err(ApprovalLifecycleError::RequesterMismatch) ); - assert_eq!( - request.state(), - ApprovalLifecycleState::ApprovalRequested - ); + assert_eq!(request.state(), ApprovalLifecycleState::ApprovalRequested); } #[test] @@ -97,10 +94,7 @@ fn self_approval_at_expiry_does_not_disclose_or_mutate_lifecycle() { request.approve(maker, 200), Err(ApprovalLifecycleError::SelfApproval) ); - assert_eq!( - request.state(), - ApprovalLifecycleState::ApprovalRequested - ); + assert_eq!(request.state(), ApprovalLifecycleState::ApprovalRequested); } #[test] @@ -119,8 +113,5 @@ fn self_denial_at_expiry_does_not_disclose_or_mutate_lifecycle() { request.deny(maker, 200), Err(ApprovalLifecycleError::SelfApproval) ); - assert_eq!( - request.state(), - ApprovalLifecycleState::ApprovalRequested - ); + assert_eq!(request.state(), ApprovalLifecycleState::ApprovalRequested); } From 9205583997bed355f161a58f287aa78b8a278d4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 06:48:30 -0700 Subject: [PATCH 40/54] fix(policy): validate actor identity before lifecycle state --- .../src/enterprise_approval.rs | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index b25ad7d76..a061bc80c 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -283,13 +283,18 @@ impl EnterpriseApprovalRequest { /// Approve a pending request as a distinct checker. /// - /// `now_epoch_seconds` must be trusted control-plane time. Expiry is - /// exclusive: a transition at the deadline fails closed. + /// The local maker/checker identity relationship is validated before + /// lifecycle or trusted-time state so a self-approval attempt cannot reveal + /// or mutate those states. `now_epoch_seconds` must be trusted control-plane + /// time. Expiry is exclusive: a transition at the deadline fails closed. pub fn approve( &mut self, approver: ApprovalPrincipalRef, now_epoch_seconds: u64, ) -> Result<(), ApprovalLifecycleError> { + if approver == self.requester { + return Err(ApprovalLifecycleError::SelfApproval); + } if self.state != ApprovalLifecycleState::ApprovalRequested { return Err(ApprovalLifecycleError::InvalidState(self.state)); } @@ -299,9 +304,6 @@ impl EnterpriseApprovalRequest { self.state = ApprovalLifecycleState::Expired; return Err(ApprovalLifecycleError::Expired); } - if approver == self.requester { - return Err(ApprovalLifecycleError::SelfApproval); - } self.decision_actor = Some(approver); self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Approved; @@ -310,12 +312,17 @@ impl EnterpriseApprovalRequest { /// Deny a pending request as a distinct checker. /// - /// `now_epoch_seconds` must be trusted control-plane time. + /// The local maker/checker identity relationship is validated before + /// lifecycle or trusted-time state so a self-denial attempt cannot reveal or + /// mutate those states. `now_epoch_seconds` must be trusted control-plane time. pub fn deny( &mut self, actor: ApprovalPrincipalRef, now_epoch_seconds: u64, ) -> Result<(), ApprovalLifecycleError> { + if actor == self.requester { + return Err(ApprovalLifecycleError::SelfApproval); + } if self.state != ApprovalLifecycleState::ApprovalRequested { return Err(ApprovalLifecycleError::InvalidState(self.state)); } @@ -325,9 +332,6 @@ impl EnterpriseApprovalRequest { self.state = ApprovalLifecycleState::Expired; return Err(ApprovalLifecycleError::Expired); } - if actor == self.requester { - return Err(ApprovalLifecycleError::SelfApproval); - } self.decision_actor = Some(actor); self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Denied; @@ -336,12 +340,17 @@ impl EnterpriseApprovalRequest { /// Withdraw a pending request as the exact requesting maker. /// - /// `now_epoch_seconds` must be trusted control-plane time. + /// Requester identity is validated before lifecycle or trusted-time state so + /// a foreign actor cannot reveal or mutate those states. `now_epoch_seconds` + /// must be trusted control-plane time. pub fn withdraw( &mut self, actor: &ApprovalPrincipalRef, now_epoch_seconds: u64, ) -> Result<(), ApprovalLifecycleError> { + if actor != &self.requester { + return Err(ApprovalLifecycleError::RequesterMismatch); + } if self.state != ApprovalLifecycleState::ApprovalRequested { return Err(ApprovalLifecycleError::InvalidState(self.state)); } @@ -351,9 +360,6 @@ impl EnterpriseApprovalRequest { self.state = ApprovalLifecycleState::Expired; return Err(ApprovalLifecycleError::Expired); } - if actor != &self.requester { - return Err(ApprovalLifecycleError::RequesterMismatch); - } self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Withdrawn; Ok(()) @@ -400,16 +406,21 @@ impl EnterpriseApprovalRequest { /// Revoke an approved or fully-issued request as the exact checker that approved it. /// - /// `now_epoch_seconds` must be trusted control-plane time. Revocation also - /// invalidates already-consumed one-shot uses that have not yet begun their - /// evaluation-time validity check, including an outstanding final use after - /// the request entered [`ApprovalLifecycleState::Consumed`]. Revocation does - /// not undo policy evaluations that completed before the revocation signal. + /// Checker identity is validated before lifecycle or trusted-time state so a + /// foreign actor cannot reveal or mutate those states. `now_epoch_seconds` + /// must be trusted control-plane time. Revocation also invalidates + /// already-consumed one-shot uses that have not yet begun their evaluation-time + /// validity check, including an outstanding final use after the request entered + /// [`ApprovalLifecycleState::Consumed`]. Revocation does not undo policy + /// evaluations that completed before the revocation signal. pub fn revoke( &mut self, actor: &ApprovalPrincipalRef, now_epoch_seconds: u64, ) -> Result<(), ApprovalLifecycleError> { + if self.decision_actor.as_ref() != Some(actor) { + return Err(ApprovalLifecycleError::DecisionActorMismatch); + } if !matches!( self.state, ApprovalLifecycleState::Approved | ApprovalLifecycleState::Consumed @@ -422,9 +433,6 @@ impl EnterpriseApprovalRequest { self.state = ApprovalLifecycleState::Expired; return Err(ApprovalLifecycleError::Expired); } - if self.decision_actor.as_ref() != Some(actor) { - return Err(ApprovalLifecycleError::DecisionActorMismatch); - } self.revocation_signal.get_or_init(|| ()); self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Revoked; From ae368111fadc23cccef2db9ce55f63b939ad41f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 11:06:05 -0700 Subject: [PATCH 41/54] test(approval): reject bidi controls in principal refs --- .../tests/enterprise_approval_lifecycle.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs b/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs index f89916c91..633086b4d 100644 --- a/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs +++ b/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs @@ -49,6 +49,22 @@ fn principal_rejects_empty_ambiguous_or_oversized_references() { ApprovalPrincipalRef::new("https://id.example", "user\n123"), Err(ApprovalPrincipalRefError::InvalidSubject) ); + assert_eq!( + ApprovalPrincipalRef::new("https://id.example\u{202e}", "user-123"), + Err(ApprovalPrincipalRefError::InvalidIssuer) + ); + assert_eq!( + ApprovalPrincipalRef::new("https://id.example", "user\u{061c}123"), + Err(ApprovalPrincipalRefError::InvalidSubject) + ); + assert_eq!( + ApprovalPrincipalRef::new("https://id.example", "user\u{200e}123"), + Err(ApprovalPrincipalRefError::InvalidSubject) + ); + assert_eq!( + ApprovalPrincipalRef::new("https://id.example", "user\u{2066}123\u{2069}"), + Err(ApprovalPrincipalRefError::InvalidSubject) + ); assert_eq!( ApprovalPrincipalRef::new("https://id.example", &"x".repeat(257)), Err(ApprovalPrincipalRefError::InvalidSubject) From 6ba9d89e37776046009564ff2b8d892babeffd18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 11:10:20 -0700 Subject: [PATCH 42/54] fix(approval): reject bidi controls in principal refs --- .../originweave-policy/src/enterprise_approval.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index a061bc80c..870b13b3a 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -59,7 +59,19 @@ fn principal_component_is_valid(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_PRINCIPAL_REFERENCE_BYTES && value.trim() == value - && !value.chars().any(char::is_control) + && !value + .chars() + .any(|character| character.is_control() || is_bidi_control(character)) +} + +fn is_bidi_control(character: char) -> bool { + matches!( + character, + '\u{061c}' + | '\u{200e}'..='\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2066}'..='\u{2069}' + ) } /// A validation error for an enterprise principal reference. From 7dd5c84fa78d0030b2eb9eb91c1eee54a12b1691 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 11:11:38 -0700 Subject: [PATCH 43/54] docs(approval): bind principal display safety to Unicode bidi controls --- docs/adr/0017-enterprise-maker-checker-approval.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/adr/0017-enterprise-maker-checker-approval.md b/docs/adr/0017-enterprise-maker-checker-approval.md index 9af59a405..10c2d1b6e 100644 --- a/docs/adr/0017-enterprise-maker-checker-approval.md +++ b/docs/adr/0017-enterprise-maker-checker-approval.md @@ -30,7 +30,7 @@ This decision extends, but does not replace, the Accepted agent-safety model in ## Assumptions and authority boundaries -`ApprovalPrincipalRef` is an opaque `(issuer, subject)` tuple supplied by an already trusted identity boundary. This crate validates only bounded canonical representation and does not authenticate principals, merge identities by mutable attributes such as email address, or discover tenant membership. +`ApprovalPrincipalRef` is an opaque `(issuer, subject)` tuple supplied by an already trusted identity boundary. This crate validates only a bounded canonical representation and does not authenticate principals, merge identities by mutable attributes such as email address, or discover tenant membership. The canonical representation rejects control characters and the Unicode Standard Annex #9 `Bidi_Control` set (directional marks, embeddings, overrides, and isolates) so a logically distinct principal reference cannot rely on hidden directional formatting to present misleading issuer/subject text in operator or audit surfaces. Other Unicode remains opaque; this crate does not perform identity normalization or confusable folding. Before calling `EnterpriseApprovalRequest::approve` or `EnterpriseApprovalRequest::deny`, the trusted identity or workflow boundary must verify that the proposed checker has the required checker role, belongs to the request's authoritative tenant, and is authorized for the exact approval scope. Those lifecycle methods enforce requester/checker identity separation and state/time invariants only; they do not establish checker eligibility, tenant membership, or policy scope by themselves. @@ -78,7 +78,7 @@ The policy crate remains deterministic and I/O-free. Authentication, clock acqui ## Failure and degraded behavior -The lifecycle fails closed on invalid validity windows, zero use limits, non-delegable actions, invalid state transitions, trusted-time regression, self-approval, requester/checker role mismatch, exact-scope mismatch, and expiry. The consumed-use evaluation repeats the trusted-time regression and expiry checks and observes the shared revocation signal before it can introduce approval evidence. +The lifecycle fails closed on invalid validity windows, zero use limits, non-delegable actions, invalid state transitions, trusted-time regression, self-approval, requester mismatch, decision-actor mismatch, exact-scope mismatch, and expiry. Checker-role, tenant-membership, and business-authorization failures must already have failed closed at the trusted identity/workflow boundary before an approval or denial enters this lifecycle. The consumed-use evaluation repeats the trusted-time regression and expiry checks and observes the shared revocation signal before it can introduce approval evidence. Within the live `EnterpriseApprovalRequest` instance, a successful consume spends that use even if downstream policy evaluation denies the action or the resulting one-shot value later fails its evaluation-time validity check. This deliberately prefers loss of a delegated use over replay ambiguity. A caller needing another attempt must obtain another bounded lifecycle use through the authoritative request state rather than recover authority from a failed evaluation. @@ -86,7 +86,7 @@ If process failure occurs after `consume` but before the one-shot evaluation com ## Security / privacy / governance impact -The decision narrows enterprise approval authority by coupling each configured use to one non-replayable, still-valid evaluation attempt. It prevents cloning of lifecycle state or consumed execution authority from bypassing `max_uses`, expiry, terminal-state, or revocation semantics, prevents a token created immediately before expiry from being exercised after its approval deadline, and prevents an already-issued but not-yet-evaluated token from surviving a successful checker revocation in the same live process even when that token was the final configured use. +The decision narrows enterprise approval authority by coupling each configured use to one non-replayable, still-valid evaluation attempt. It prevents cloning of lifecycle state or consumed execution authority from bypassing `max_uses`, expiry, terminal-state, or revocation semantics, prevents a token created immediately before expiry from being exercised after its approval deadline, and prevents an already-issued but not-yet-evaluated token from surviving a successful checker revocation in the same live process even when that token was the final configured use. Principal references additionally reject Unicode `Bidi_Control` formatting characters so invisible direction overrides or isolates cannot create a misleading displayed identity while retaining a different exact `(issuer, subject)` tuple. The decision does not put credentials, secrets, mutable identity attributes, or raw identity-provider tokens into model context. Principal references remain opaque. Legal consent remains non-delegable. Existing origin, capability, secret-broker, and risk gates are unchanged and continue to fail closed independently of enterprise approval. @@ -95,7 +95,8 @@ The decision does not put credentials, secrets, mutable identity attributes, or The owning PR must retain realistic executable evidence for: - distinct maker/checker approval of an exact immutable scope; -- rejection of self-approval, role mismatch, scope mutation, expiry, clock regression, and invalid terminal transitions; +- rejection of non-canonical principal references including control and Unicode `Bidi_Control` formatting characters; +- rejection of self-approval, requester mismatch, decision-actor mismatch, scope mutation, expiry, clock regression, and invalid terminal transitions; - exact bounded multi-use accounting; - a single configured use yielding exactly one policy evaluation and rejecting subsequent lifecycle consumption; - a policy denial burning the already consumed one-shot use; @@ -115,13 +116,14 @@ A rollback must revert the lifecycle/use API coherently. Reintroducing a direct ## Open follow-ups -Issue #202 remains the owner for the broader enterprise control plane, including trusted principal authentication, tenant identity, durable state, workflow delivery, operator UI, signed/auditable evidence, and crash-safe/distributed consumption and revocation semantics. Those additions must preserve the exact-scope, separation-of-duties, monotonic-time, terminal-state, evaluation-time expiry, in-process outstanding-use revocation, and one-shot-use invariants defined here. +Issue #202 remains the owner for the broader enterprise control plane, including trusted principal authentication, tenant identity, durable state, workflow delivery, operator UI, signed/auditable evidence, and crash-safe/distributed consumption and revocation semantics. Those additions must preserve the exact-scope, separation-of-duties, monotonic-time, terminal-state, evaluation-time expiry, in-process outstanding-use revocation, one-shot-use, and canonical-principal-display invariants defined here. ## Supersession / reversal conditions -Supersede this ADR if OriginWeave adopts a different formally bounded authority object that can prove, under concurrency and crash recovery, that one enterprise approval use cannot authorize more policy evaluations than the authoritative lifecycle permits. Any replacement must retain or strengthen exact intent binding, maker-checker separation, trusted-time ordering, fail-closed terminal states, evaluation-time expiry and revocation, R5 non-delegability, and replay resistance. +Supersede this ADR if OriginWeave adopts a different formally bounded authority object that can prove, under concurrency and crash recovery, that one enterprise approval use cannot authorize more policy evaluations than the authoritative lifecycle permits. Any replacement must retain or strengthen exact intent binding, maker-checker separation, trusted-time ordering, fail-closed terminal states, evaluation-time expiry and revocation, R5 non-delegability, replay resistance, and principal-reference presentation safety. ## References - [ADR 0002: Agent safety kernel](0002-agent-safety-kernel.md). +- [Research and standards doctoring](../doctoring.md), including Unicode Standard Annex #9. - OriginWeave issue #202, enterprise policy and approval control-plane completion criteria. From d7de0524bb058659e46e9b487e291b9e903b777d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 11:12:56 -0700 Subject: [PATCH 44/54] docs(standards): record Unicode bidi-control identity boundary --- docs/doctoring.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/doctoring.md b/docs/doctoring.md index 693840f63..b303556bd 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -16,6 +16,12 @@ The WHATWG URL host parser and Chromium canonicalizer classify shortened decimal The exact Chromium regression evidence is pinned to revision `446d05d21720f0b3505ec21057b3e9f909784262`. A mutable `HEAD` reference is not sufficient for a reproducible security contract. +### Enterprise principal display safety + +Unicode Standard Annex #9, Revision 51 for Unicode 17.0.0, defines directional formatting characters under the `Bidi_Control` property, including the Arabic letter mark, left/right marks, explicit embeddings and overrides, and directional isolates and their terminators. These code points affect bidirectional presentation while remaining part of the logical character sequence; the annex also warns that directional overrides have security implications and should be avoided where possible. + +`ApprovalPrincipalRef` is an exact opaque `(issuer, subject)` authority identifier that is likely to appear in audit and operator surfaces. Allowing hidden bidirectional formatting would let two logically different references compare distinctly while one can be presented with misleading visual order. OriginWeave therefore rejects exactly the Unicode 17.0.0 `Bidi_Control` set in principal-reference components in addition to ordinary control characters, surrounding whitespace, empty values, and the byte bound. This is a presentation-safety invariant, not Unicode normalization, script restriction, confusable folding, authentication, or identity resolution; other Unicode format characters remain admissible unless a separately reviewed invariant rejects them. + ### Extension-to-Agent grant origin binding RFC 6454 defines a web origin as the scheme, host, and port tuple that browsers use to isolate authority. An OriginWeave `extension_grant` that is bound only to extension identity, session, and browsing context would remain valid after the same context navigates to another origin. OriginWeave therefore requires the grant and the request to carry the same canonical origin. A host change or a non-default port change is a different origin and cannot reuse the grant. This is grant-scope isolation only; it does not install an extension, parse Chrome messages, or mint Agent capabilities from Manifest V3 permissions. @@ -174,6 +180,8 @@ The Rust Project Developers. (2026). *Ipv6Addr in std::net* (Rust 1.97.1) [Softw The Rust Project Developers. (2026). *TcpStream in std::net* (Rust 1.97.1) [Software documentation]. https://doc.rust-lang.org/stable/std/net/struct.TcpStream.html +Unicode Consortium. (2025, August 13). *Unicode bidirectional algorithm* (Unicode Standard Annex #9, Revision 51, Unicode 17.0.0). https://www.unicode.org/reports/tr9/ + Web Hypertext Application Technology Working Group. (2026). *URL standard*. https://url.spec.whatwg.org/ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ From cda5538d1a91180f1b0b5342e52e59f720447f4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 11:15:34 -0700 Subject: [PATCH 45/54] docs(changelog): record principal bidi-control hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d71269d1..6f9d62660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - State-changing actions are same-origin by default. - R3 and R4 approvals are bound to the exact action, target origin, and immutable digest of the complete canonical action intent; R5 legal consent is non-delegable. - Consumed enterprise maker-checker approvals expose only a non-cloneable one-shot policy-evaluation use; denial still burns the consumed use and the reusable caller policy context is not upgraded with enterprise approval evidence. +- Enterprise approval principal references reject Unicode `Bidi_Control` directional marks, embeddings, overrides, and isolates so hidden bidirectional formatting cannot make an exact `(issuer, subject)` authority tuple present as a misleading audit/operator identity. - Shortened, integer, hexadecimal, and legacy octal-looking IPv4 host spellings are rejected so the policy origin cannot diverge from Chromium host interpretation. - IPv4-mapped IPv6 is canonicalized before destination classification and pin comparison so mapped private or loopback addresses cannot bypass IPv4 policy. - The default destination policy permits only public addresses and denies unspecified, loopback, private, shared, link-local, metadata, documentation, benchmarking, multicast, broadcast, transition, and protocol-reserved destinations. From 4d274939f5601d372976d59e6983104ed44a3940 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 11:46:52 -0700 Subject: [PATCH 46/54] docs(approval): clarify actor separation boundary --- docs/adr/0017-enterprise-maker-checker-approval.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0017-enterprise-maker-checker-approval.md b/docs/adr/0017-enterprise-maker-checker-approval.md index 10c2d1b6e..3c8fcc601 100644 --- a/docs/adr/0017-enterprise-maker-checker-approval.md +++ b/docs/adr/0017-enterprise-maker-checker-approval.md @@ -32,7 +32,7 @@ This decision extends, but does not replace, the Accepted agent-safety model in `ApprovalPrincipalRef` is an opaque `(issuer, subject)` tuple supplied by an already trusted identity boundary. This crate validates only a bounded canonical representation and does not authenticate principals, merge identities by mutable attributes such as email address, or discover tenant membership. The canonical representation rejects control characters and the Unicode Standard Annex #9 `Bidi_Control` set (directional marks, embeddings, overrides, and isolates) so a logically distinct principal reference cannot rely on hidden directional formatting to present misleading issuer/subject text in operator or audit surfaces. Other Unicode remains opaque; this crate does not perform identity normalization or confusable folding. -Before calling `EnterpriseApprovalRequest::approve` or `EnterpriseApprovalRequest::deny`, the trusted identity or workflow boundary must verify that the proposed checker has the required checker role, belongs to the request's authoritative tenant, and is authorized for the exact approval scope. Those lifecycle methods enforce requester/checker identity separation and state/time invariants only; they do not establish checker eligibility, tenant membership, or policy scope by themselves. +Before calling `EnterpriseApprovalRequest::approve` or `EnterpriseApprovalRequest::deny`, the trusted identity or workflow boundary must verify that the proposed checker has the required checker role, belongs to the request's authoritative tenant, is authorized for the exact approval scope, and resolves to a distinct canonical human or workload actor from the maker. Exact `(issuer, subject)` inequality inside this crate is not sufficient separation-of-duties evidence when one real actor can hold aliases or multiple federated identities; canonical actor correlation and alias/account-link governance belong to that trusted boundary. Those lifecycle methods enforce requester/checker tuple separation and state/time invariants only; they do not establish actor uniqueness, checker eligibility, tenant membership, or policy scope by themselves. All lifecycle timestamps are supplied by a trusted control-plane clock. Model output, page content, browser content, or other untrusted inputs must not supply authoritative lifecycle time. Accepted transitions require non-decreasing trusted time; the expiry deadline is exclusive. A consumed approval use retains its consumption time and the same exclusive expiry deadline so the consuming policy evaluation can revalidate trusted time immediately before introducing approval evidence. @@ -78,7 +78,7 @@ The policy crate remains deterministic and I/O-free. Authentication, clock acqui ## Failure and degraded behavior -The lifecycle fails closed on invalid validity windows, zero use limits, non-delegable actions, invalid state transitions, trusted-time regression, self-approval, requester mismatch, decision-actor mismatch, exact-scope mismatch, and expiry. Checker-role, tenant-membership, and business-authorization failures must already have failed closed at the trusted identity/workflow boundary before an approval or denial enters this lifecycle. The consumed-use evaluation repeats the trusted-time regression and expiry checks and observes the shared revocation signal before it can introduce approval evidence. +The lifecycle fails closed on invalid validity windows, zero use limits, non-delegable actions, invalid state transitions, trusted-time regression, self-approval, requester mismatch, decision-actor mismatch, exact-scope mismatch, and expiry. Checker-role, tenant-membership, actor-uniqueness, and business-authorization failures must already have failed closed at the trusted identity/workflow boundary before an approval or denial enters this lifecycle. The consumed-use evaluation repeats the trusted-time regression and expiry checks and observes the shared revocation signal before it can introduce approval evidence. Within the live `EnterpriseApprovalRequest` instance, a successful consume spends that use even if downstream policy evaluation denies the action or the resulting one-shot value later fails its evaluation-time validity check. This deliberately prefers loss of a delegated use over replay ambiguity. A caller needing another attempt must obtain another bounded lifecycle use through the authoritative request state rather than recover authority from a failed evaluation. From 093a8a98fea150890f9575df3c127c0f5d882d7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 12:33:37 -0700 Subject: [PATCH 47/54] test(policy): cover observed expiry invalidation --- .../tests/enterprise_approval_single_use.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/originweave-policy/tests/enterprise_approval_single_use.rs b/crates/originweave-policy/tests/enterprise_approval_single_use.rs index 353519c7b..86e1ee303 100644 --- a/crates/originweave-policy/tests/enterprise_approval_single_use.rs +++ b/crates/originweave-policy/tests/enterprise_approval_single_use.rs @@ -203,6 +203,66 @@ fn consumed_approval_use_expires_before_policy_evaluation() { ); } +#[test] +fn observed_request_expiry_invalidates_an_issued_use_against_backdated_evaluation() { + let approval_scope = scope(); + let mut approval = + EnterpriseApprovalRequest::new(approval_scope.clone(), principal("maker"), 100, 200, 2) + .expect("approval request must be valid"); + approval + .approve(principal("checker"), 110) + .expect("distinct checker must approve"); + + let approval_use = approval + .consume(&approval_scope, 120) + .expect("first bounded use must be issued while approval remains active"); + assert_eq!(approval.state(), ApprovalLifecycleState::Approved); + assert_eq!( + approval.consume(&approval_scope, 200), + Err(ApprovalLifecycleError::Expired) + ); + assert_eq!(approval.state(), ApprovalLifecycleState::Expired); + + assert_eq!( + approval_use.evaluate_at( + &purchase_request(), + &policy_context(BTreeSet::from([Capability::Purchase])), + 150, + ), + Err(ApprovalLifecycleError::Expired) + ); +} + +#[test] +fn expiry_observed_during_revocation_invalidates_an_issued_use() { + let approval_scope = scope(); + let checker = principal("checker"); + let mut approval = + EnterpriseApprovalRequest::new(approval_scope.clone(), principal("maker"), 100, 200, 2) + .expect("approval request must be valid"); + approval + .approve(checker.clone(), 110) + .expect("distinct checker must approve"); + + let approval_use = approval + .consume(&approval_scope, 120) + .expect("first bounded use must be issued while approval remains active"); + assert_eq!( + approval.revoke(&checker, 200), + Err(ApprovalLifecycleError::Expired) + ); + assert_eq!(approval.state(), ApprovalLifecycleState::Expired); + + assert_eq!( + approval_use.evaluate_at( + &purchase_request(), + &policy_context(BTreeSet::from([Capability::Purchase])), + 150, + ), + Err(ApprovalLifecycleError::Expired) + ); +} + #[test] fn consumed_approval_use_rejects_trusted_time_rollback() { let approval_scope = scope(); From a8b94988cea119eac2c6d6b0fa15cc0dd472c478 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 12:37:39 -0700 Subject: [PATCH 48/54] fix(policy): invalidate issued uses on observed expiry --- .../src/enterprise_approval.rs | 65 ++++++++++++------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index 870b13b3a..806cbaee6 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -113,18 +113,24 @@ pub enum ApprovalLifecycleState { Revoked, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ApprovalUseInvalidation { + Expired, + Revoked, +} + /// One consumed, non-replayable enterprise approval use. /// /// This value is intentionally not [`Clone`]. It is created only by /// [`EnterpriseApprovalRequest::consume`] after exact-scope, trusted-time, and /// use-count checks succeed. [`Self::evaluate_at`] consumes the value and first /// revalidates trusted time against both the consumption time and retained -/// exclusive expiry deadline, then rejects checker revocation that occurred -/// after the use was issued but before evaluation. Only a still-valid use -/// injects the approved scope into a private copy of the supplied policy context -/// and delegates to the normal fail-closed policy evaluator. The use is burned -/// even when evaluation is denied for expiry, time rollback, revocation, policy, -/// or a different approval need. +/// exclusive expiry deadline, then rejects any shared terminal lifecycle +/// invalidation observed by the issuing request after this use was issued. Only +/// a still-valid use injects the approved scope into a private copy of the +/// supplied policy context and delegates to the normal fail-closed policy +/// evaluator. The use is burned even when evaluation is denied for expiry, time +/// rollback, revocation, policy, or a different approval need. /// /// ```compile_fail /// # use originweave_core::{ActionRequest, PolicyContext}; @@ -144,7 +150,7 @@ pub struct EnterpriseApprovalUse { scope: ApprovalScope, consumed_at_epoch_seconds: u64, expires_at_epoch_seconds: u64, - revocation_signal: Arc>, + invalidation_signal: Arc>, } impl EnterpriseApprovalUse { @@ -153,10 +159,11 @@ impl EnterpriseApprovalUse { /// `now_epoch_seconds` must come from the same trusted control-plane clock /// used by the approval lifecycle. Evaluation fails closed if trusted time /// moves backward before the consumption time, reaches the retained - /// exclusive expiry deadline, or the approving checker revoked the live - /// request after this use was issued. The caller-provided context is cloned - /// so the reusable caller context is never upgraded with replayable approval - /// evidence. This value itself is consumed regardless of the result. + /// exclusive expiry deadline, or the issuing request already observed a + /// terminal expiry or checker revocation after this use was issued. The + /// caller-provided context is cloned so the reusable caller context is never + /// upgraded with replayable approval evidence. This value itself is consumed + /// regardless of the result. pub fn evaluate_at( self, request: &ActionRequest, @@ -169,10 +176,13 @@ impl EnterpriseApprovalUse { if now_epoch_seconds >= self.expires_at_epoch_seconds { return Err(ApprovalLifecycleError::Expired); } - if self.revocation_signal.get().is_some() { - return Err(ApprovalLifecycleError::InvalidState( - ApprovalLifecycleState::Revoked, - )); + if let Some(invalidation) = self.invalidation_signal.get() { + return Err(match invalidation { + ApprovalUseInvalidation::Expired => ApprovalLifecycleError::Expired, + ApprovalUseInvalidation::Revoked => ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Revoked, + ), + }); } let mut one_shot_context = context.clone(); one_shot_context.set_approval(ApprovalEvidence::UserConfirmed(self.scope)); @@ -196,7 +206,7 @@ pub struct EnterpriseApprovalRequest { max_uses: u32, uses_consumed: u32, state: ApprovalLifecycleState, - revocation_signal: Arc>, + invalidation_signal: Arc>, } impl EnterpriseApprovalRequest { @@ -231,7 +241,7 @@ impl EnterpriseApprovalRequest { max_uses, uses_consumed: 0, state: ApprovalLifecycleState::ApprovalRequested, - revocation_signal: Arc::new(OnceLock::new()), + invalidation_signal: Arc::new(OnceLock::new()), }) } @@ -383,9 +393,9 @@ impl EnterpriseApprovalRequest { /// validated before lifecycle or trusted-time state, so a mismatched scope /// neither reveals nor mutates those states. Successful consumption returns /// a non-cloneable [`EnterpriseApprovalUse`] that retains the consumption - /// time, expiry deadline, and a shared monotonic revocation signal for a - /// second validity check immediately before policy evaluation rather than - /// replayable approval evidence. + /// time, expiry deadline, and a shared terminal lifecycle invalidation signal + /// for a second validity check immediately before policy evaluation rather + /// than replayable approval evidence. pub fn consume( &mut self, required_scope: &ApprovalScope, @@ -399,6 +409,8 @@ impl EnterpriseApprovalRequest { } self.ensure_monotonic_transition_time(now_epoch_seconds)?; if now_epoch_seconds >= self.expires_at_epoch_seconds { + self.invalidation_signal + .get_or_init(|| ApprovalUseInvalidation::Expired); self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Expired; return Err(ApprovalLifecycleError::Expired); @@ -412,7 +424,7 @@ impl EnterpriseApprovalRequest { scope: self.scope.clone(), consumed_at_epoch_seconds: now_epoch_seconds, expires_at_epoch_seconds: self.expires_at_epoch_seconds, - revocation_signal: Arc::clone(&self.revocation_signal), + invalidation_signal: Arc::clone(&self.invalidation_signal), }) } @@ -423,8 +435,10 @@ impl EnterpriseApprovalRequest { /// must be trusted control-plane time. Revocation also invalidates /// already-consumed one-shot uses that have not yet begun their evaluation-time /// validity check, including an outstanding final use after the request entered - /// [`ApprovalLifecycleState::Consumed`]. Revocation does not undo policy - /// evaluations that completed before the revocation signal. + /// [`ApprovalLifecycleState::Consumed`]. Reaching expiry through this transition + /// likewise invalidates outstanding uses even if a later evaluator presents an + /// earlier timestamp. Revocation does not undo policy evaluations that completed + /// before the terminal invalidation signal. pub fn revoke( &mut self, actor: &ApprovalPrincipalRef, @@ -441,11 +455,14 @@ impl EnterpriseApprovalRequest { } self.ensure_monotonic_transition_time(now_epoch_seconds)?; if now_epoch_seconds >= self.expires_at_epoch_seconds { + self.invalidation_signal + .get_or_init(|| ApprovalUseInvalidation::Expired); self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Expired; return Err(ApprovalLifecycleError::Expired); } - self.revocation_signal.get_or_init(|| ()); + self.invalidation_signal + .get_or_init(|| ApprovalUseInvalidation::Revoked); self.last_transition_at_epoch_seconds = now_epoch_seconds; self.state = ApprovalLifecycleState::Revoked; Ok(()) From cfabd8b07833c824e11cfa2bdbbf888161bf2fcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 12:40:12 -0700 Subject: [PATCH 49/54] docs(adr): bind issued uses to observed terminal expiry --- .../0017-enterprise-maker-checker-approval.md | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/adr/0017-enterprise-maker-checker-approval.md b/docs/adr/0017-enterprise-maker-checker-approval.md index 3c8fcc601..325502ac7 100644 --- a/docs/adr/0017-enterprise-maker-checker-approval.md +++ b/docs/adr/0017-enterprise-maker-checker-approval.md @@ -13,6 +13,8 @@ A lifecycle counter alone is insufficient if successful consumption returns ordi A second split can occur when an approved request issues a one-shot use and the approving checker revokes before that use is evaluated. That risk remains when the issued use is the final configured use and the live request has already entered `Consumed`: issuance exhaustion is not proof that execution finished. If the issued use is detached from later revocation state, it can remain effective even though the checker has withdrawn the delegated authority. Revocation therefore has to invalidate outstanding, not-yet-evaluated uses whether the request is still `Approved` or has become `Consumed` because all configured uses were issued. +The same detached-use problem exists when the authoritative request later observes its expiry deadline. An outstanding use still performs its own deadline check, but without sharing that observed terminal state a caller could advance the request's trusted timeline to `Expired` and then present an earlier, locally valid evaluation timestamp to the detached use. Request-observed expiry therefore must also invalidate outstanding in-process uses so backdated evaluation cannot resurrect authority after the lifecycle has already become terminal. + This decision extends, but does not replace, the Accepted agent-safety model in ADR 0002. It defines a branch-local proposed enterprise authority primitive. Protected-main source and live repository policy remain authoritative until this proposal is reviewed and integrated. ## Decision drivers @@ -25,6 +27,7 @@ This decision extends, but does not replace, the Accepted agent-safety model in - Prevent a successfully consumed use from becoming replayable merely because surrounding policy context or generic approval evidence is cloneable. - Revalidate approval lifetime immediately before policy evaluation so a pre-expiry consume cannot authorize after the deadline. - Invalidate an outstanding one-shot use when its approving checker revokes before evaluation begins, including after the final configured use has been issued. +- Invalidate outstanding in-process uses when the authoritative request observes expiry, even if a later evaluator supplies a backdated timestamp that is individually after that use's consumption time and before the retained deadline. - Keep R5 legal consent non-delegable. - Avoid introducing authentication, persistence, signing, workflow, release, or ambient authority into the policy crate. @@ -36,7 +39,7 @@ Before calling `EnterpriseApprovalRequest::approve` or `EnterpriseApprovalReques All lifecycle timestamps are supplied by a trusted control-plane clock. Model output, page content, browser content, or other untrusted inputs must not supply authoritative lifecycle time. Accepted transitions require non-decreasing trusted time; the expiry deadline is exclusive. A consumed approval use retains its consumption time and the same exclusive expiry deadline so the consuming policy evaluation can revalidate trusted time immediately before introducing approval evidence. -The live request and its issued uses also share a monotonic in-memory revocation signal. A successful checker revocation sets that signal before the request enters `Revoked`; an issued use checks it before introducing approval evidence. This is process-local coordination only. It does not provide durable revocation, distributed consensus, crash recovery, or cross-process invalidation. +The live request and its issued uses also share a one-way in-memory terminal invalidation signal. A successful checker revocation records `Revoked` before the request enters `Revoked`; any request transition that observes the exclusive deadline records `Expired` before the request enters `Expired`. An issued use checks that shared terminal signal before introducing approval evidence. This is process-local coordination only. It does not provide durable revocation or expiry propagation, distributed consensus, crash recovery, or cross-process invalidation. The lifecycle does not persist state, acquire clocks, deliver approvals, render UI, sign evidence, resolve external identity, grant release authority, or authorize any action by itself. Normal `originweave-policy` capability, origin, mode, purpose, robots, secret, and risk gates still apply. @@ -52,7 +55,7 @@ Rejected. `PolicyContext` is a reusable policy input and is cloneable by design. ### Return a linear, non-cloneable approval-use value -Selected. A successful lifecycle consumption produces exactly one `EnterpriseApprovalUse`. Its policy-evaluation operation consumes `self`, requires current trusted time, rejects time rollback, expiry, or a checker revocation observed before evaluation begins, injects the exact approved scope only into a private cloned context for that one evaluation, and delegates to the ordinary fail-closed evaluator. +Selected. A successful lifecycle consumption produces exactly one `EnterpriseApprovalUse`. Its policy-evaluation operation consumes `self`, requires current trusted time, rejects time rollback, direct deadline expiry, or a shared terminal expiry/revocation observed by the issuing request before evaluation begins, injects the exact approved scope only into a private cloned context for that one evaluation, and delegates to the ordinary fail-closed evaluator. ## Decision @@ -60,13 +63,13 @@ Selected. A successful lifecycle consumption produces exactly one `EnterpriseApp A pending request may be approved or denied only by a principal distinct from the maker. The maker alone may withdraw a pending request. After approval, the exact approving checker may revoke while the request is `Approved` or after all configured uses have been issued and the request is `Consumed`. State validation occurs before transition-specific mutation; trusted transition time must not move backward; and a transition at or after the exclusive expiry deadline moves the live request to `Expired` and fails closed. A revocation after `Consumed` invalidates any issued use that has not yet begun its evaluation-time validity check; it does not retroactively undo policy evaluations completed before revocation. -`consume` is permitted only from `Approved`, before expiry, and for an exactly equal `ApprovalScope`. A scope mismatch does not spend a use. A successful consume increments lifecycle accounting immediately and returns a non-cloneable `EnterpriseApprovalUse` that retains the exact scope, consumption time, exclusive expiry deadline, and a shared monotonic revocation signal. The request becomes `Consumed` when the configured use count is exhausted. +`consume` is permitted only from `Approved`, before expiry, and for an exactly equal `ApprovalScope`. A scope mismatch does not spend a use. A successful consume increments lifecycle accounting immediately and returns a non-cloneable `EnterpriseApprovalUse` that retains the exact scope, consumption time, exclusive expiry deadline, and a shared one-way terminal invalidation signal. The request becomes `Consumed` when the configured use count is exhausted. If a later consume attempt observes the expiry deadline while the request is still `Approved`, it records shared `Expired` invalidation before entering `Expired`; outstanding uses from the same live request then fail closed even if their evaluator supplies an earlier timestamp. -`EnterpriseApprovalUse::evaluate_at(self, request, context, now_epoch_seconds)` consumes the approval-use value. It first rejects trusted time earlier than the recorded consumption time with `NonMonotonicTime`, rejects evaluation at or after the retained exclusive deadline with `Expired`, and rejects a checker revocation observed before approval evidence is introduced with `InvalidState(Revoked)`. Only then does it clone the supplied policy context privately, install `ApprovalEvidence::UserConfirmed` for the retained exact scope in that private copy, and delegate to the normal deterministic policy evaluator. The caller's reusable context is not upgraded. The approval use is burned regardless of whether evaluation returns a policy decision or fails the evaluation-time validity checks. +`EnterpriseApprovalUse::evaluate_at(self, request, context, now_epoch_seconds)` consumes the approval-use value. It first rejects trusted time earlier than the recorded consumption time with `NonMonotonicTime`, rejects evaluation at or after the retained exclusive deadline with `Expired`, and then checks the issuing request's shared terminal invalidation. An observed `Expired` invalidation returns `Expired`; an observed `Revoked` invalidation returns `InvalidState(Revoked)`. Only then does it clone the supplied policy context privately, install `ApprovalEvidence::UserConfirmed` for the retained exact scope in that private copy, and delegate to the normal deterministic policy evaluator. The caller's reusable context is not upgraded. The approval use is burned regardless of whether evaluation returns a policy decision or fails the evaluation-time validity checks. -The revocation signal is intentionally one-way and process-local. Once set it cannot be cleared, and every outstanding use sharing it fails closed if its evaluation-time validity check begins after revocation. An evaluation that has already passed that validity check is considered in flight; stronger cross-process or transactional cancellation semantics belong to the durable enterprise control plane under issue #202. +The terminal invalidation signal is intentionally one-way and process-local. Once set it cannot be cleared. An outstanding use that begins its validity check after request-observed expiry or checker revocation fails closed according to the first shared terminal condition recorded by the live request. An evaluation that has already passed that validity check is considered in flight; stronger cross-process or transactional cancellation semantics belong to the durable enterprise control plane under issue #202. -No public API converts `EnterpriseApprovalUse` back into reusable `ApprovalEvidence`, exposes its retained scope for later reinjection, or implements `Clone`/`Copy` for it. There is no untimed evaluation entry point that can bypass the retained expiry or revocation boundary. +No public API converts `EnterpriseApprovalUse` back into reusable `ApprovalEvidence`, exposes its retained scope for later reinjection, or implements `Clone`/`Copy` for it. There is no untimed evaluation entry point that can bypass the retained expiry or terminal-invalidation boundary. ## Consequences @@ -78,15 +81,15 @@ The policy crate remains deterministic and I/O-free. Authentication, clock acqui ## Failure and degraded behavior -The lifecycle fails closed on invalid validity windows, zero use limits, non-delegable actions, invalid state transitions, trusted-time regression, self-approval, requester mismatch, decision-actor mismatch, exact-scope mismatch, and expiry. Checker-role, tenant-membership, actor-uniqueness, and business-authorization failures must already have failed closed at the trusted identity/workflow boundary before an approval or denial enters this lifecycle. The consumed-use evaluation repeats the trusted-time regression and expiry checks and observes the shared revocation signal before it can introduce approval evidence. +The lifecycle fails closed on invalid validity windows, zero use limits, non-delegable actions, invalid state transitions, trusted-time regression, self-approval, requester mismatch, decision-actor mismatch, exact-scope mismatch, and expiry. Checker-role, tenant-membership, actor-uniqueness, and business-authorization failures must already have failed closed at the trusted identity/workflow boundary before an approval or denial enters this lifecycle. The consumed-use evaluation repeats the trusted-time regression and direct expiry checks and observes the shared terminal invalidation signal before it can introduce approval evidence. Within the live `EnterpriseApprovalRequest` instance, a successful consume spends that use even if downstream policy evaluation denies the action or the resulting one-shot value later fails its evaluation-time validity check. This deliberately prefers loss of a delegated use over replay ambiguity. A caller needing another attempt must obtain another bounded lifecycle use through the authoritative request state rather than recover authority from a failed evaluation. -If process failure occurs after `consume` but before the one-shot evaluation completes, the in-memory request has advanced, but this crate does not persist that state or its revocation signal across restart. Crash-safe replay and revocation prevention require an external durable control plane that atomically preserves authoritative consumption/revocation state and recovery evidence. It must not be approximated by making the approval use cloneable or replayable. +If process failure occurs after `consume` but before the one-shot evaluation completes, the in-memory request has advanced, but this crate does not persist that state or its terminal invalidation signal across restart. Crash-safe replay, expiry propagation, and revocation prevention require an external durable control plane that atomically preserves authoritative consumption/expiry/revocation state and recovery evidence. It must not be approximated by making the approval use cloneable or replayable. ## Security / privacy / governance impact -The decision narrows enterprise approval authority by coupling each configured use to one non-replayable, still-valid evaluation attempt. It prevents cloning of lifecycle state or consumed execution authority from bypassing `max_uses`, expiry, terminal-state, or revocation semantics, prevents a token created immediately before expiry from being exercised after its approval deadline, and prevents an already-issued but not-yet-evaluated token from surviving a successful checker revocation in the same live process even when that token was the final configured use. Principal references additionally reject Unicode `Bidi_Control` formatting characters so invisible direction overrides or isolates cannot create a misleading displayed identity while retaining a different exact `(issuer, subject)` tuple. +The decision narrows enterprise approval authority by coupling each configured use to one non-replayable, still-valid evaluation attempt. It prevents cloning of lifecycle state or consumed execution authority from bypassing `max_uses`, expiry, terminal-state, or revocation semantics; prevents a token created immediately before expiry from being exercised after its approval deadline; prevents an already-issued but not-yet-evaluated token from surviving a successful checker revocation in the same live process even when that token was the final configured use; and prevents a caller from resurrecting an outstanding token with a backdated timestamp after the live request has already observed expiry. Principal references additionally reject Unicode `Bidi_Control` formatting characters so invisible direction overrides or isolates cannot create a misleading displayed identity while retaining a different exact `(issuer, subject)` tuple. The decision does not put credentials, secrets, mutable identity attributes, or raw identity-provider tokens into model context. Principal references remain opaque. Legal consent remains non-delegable. Existing origin, capability, secret-broker, and risk gates are unchanged and continue to fail closed independently of enterprise approval. @@ -103,6 +106,8 @@ The owning PR must retain realistic executable evidence for: - evaluation at the retained expiry deadline and trusted-time rollback after consumption both failing closed before approval evidence is applied; - checker revocation after one use was issued from a still-live multi-use request invalidating that unexecuted use before approval evidence is applied; - checker revocation after the final configured use was issued invalidating that still-outstanding use before approval evidence is applied; +- request-observed expiry after an earlier use was issued invalidating that outstanding use even when evaluation later supplies a backdated timestamp inside the use's original local validity window; +- expiry observed through the revocation transition invalidating an already-issued use under the same backdated-evaluation attempt; - compile-time proof that `EnterpriseApprovalRequest` and `EnterpriseApprovalUse` are not cloneable; and - exact-head repository contracts, Rust 1.97.1 formatting/check/tests/strict Clippy/rustdoc, security scanning where applicable, and exact owned-production function/line/region/branch coverage. @@ -112,11 +117,11 @@ Historical or predecessor-head results do not establish acceptance for a changed Call sites must migrate from storing or passing raw enterprise-produced `ApprovalEvidence` to consuming `EnterpriseApprovalUse::evaluate_at` with trusted current time. No persistence migration is introduced by this branch. -A rollback must revert the lifecycle/use API coherently. Reintroducing a direct `consume -> ApprovalEvidence` path, adding `Clone`/`Copy` to lifecycle accounting or consumed-use types, restoring an untimed evaluation path, detaching issued uses from live in-process checker revocation, or mutating a reusable caller policy context with enterprise approval evidence is not an acceptable partial rollback because it reopens replay, post-expiry, or post-revocation authority. +A rollback must revert the lifecycle/use API coherently. Reintroducing a direct `consume -> ApprovalEvidence` path, adding `Clone`/`Copy` to lifecycle accounting or consumed-use types, restoring an untimed evaluation path, detaching issued uses from live in-process terminal expiry/revocation invalidation, or mutating a reusable caller policy context with enterprise approval evidence is not an acceptable partial rollback because it reopens replay, post-expiry, or post-revocation authority. ## Open follow-ups -Issue #202 remains the owner for the broader enterprise control plane, including trusted principal authentication, tenant identity, durable state, workflow delivery, operator UI, signed/auditable evidence, and crash-safe/distributed consumption and revocation semantics. Those additions must preserve the exact-scope, separation-of-duties, monotonic-time, terminal-state, evaluation-time expiry, in-process outstanding-use revocation, one-shot-use, and canonical-principal-display invariants defined here. +Issue #202 remains the owner for the broader enterprise control plane, including trusted principal authentication, tenant identity, durable state, workflow delivery, operator UI, signed/auditable evidence, and crash-safe/distributed consumption, expiry, and revocation semantics. Those additions must preserve the exact-scope, separation-of-duties, monotonic-time, terminal-state, evaluation-time expiry, in-process outstanding-use terminal invalidation, one-shot-use, and canonical-principal-display invariants defined here. ## Supersession / reversal conditions From 1030018fd64c46728e41b3f2663a39c92cd0dca3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 12:44:37 -0700 Subject: [PATCH 50/54] fix(policy): apply canonical rustfmt to terminal invalidation --- crates/originweave-policy/src/enterprise_approval.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index 806cbaee6..a7e4c5111 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -179,9 +179,9 @@ impl EnterpriseApprovalUse { if let Some(invalidation) = self.invalidation_signal.get() { return Err(match invalidation { ApprovalUseInvalidation::Expired => ApprovalLifecycleError::Expired, - ApprovalUseInvalidation::Revoked => ApprovalLifecycleError::InvalidState( - ApprovalLifecycleState::Revoked, - ), + ApprovalUseInvalidation::Revoked => { + ApprovalLifecycleError::InvalidState(ApprovalLifecycleState::Revoked) + } }); } let mut one_shot_context = context.clone(); From ce001d3fefe7185e905f4e458140bfe5668d65dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 13:18:51 -0700 Subject: [PATCH 51/54] test(policy): reject approval use scope drift --- .../enterprise_approval_use_scope_binding.rs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 crates/originweave-policy/tests/enterprise_approval_use_scope_binding.rs diff --git a/crates/originweave-policy/tests/enterprise_approval_use_scope_binding.rs b/crates/originweave-policy/tests/enterprise_approval_use_scope_binding.rs new file mode 100644 index 000000000..2daa5443b --- /dev/null +++ b/crates/originweave-policy/tests/enterprise_approval_use_scope_binding.rs @@ -0,0 +1,96 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, Capability, + ExecutionPurpose, InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, + SessionMode, +}; +use originweave_policy::{ + ApprovalLifecycleError, ApprovalPrincipalRef, EnterpriseApprovalRequest, +}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn site() -> Origin { + Origin::parse("https://shop.example").expect("test origin must be valid") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("test intent digest must be valid") +} + +fn purchase_scope() -> ApprovalScope { + ApprovalScope::new(ActionKind::Purchase, site(), intent()) +} + +fn principal(subject: &str) -> ApprovalPrincipalRef { + ApprovalPrincipalRef::new("https://id.example", subject).expect("test principal must be valid") +} + +fn observe_request() -> ActionRequest { + let origin = site(); + ActionRequest::new( + ActionKind::Observe, + origin.clone(), + origin, + InstructionSource::User, + SecretDelivery::None, + intent(), + ) +} + +fn observe_context() -> PolicyContext { + let origin = site(); + PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([origin.clone()]), + BTreeSet::from([origin]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ) +} + +fn issued_purchase_use( + consume_at_epoch_seconds: u64, +) -> originweave_policy::EnterpriseApprovalUse { + let approved_scope = purchase_scope(); + let mut approval = EnterpriseApprovalRequest::new( + approved_scope.clone(), + principal("maker"), + 100, + 200, + 1, + ) + .expect("approval request must be valid"); + approval + .approve(principal("checker"), 110) + .expect("distinct checker must approve"); + approval + .consume(&approved_scope, consume_at_epoch_seconds) + .expect("approved exact scope must yield one bounded use") +} + +#[test] +fn consumed_approval_use_rejects_a_different_low_risk_scope() { + let approval_use = issued_purchase_use(120); + + assert_eq!( + approval_use.evaluate_at(&observe_request(), &observe_context(), 130), + Err(ApprovalLifecycleError::ScopeMismatch) + ); +} + +#[test] +fn mismatched_use_scope_is_rejected_before_lifecycle_state_is_disclosed() { + let approval_use = issued_purchase_use(199); + + assert_eq!( + approval_use.evaluate_at(&observe_request(), &observe_context(), 200), + Err(ApprovalLifecycleError::ScopeMismatch) + ); +} From 248740a7cfdec0d534dda803ab5f04c2110cb1ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 13:21:26 -0700 Subject: [PATCH 52/54] test(policy): format approval scope regressions --- .../enterprise_approval_use_scope_binding.rs | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/crates/originweave-policy/tests/enterprise_approval_use_scope_binding.rs b/crates/originweave-policy/tests/enterprise_approval_use_scope_binding.rs index 2daa5443b..567e7198b 100644 --- a/crates/originweave-policy/tests/enterprise_approval_use_scope_binding.rs +++ b/crates/originweave-policy/tests/enterprise_approval_use_scope_binding.rs @@ -7,9 +7,7 @@ use originweave_core::{ ExecutionPurpose, InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, }; -use originweave_policy::{ - ApprovalLifecycleError, ApprovalPrincipalRef, EnterpriseApprovalRequest, -}; +use originweave_policy::{ApprovalLifecycleError, ApprovalPrincipalRef, EnterpriseApprovalRequest}; const VALID_INTENT: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -55,18 +53,11 @@ fn observe_context() -> PolicyContext { ) } -fn issued_purchase_use( - consume_at_epoch_seconds: u64, -) -> originweave_policy::EnterpriseApprovalUse { +fn issued_purchase_use(consume_at_epoch_seconds: u64) -> originweave_policy::EnterpriseApprovalUse { let approved_scope = purchase_scope(); - let mut approval = EnterpriseApprovalRequest::new( - approved_scope.clone(), - principal("maker"), - 100, - 200, - 1, - ) - .expect("approval request must be valid"); + let mut approval = + EnterpriseApprovalRequest::new(approved_scope.clone(), principal("maker"), 100, 200, 1) + .expect("approval request must be valid"); approval .approve(principal("checker"), 110) .expect("distinct checker must approve"); From f04980c7a21b8370b69ded84cd2757fd802a312c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 13:25:38 -0700 Subject: [PATCH 53/54] fix(policy): bind approval use to exact request scope --- .../src/enterprise_approval.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs index a7e4c5111..dd09759a8 100644 --- a/crates/originweave-policy/src/enterprise_approval.rs +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -124,13 +124,15 @@ enum ApprovalUseInvalidation { /// This value is intentionally not [`Clone`]. It is created only by /// [`EnterpriseApprovalRequest::consume`] after exact-scope, trusted-time, and /// use-count checks succeed. [`Self::evaluate_at`] consumes the value and first +/// rejects any request whose action/origin/intent differs from the retained +/// exact scope, before reading lifecycle or trusted-time state. It then /// revalidates trusted time against both the consumption time and retained -/// exclusive expiry deadline, then rejects any shared terminal lifecycle +/// exclusive expiry deadline and rejects any shared terminal lifecycle /// invalidation observed by the issuing request after this use was issued. Only /// a still-valid use injects the approved scope into a private copy of the /// supplied policy context and delegates to the normal fail-closed policy -/// evaluator. The use is burned even when evaluation is denied for expiry, time -/// rollback, revocation, policy, or a different approval need. +/// evaluator. The use is burned even when evaluation is denied for scope, +/// expiry, time rollback, revocation, policy, or a different approval need. /// /// ```compile_fail /// # use originweave_core::{ActionRequest, PolicyContext}; @@ -156,6 +158,8 @@ pub struct EnterpriseApprovalUse { impl EnterpriseApprovalUse { /// Evaluate exactly one action using this already-consumed approval use. /// + /// The incoming request must resolve to the retained exact approval scope; + /// scope is checked before lifecycle or trusted-time state is exposed. /// `now_epoch_seconds` must come from the same trusted control-plane clock /// used by the approval lifecycle. Evaluation fails closed if trusted time /// moves backward before the consumption time, reaches the retained @@ -170,6 +174,14 @@ impl EnterpriseApprovalUse { context: &PolicyContext, now_epoch_seconds: u64, ) -> Result { + let required_scope = ApprovalScope::new( + request.action(), + request.target_origin().clone(), + request.intent_digest().clone(), + ); + if required_scope != self.scope { + return Err(ApprovalLifecycleError::ScopeMismatch); + } if now_epoch_seconds < self.consumed_at_epoch_seconds { return Err(ApprovalLifecycleError::NonMonotonicTime); } From e0740a6f3a41067a4460249378e0266815018a74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 13:32:05 -0700 Subject: [PATCH 54/54] docs(adr): align approval-use scope ordering --- .../0017-enterprise-maker-checker-approval.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/adr/0017-enterprise-maker-checker-approval.md b/docs/adr/0017-enterprise-maker-checker-approval.md index 325502ac7..84736f185 100644 --- a/docs/adr/0017-enterprise-maker-checker-approval.md +++ b/docs/adr/0017-enterprise-maker-checker-approval.md @@ -55,7 +55,7 @@ Rejected. `PolicyContext` is a reusable policy input and is cloneable by design. ### Return a linear, non-cloneable approval-use value -Selected. A successful lifecycle consumption produces exactly one `EnterpriseApprovalUse`. Its policy-evaluation operation consumes `self`, requires current trusted time, rejects time rollback, direct deadline expiry, or a shared terminal expiry/revocation observed by the issuing request before evaluation begins, injects the exact approved scope only into a private cloned context for that one evaluation, and delegates to the ordinary fail-closed evaluator. +Selected. A successful lifecycle consumption produces exactly one `EnterpriseApprovalUse`. Its policy-evaluation operation consumes `self`, requires current trusted time, rejects any request whose action/origin/intent differs from the retained exact scope before exposing lifecycle or time state, then rejects time rollback, direct deadline expiry, or a shared terminal expiry/revocation observed by the issuing request before evaluation begins. Only a still-valid exact-scope use injects approval into a private cloned context for that one evaluation and delegates to the ordinary fail-closed evaluator. ## Decision @@ -65,15 +65,15 @@ A pending request may be approved or denied only by a principal distinct from th `consume` is permitted only from `Approved`, before expiry, and for an exactly equal `ApprovalScope`. A scope mismatch does not spend a use. A successful consume increments lifecycle accounting immediately and returns a non-cloneable `EnterpriseApprovalUse` that retains the exact scope, consumption time, exclusive expiry deadline, and a shared one-way terminal invalidation signal. The request becomes `Consumed` when the configured use count is exhausted. If a later consume attempt observes the expiry deadline while the request is still `Approved`, it records shared `Expired` invalidation before entering `Expired`; outstanding uses from the same live request then fail closed even if their evaluator supplies an earlier timestamp. -`EnterpriseApprovalUse::evaluate_at(self, request, context, now_epoch_seconds)` consumes the approval-use value. It first rejects trusted time earlier than the recorded consumption time with `NonMonotonicTime`, rejects evaluation at or after the retained exclusive deadline with `Expired`, and then checks the issuing request's shared terminal invalidation. An observed `Expired` invalidation returns `Expired`; an observed `Revoked` invalidation returns `InvalidState(Revoked)`. Only then does it clone the supplied policy context privately, install `ApprovalEvidence::UserConfirmed` for the retained exact scope in that private copy, and delegate to the normal deterministic policy evaluator. The caller's reusable context is not upgraded. The approval use is burned regardless of whether evaluation returns a policy decision or fails the evaluation-time validity checks. +`EnterpriseApprovalUse::evaluate_at(self, request, context, now_epoch_seconds)` consumes the approval-use value. It first reconstructs the exact `ApprovalScope` from the supplied request's action, canonical target origin, and immutable action-intent digest and returns `ScopeMismatch` if that scope differs from the retained approved scope. This scope check intentionally precedes lifecycle and trusted-time checks so an unrelated request cannot use the token to infer expiry or terminal state. For an exact-scope request, evaluation rejects trusted time earlier than the recorded consumption time with `NonMonotonicTime`, rejects evaluation at or after the retained exclusive deadline with `Expired`, and then checks the issuing request's shared terminal invalidation. An observed `Expired` invalidation returns `Expired`; an observed `Revoked` invalidation returns `InvalidState(Revoked)`. Only then does it clone the supplied policy context privately, install `ApprovalEvidence::UserConfirmed` for the retained exact scope in that private copy, and delegate to the normal deterministic policy evaluator. The caller's reusable context is not upgraded. The approval use is burned regardless of whether evaluation returns a policy decision or fails scope, time, expiry, or terminal-invalidation validation. The terminal invalidation signal is intentionally one-way and process-local. Once set it cannot be cleared. An outstanding use that begins its validity check after request-observed expiry or checker revocation fails closed according to the first shared terminal condition recorded by the live request. An evaluation that has already passed that validity check is considered in flight; stronger cross-process or transactional cancellation semantics belong to the durable enterprise control plane under issue #202. -No public API converts `EnterpriseApprovalUse` back into reusable `ApprovalEvidence`, exposes its retained scope for later reinjection, or implements `Clone`/`Copy` for it. There is no untimed evaluation entry point that can bypass the retained expiry or terminal-invalidation boundary. +No public API converts `EnterpriseApprovalUse` back into reusable `ApprovalEvidence`, exposes its retained scope for later reinjection, or implements `Clone`/`Copy` for it. There is no untimed evaluation entry point that can bypass the retained scope, expiry, or terminal-invalidation boundary. ## Consequences -Enterprise callers receive a capability-like one-shot policy input rather than reusable approval evidence. This aligns effective execution authority with lifecycle accounting: each successful consumption can authorize at most one still-valid policy evaluation, and a denied, expired, or revoked evaluation cannot be retried by replaying the same consumed value. +Enterprise callers receive a capability-like one-shot policy input rather than reusable approval evidence. This aligns effective execution authority with lifecycle accounting: each successful consumption can authorize at most one still-valid, exact-scope policy evaluation, and a scope mismatch, policy denial, expiry, or revocation cannot be retried by replaying the same consumed value. Callers that previously expected `consume` to return `ApprovalEvidence` must instead pass the returned `EnterpriseApprovalUse` directly to its consuming `evaluate_at` method together with the intended request, ordinary policy context, and trusted current epoch seconds. @@ -81,7 +81,7 @@ The policy crate remains deterministic and I/O-free. Authentication, clock acqui ## Failure and degraded behavior -The lifecycle fails closed on invalid validity windows, zero use limits, non-delegable actions, invalid state transitions, trusted-time regression, self-approval, requester mismatch, decision-actor mismatch, exact-scope mismatch, and expiry. Checker-role, tenant-membership, actor-uniqueness, and business-authorization failures must already have failed closed at the trusted identity/workflow boundary before an approval or denial enters this lifecycle. The consumed-use evaluation repeats the trusted-time regression and direct expiry checks and observes the shared terminal invalidation signal before it can introduce approval evidence. +The lifecycle fails closed on invalid validity windows, zero use limits, non-delegable actions, invalid state transitions, trusted-time regression, self-approval, requester mismatch, decision-actor mismatch, exact-scope mismatch, and expiry. Checker-role, tenant-membership, actor-uniqueness, and business-authorization failures must already have failed closed at the trusted identity/workflow boundary before an approval or denial enters this lifecycle. The consumed-use evaluation repeats exact request-scope binding before trusted-time regression, direct expiry, and shared terminal invalidation checks so a mismatched request neither gains authority nor learns lifecycle/time state. Within the live `EnterpriseApprovalRequest` instance, a successful consume spends that use even if downstream policy evaluation denies the action or the resulting one-shot value later fails its evaluation-time validity check. This deliberately prefers loss of a delegated use over replay ambiguity. A caller needing another attempt must obtain another bounded lifecycle use through the authoritative request state rather than recover authority from a failed evaluation. @@ -89,7 +89,7 @@ If process failure occurs after `consume` but before the one-shot evaluation com ## Security / privacy / governance impact -The decision narrows enterprise approval authority by coupling each configured use to one non-replayable, still-valid evaluation attempt. It prevents cloning of lifecycle state or consumed execution authority from bypassing `max_uses`, expiry, terminal-state, or revocation semantics; prevents a token created immediately before expiry from being exercised after its approval deadline; prevents an already-issued but not-yet-evaluated token from surviving a successful checker revocation in the same live process even when that token was the final configured use; and prevents a caller from resurrecting an outstanding token with a backdated timestamp after the live request has already observed expiry. Principal references additionally reject Unicode `Bidi_Control` formatting characters so invisible direction overrides or isolates cannot create a misleading displayed identity while retaining a different exact `(issuer, subject)` tuple. +The decision narrows enterprise approval authority by coupling each configured use to one non-replayable, exact-scope, still-valid evaluation attempt. It prevents cloning of lifecycle state or consumed execution authority from bypassing `max_uses`, exact scope, expiry, terminal-state, or revocation semantics; prevents an unrelated low-risk request from bypassing scope binding; prevents a mismatched request from learning expiry/terminal state before receiving `ScopeMismatch`; prevents a token created immediately before expiry from being exercised after its approval deadline; prevents an already-issued but not-yet-evaluated token from surviving a successful checker revocation in the same live process even when that token was the final configured use; and prevents a caller from resurrecting an outstanding token with a backdated timestamp after the live request has already observed expiry. Principal references additionally reject Unicode `Bidi_Control` formatting characters so invisible direction overrides or isolates cannot create a misleading displayed identity while retaining a different exact `(issuer, subject)` tuple. The decision does not put credentials, secrets, mutable identity attributes, or raw identity-provider tokens into model context. Principal references remain opaque. Legal consent remains non-delegable. Existing origin, capability, secret-broker, and risk gates are unchanged and continue to fail closed independently of enterprise approval. @@ -100,6 +100,7 @@ The owning PR must retain realistic executable evidence for: - distinct maker/checker approval of an exact immutable scope; - rejection of non-canonical principal references including control and Unicode `Bidi_Control` formatting characters; - rejection of self-approval, requester mismatch, decision-actor mismatch, scope mutation, expiry, clock regression, and invalid terminal transitions; +- rejection of a consumed use presented to a different low-risk request before lifecycle/time state is exposed; - exact bounded multi-use accounting; - a single configured use yielding exactly one policy evaluation and rejecting subsequent lifecycle consumption; - a policy denial burning the already consumed one-shot use; @@ -115,9 +116,9 @@ Historical or predecessor-head results do not establish acceptance for a changed ## Migration and rollback -Call sites must migrate from storing or passing raw enterprise-produced `ApprovalEvidence` to consuming `EnterpriseApprovalUse::evaluate_at` with trusted current time. No persistence migration is introduced by this branch. +Call sites must migrate from storing or passing raw enterprise-produced `ApprovalEvidence` to consuming `EnterpriseApprovalUse::evaluate_at` with the exact intended request and trusted current time. No persistence migration is introduced by this branch. -A rollback must revert the lifecycle/use API coherently. Reintroducing a direct `consume -> ApprovalEvidence` path, adding `Clone`/`Copy` to lifecycle accounting or consumed-use types, restoring an untimed evaluation path, detaching issued uses from live in-process terminal expiry/revocation invalidation, or mutating a reusable caller policy context with enterprise approval evidence is not an acceptable partial rollback because it reopens replay, post-expiry, or post-revocation authority. +A rollback must revert the lifecycle/use API coherently. Reintroducing a direct `consume -> ApprovalEvidence` path, adding `Clone`/`Copy` to lifecycle accounting or consumed-use types, restoring an untimed evaluation path, removing exact request-scope revalidation, detaching issued uses from live in-process terminal expiry/revocation invalidation, or mutating a reusable caller policy context with enterprise approval evidence is not an acceptable partial rollback because it reopens replay, scope-confusion/privacy, post-expiry, or post-revocation authority. ## Open follow-ups @@ -125,7 +126,7 @@ Issue #202 remains the owner for the broader enterprise control plane, including ## Supersession / reversal conditions -Supersede this ADR if OriginWeave adopts a different formally bounded authority object that can prove, under concurrency and crash recovery, that one enterprise approval use cannot authorize more policy evaluations than the authoritative lifecycle permits. Any replacement must retain or strengthen exact intent binding, maker-checker separation, trusted-time ordering, fail-closed terminal states, evaluation-time expiry and revocation, R5 non-delegability, replay resistance, and principal-reference presentation safety. +Supersede this ADR if OriginWeave adopts a different formally bounded authority object that can prove, under concurrency and crash recovery, that one enterprise approval use cannot authorize more policy evaluations than the authoritative lifecycle permits. Any replacement must retain or strengthen exact intent binding, maker-checker separation, trusted-time ordering, fail-closed terminal states, evaluation-time scope/expiry/revocation, R5 non-delegability, replay resistance, and principal-reference presentation safety. ## References