diff --git a/CHANGELOG.md b/CHANGELOG.md index 116c02fe6..6f9d62660 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, fail-closed terminal states, and non-cloneable one-shot policy-evaluation uses so consumed enterprise authority cannot be replayed as reusable approval evidence. - Refreshed the product and technical gap baseline with the 2026-08-24 live inventory: 158 open pull requests (44 ready, 114 draft), refreshed exact base/head evidence for the #208–#222 release, enterprise-approval, BAP, and WARC/PROV chains, the governance issue additions #212 and #215, and a required-check provider-failure record for the fail-closed Strix re-dispatches on #208/#218/#220. - Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. - Refreshed the product and technical gap baseline with the current open-PR inventory and exact base/head evidence for the newest Chromium, BAP, extraction, WARC, and idempotency slices. @@ -64,6 +65,8 @@ 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. +- 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. diff --git a/crates/originweave-policy/src/enterprise_approval.rs b/crates/originweave-policy/src/enterprise_approval.rs new file mode 100644 index 000000000..dd09759a8 --- /dev/null +++ b/crates/originweave-policy/src/enterprise_approval.rs @@ -0,0 +1,535 @@ +//! 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, + sync::{Arc, OnceLock}, +}; + +use originweave_core::{ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, PolicyContext}; + +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(|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. +#[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 a request after approval, including after all uses were issued. + 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 +/// 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 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 scope, +/// expiry, time rollback, revocation, policy, or a different approval need. +/// +/// ```compile_fail +/// # use originweave_core::{ActionRequest, PolicyContext}; +/// # use originweave_policy::EnterpriseApprovalUse; +/// # fn replay_is_rejected( +/// # approval_use: EnterpriseApprovalUse, +/// # request: &ActionRequest, +/// # context: &PolicyContext, +/// # trusted_now: u64, +/// # ) { +/// 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, + invalidation_signal: Arc>, +} + +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 + /// 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, + 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); + } + if now_epoch_seconds >= self.expires_at_epoch_seconds { + return Err(ApprovalLifecycleError::Expired); + } + 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)); + Ok(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. +/// 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, PartialEq, Eq)] +pub struct EnterpriseApprovalRequest { + scope: ApprovalScope, + requester: ApprovalPrincipalRef, + 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, + invalidation_signal: Arc>, +} + +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, + last_transition_at_epoch_seconds: requested_at_epoch_seconds, + max_uses, + uses_consumed: 0, + state: ApprovalLifecycleState::ApprovalRequested, + invalidation_signal: Arc::new(OnceLock::new()), + }) + } + + /// 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 + } + + 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. + /// + /// 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)); + } + 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); + } + self.decision_actor = Some(approver); + self.last_transition_at_epoch_seconds = now_epoch_seconds; + self.state = ApprovalLifecycleState::Approved; + Ok(()) + } + + /// Deny a pending request as a distinct checker. + /// + /// 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)); + } + 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); + } + self.decision_actor = Some(actor); + self.last_transition_at_epoch_seconds = now_epoch_seconds; + self.state = ApprovalLifecycleState::Denied; + Ok(()) + } + + /// Withdraw a pending request as the exact requesting maker. + /// + /// 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)); + } + 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); + } + self.last_transition_at_epoch_seconds = now_epoch_seconds; + 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. 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 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, + 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)); + } + 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.uses_consumed += 1; + self.last_transition_at_epoch_seconds = now_epoch_seconds; + if self.uses_consumed == self.max_uses { + self.state = ApprovalLifecycleState::Consumed; + } + Ok(EnterpriseApprovalUse { + scope: self.scope.clone(), + consumed_at_epoch_seconds: now_epoch_seconds, + expires_at_epoch_seconds: self.expires_at_epoch_seconds, + invalidation_signal: Arc::clone(&self.invalidation_signal), + }) + } + + /// Revoke an approved or fully-issued request as the exact checker that approved it. + /// + /// 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`]. 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, + 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 + ) { + return Err(ApprovalLifecycleError::InvalidState(self.state)); + } + 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.invalidation_signal + .get_or_init(|| ApprovalUseInvalidation::Revoked); + self.last_transition_at_epoch_seconds = now_epoch_seconds; + 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), + /// 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. + 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::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 => { + 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 {} diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index dbfb3c16d..7e5b051a1 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, EnterpriseApprovalUse, +}; pub use sensitive_data::{ DataClassification, DisclosureDecision, DisclosureScope, HandleUseDecision, HandleUseRequest, SensitiveDataAuthority, SensitiveDataRequest, SensitiveValueHandleScope, evaluate_disclosure, 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..deb8a9424 --- /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; +} 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..633086b4d --- /dev/null +++ b/crates/originweave-policy/tests/enterprise_approval_lifecycle.rs @@ -0,0 +1,331 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ActionIntentDigest, ActionKind, 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\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) + ); +} + +#[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 _approval_use = request + .consume(&scope, 120) + .expect("approved exact scope must be consumable"); + 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"); + + 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); + 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!( + 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()); + } +} 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..529ffd1ed --- /dev/null +++ b/crates/originweave-policy/tests/enterprise_approval_scope_privacy.rs @@ -0,0 +1,117 @@ +#![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); +} + +#[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); +} 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..86e1ee303 --- /dev/null +++ b/crates/originweave-policy/tests/enterprise_approval_single_use.rs @@ -0,0 +1,301 @@ +#![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_at( + &purchase_request(), + &policy_context(BTreeSet::from([Capability::Purchase])), + 130, + ); + assert_eq!(decision, Ok(Decision::Allow)); + assert_eq!( + approval.consume(&approval_scope, 130), + Err(ApprovalLifecycleError::InvalidState( + ApprovalLifecycleState::Consumed + )) + ); +} + +#[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 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(); + 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::new()), 130), + Ok(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 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 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(); + 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 { + fn marker() {} + } + + impl AmbiguousIfClone<()> for T {} + + struct CloneImplemented; + impl AmbiguousIfClone for T {} + + let _ = >::marker; +} 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..3c0be0c1d --- /dev/null +++ b/crates/originweave-policy/tests/enterprise_approval_time_integrity.rs @@ -0,0 +1,101 @@ +#![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 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"); + + 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); +} + +#[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(); + 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_eq!( + request.consume(&scope, 149), + Err(ApprovalLifecycleError::NonMonotonicTime) + ); + 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_eq!( + request.revoke(&checker, 149), + Err(ApprovalLifecycleError::NonMonotonicTime) + ); + assert_eq!(request.state(), ApprovalLifecycleState::Approved); +} 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..1778616e3 --- /dev/null +++ b/crates/originweave-policy/tests/enterprise_approval_transition_guards.rs @@ -0,0 +1,83 @@ +#![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 + )) + ); +} 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..567e7198b --- /dev/null +++ b/crates/originweave-policy/tests/enterprise_approval_use_scope_binding.rs @@ -0,0 +1,87 @@ +#![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) + ); +} diff --git a/docs/README.md b/docs/README.md index 775dd0de6..615a95ba3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -87,4 +87,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. 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..84736f185 --- /dev/null +++ b/docs/adr/0017-enterprise-maker-checker-approval.md @@ -0,0 +1,135 @@ +# 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. + +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 + +- 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. +- 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. + +## Assumptions and authority boundaries + +`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, 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. + +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. + +## 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`, 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 + +`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. 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 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 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 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, 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. + +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 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. + +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, 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. + +## Tests and acceptance evidence + +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; +- 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. + +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_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, 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 + +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 + +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 + +- [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. 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. 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/