diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fe287389b..6f3348e0a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -137,6 +137,10 @@ Owns validated task budgets and deterministic cumulative mitigation plans. Platf Owns universally value-redacted network evidence and source-bound provenance records. Generic network records retain only bounded method, canonical origin, unambiguous bounded path, and bounded field names. Body capture, typed metadata values, WARC serialization, object storage, retention, encryption, and legal policy remain future bounded modules. +### `originweave-bap` + +The active BAP lane owns the in-memory task lifecycle and immutable command-receipt contract. A receipt binds a bounded tenant namespace, idempotency key, and task identifier to one accepted lifecycle event; the caller-supplied tenant namespace scopes retry identity only and is not authentication. The receipt can identify an exact retry but is not durable deduplication, policy authority, transport, browser state, or side-effect suppression. + ## 6. Planned modules ```text diff --git a/CHANGELOG.md b/CHANGELOG.md index f747adeae..e22024564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Refreshed the product and technical gap baseline onto the 2026-08-26 live inventory: 126 open pull requests (54 ready, 72 draft), protected-main promotion of #168/#194/#196/#216/#151, a verified maintenance-loop record (supersession closure of #153, conflict reconciliations on #37/#149/#152/#173/#175, issue #212 option-(b) authorization on #43, Strix vuln-0001 homoglyph remediation on #124), provider-rerun outcome evidence, an organization review-pipeline congestion record, and refreshed merge-order queue guidance. Documentation evidence contracts were aligned to the same snapshot so the baseline, its dated markers, and the pinned exact-head rows cannot silently diverge. - Added `originweave_core::release_acceptance`, a deterministic fail-closed benchmark release-decision contract that requires one authoritative result for every mandatory suite, bounds explicit buyer-visible limitations, rejects duplicate limitation claim identities, and rejects non-canonical surrounding whitespace rather than normalizing it into an alternate claim spelling. +- Added an active BAP in-memory command receipt that binds bounded tenant namespaces, idempotency keys, and task identities to accepted lifecycle transitions without claiming authenticated tenant authority, durable deduplication, or side-effect suppression. - 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. @@ -48,6 +49,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Receipt replay now additionally requires the lifecycle's actual most recently accepted transition to equal the retained receipt transition; same-state/same-sequence divergent histories and state-only restored snapshots fail closed instead of replaying ambiguous command evidence. - Aligned the hourly product-development branch-coverage toolchain and its one-shot materializer with the reviewed `nightly-2026-08-18` pin, and corrected the official Dependabot Rust-toolchain reference. - Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result. - Separated resolved-address authorization from direct transport evidence; an approved IP now becomes a usable stream only after the operating system reports the exact requested IP and port. diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 404a88c10..f9e6c184f 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -8,6 +8,13 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +/// Maximum UTF-8 byte length of a mutating command's idempotency key. +pub const MAX_BAP_IDEMPOTENCY_KEY_BYTES: usize = 128; +/// Maximum UTF-8 byte length of a BAP tenant namespace identifier. +pub const MAX_BAP_TENANT_ID_BYTES: usize = 128; +/// Maximum UTF-8 byte length of a BAP task identifier. +pub const MAX_BAP_TASK_ID_BYTES: usize = 128; + /// Durable logical state of one governed BAP task. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BapTaskState { @@ -136,6 +143,20 @@ pub enum BapTaskRestoreError { /// Last accepted transition sequence supplied by the durable recovery boundary. transition_sequence: u64, }, + /// A non-created snapshot omitted the exact last accepted transition evidence. + MissingTransitionEvidence { + /// Logical state supplied by the durable recovery boundary. + state: BapTaskState, + /// Last accepted transition sequence supplied by the durable recovery boundary. + transition_sequence: u64, + }, + /// Supplied last-transition evidence is inconsistent with the lifecycle state machine or snapshot. + InvalidTransitionEvidence { + /// Logical state the evidence attempted to restore. + state: BapTaskState, + /// Transition sequence the evidence attempted to restore. + transition_sequence: u64, + }, } impl std::fmt::Display for BapTaskRestoreError { @@ -148,6 +169,20 @@ impl std::fmt::Display for BapTaskRestoreError { formatter, "BAP task snapshot state {state:?} with transition sequence {transition_sequence} is unreachable" ), + Self::MissingTransitionEvidence { + state, + transition_sequence, + } => write!( + formatter, + "BAP task snapshot state {state:?} with transition sequence {transition_sequence} is missing last-transition evidence" + ), + Self::InvalidTransitionEvidence { + state, + transition_sequence, + } => write!( + formatter, + "BAP task transition evidence for state {state:?} with transition sequence {transition_sequence} is invalid" + ), } } } @@ -160,6 +195,7 @@ pub struct BapTaskTransition { previous_state: BapTaskState, current_state: BapTaskState, sequence: u64, + event: BapTaskEvent, } impl BapTaskTransition { @@ -180,6 +216,211 @@ impl BapTaskTransition { pub const fn sequence(self) -> u64 { self.sequence } + + /// Return the accepted lifecycle event represented by this receipt. + #[must_use] + pub const fn event(self) -> BapTaskEvent { + self.event + } + + /// Reconstruct and validate one accepted transition from durable primitive evidence. + /// + /// This validates lifecycle consistency only. It does not authenticate the persistence + /// boundary, authorize the task, or grant browser, network, model, secret, or approval authority. + pub fn restore( + previous_state: BapTaskState, + current_state: BapTaskState, + sequence: u64, + event: BapTaskEvent, + ) -> Result { + let invalid = || BapTaskRestoreError::InvalidTransitionEvidence { + state: current_state, + transition_sequence: sequence, + }; + let Some(previous_sequence) = sequence.checked_sub(1) else { + return Err(invalid()); + }; + let mut lifecycle = + BapTaskLifecycle::restore(previous_state, previous_sequence).map_err(|_| invalid())?; + let transition = lifecycle.apply(event).map_err(|_| invalid())?; + if transition.current_state() != current_state { + return Err(invalid()); + } + Ok(transition) + } +} + +/// A validation or lifecycle failure while issuing a BAP command receipt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapCommandReceiptError { + /// The idempotency key was empty or contained unsupported input. + InvalidIdempotencyKey, + /// The idempotency key exceeded its byte bound. + IdempotencyKeyLimitExceeded, + /// The tenant namespace identifier was empty or contained unsupported input. + InvalidTenantId, + /// The tenant namespace identifier exceeded its byte bound. + TenantIdLimitExceeded, + /// The task identifier was empty or contained unsupported input. + InvalidTaskId, + /// The task identifier exceeded its byte bound. + TaskIdLimitExceeded, + /// A retained receipt did not bind the exact retry command that attempted to reuse it. + IdempotencyConflict, + /// The retained receipt's accepted transition does not match this lifecycle's current state. + ReplayStateMismatch, + /// The lifecycle event could not be accepted for the current task state. + TransitionRejected { + /// The lifecycle failure preserved by the receipt boundary. + error: BapTaskTransitionError, + }, +} + +impl std::fmt::Display for BapCommandReceiptError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidIdempotencyKey => write!(formatter, "BAP idempotency key is invalid"), + Self::IdempotencyKeyLimitExceeded => { + write!(formatter, "BAP idempotency key exceeds its byte limit") + } + Self::InvalidTenantId => write!(formatter, "BAP tenant ID is invalid"), + Self::TenantIdLimitExceeded => { + write!(formatter, "BAP tenant ID exceeds its byte limit") + } + Self::InvalidTaskId => write!(formatter, "BAP task ID is invalid"), + Self::TaskIdLimitExceeded => write!(formatter, "BAP task ID exceeds its byte limit"), + Self::IdempotencyConflict => write!( + formatter, + "BAP idempotency key conflicts with the retained command receipt" + ), + Self::ReplayStateMismatch => write!( + formatter, + "BAP retained command receipt does not match the current lifecycle state" + ), + Self::TransitionRejected { error } => error.fmt(formatter), + } + } +} + +impl std::error::Error for BapCommandReceiptError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::TransitionRejected { error } => Some(error), + Self::InvalidIdempotencyKey + | Self::IdempotencyKeyLimitExceeded + | Self::InvalidTenantId + | Self::TenantIdLimitExceeded + | Self::InvalidTaskId + | Self::TaskIdLimitExceeded + | Self::IdempotencyConflict + | Self::ReplayStateMismatch => None, + } + } +} + +/// An immutable receipt binding one accepted lifecycle command to its retry namespace and key. +#[derive(Clone, PartialEq, Eq)] +pub struct BapCommandReceipt { + idempotency_key: String, + tenant_id: String, + task_id: String, + transition: BapTaskTransition, +} + +impl std::fmt::Debug for BapCommandReceipt { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BapCommandReceipt") + .field("idempotency_key_byte_count", &self.idempotency_key.len()) + .field("task_id_byte_count", &self.task_id.len()) + .field("transition", &self.transition) + .finish() + } +} + +impl BapCommandReceipt { + fn from_validated( + idempotency_key: &str, + tenant_id: &str, + task_id: &str, + transition: BapTaskTransition, + ) -> Self { + Self { + idempotency_key: idempotency_key.to_owned(), + tenant_id: tenant_id.to_owned(), + task_id: task_id.to_owned(), + transition, + } + } + + /// Reconstruct one command receipt from persisted retry metadata and validated transition evidence. + /// + /// This revalidates the bounded retry identifiers before reconstructing the immutable receipt. + /// It does not authenticate the persistence boundary, authorize the tenant or task, or prove that + /// the supplied transition was durably committed with any external browser or network side effect. + pub fn restore( + idempotency_key: &str, + tenant_id: &str, + task_id: &str, + transition: BapTaskTransition, + ) -> Result { + validate_idempotency_key(idempotency_key)?; + validate_tenant_id(tenant_id)?; + validate_task_id(task_id)?; + Ok(Self::from_validated( + idempotency_key, + tenant_id, + task_id, + transition, + )) + } + + /// Return the opaque retry key supplied by the caller. + #[must_use] + pub fn idempotency_key(&self) -> &str { + &self.idempotency_key + } + + /// Return the caller-supplied tenant namespace bound to this receipt. + /// + /// This value scopes retry identity only. It is not authentication or authorization evidence. + #[must_use] + pub fn tenant_id(&self) -> &str { + &self.tenant_id + } + + /// Return the task identity bound to this receipt. + #[must_use] + pub fn task_id(&self) -> &str { + &self.task_id + } + + /// Return the accepted lifecycle event. + #[must_use] + pub const fn event(&self) -> BapTaskEvent { + self.transition.event() + } + + /// Return the immutable transition evidence carried by this receipt. + #[must_use] + pub const fn transition(&self) -> BapTaskTransition { + self.transition + } + + /// Return whether a retry has the exact same tenant, task, key, and lifecycle event. + #[must_use] + pub fn matches( + &self, + idempotency_key: &str, + tenant_id: &str, + task_id: &str, + event: BapTaskEvent, + ) -> bool { + self.idempotency_key == idempotency_key + && self.tenant_id == tenant_id + && self.task_id == task_id + && self.event() == event + } } /// Deterministic fail-closed BAP task-lifecycle kernel. @@ -192,6 +433,7 @@ impl BapTaskTransition { pub struct BapTaskLifecycle { state: BapTaskState, transition_sequence: u64, + last_transition: Option, } impl Default for BapTaskLifecycle { @@ -207,6 +449,7 @@ impl BapTaskLifecycle { Self { state: BapTaskState::Created, transition_sequence: 0, + last_transition: None, } } @@ -214,7 +457,10 @@ impl BapTaskLifecycle { /// /// Recovery accepts only state/sequence pairs that are reachable through /// this exact state machine. This prevents corrupt or stale durable metadata - /// from manufacturing an impossible execution state. + /// from manufacturing an impossible execution state. The snapshot does not + /// authenticate the identity of the last accepted transition, so a retained + /// command receipt cannot be replayed against a restored snapshot until a + /// later durable boundary explicitly restores that transition evidence. pub const fn restore( state: BapTaskState, transition_sequence: u64, @@ -228,9 +474,47 @@ impl BapTaskLifecycle { Ok(Self { state, transition_sequence, + last_transition: None, }) } + /// Restore a lifecycle with exact validated last-transition evidence. + /// + /// Every non-created snapshot requires the exact most recently accepted transition so a + /// retained command receipt cannot replay against state and sequence alone. This validates + /// lifecycle consistency only; the caller remains responsible for the integrity and + /// authenticity of the persistence boundary supplying the evidence. + pub fn restore_with_transition( + state: BapTaskState, + transition_sequence: u64, + last_transition: Option, + ) -> Result { + let mut lifecycle = Self::restore(state, transition_sequence)?; + if transition_sequence == 0 { + if last_transition.is_some() { + return Err(BapTaskRestoreError::InvalidTransitionEvidence { + state, + transition_sequence, + }); + } + return Ok(lifecycle); + } + let Some(transition) = last_transition else { + return Err(BapTaskRestoreError::MissingTransitionEvidence { + state, + transition_sequence, + }); + }; + if transition.current_state() != state || transition.sequence() != transition_sequence { + return Err(BapTaskRestoreError::InvalidTransitionEvidence { + state, + transition_sequence, + }); + } + lifecycle.last_transition = Some(transition); + Ok(lifecycle) + } + /// Return the current logical task state. #[must_use] pub const fn state(self) -> BapTaskState { @@ -297,16 +581,137 @@ impl BapTaskLifecycle { return Err(BapTaskTransitionError::SequenceExhausted); }; let previous_state = self.state; - self.state = next_state; - self.transition_sequence = sequence; - Ok(BapTaskTransition { + let transition = BapTaskTransition { previous_state, current_state: next_state, sequence, - }) + event, + }; + self.state = next_state; + self.transition_sequence = sequence; + self.last_transition = Some(transition); + Ok(transition) + } + + /// Apply one lifecycle event and bind the accepted transition to a retry receipt. + /// + /// This remains an in-memory contract: it identifies an exact retry within the caller-supplied + /// tenant namespace but does not authenticate that namespace, authorize the operation, provide + /// durable deduplication, or suppress side effects. Receipts can only be minted at this + /// accepted-command boundary; callers cannot rebind an accepted transition to different retry, + /// tenant, or task metadata afterward. + pub fn apply_with_receipt( + &mut self, + idempotency_key: &str, + tenant_id: &str, + task_id: &str, + event: BapTaskEvent, + ) -> Result { + validate_idempotency_key(idempotency_key)?; + validate_tenant_id(tenant_id)?; + validate_task_id(task_id)?; + let transition = self + .apply(event) + .map_err(|error| BapCommandReceiptError::TransitionRejected { error })?; + Ok(BapCommandReceipt::from_validated( + idempotency_key, + tenant_id, + task_id, + transition, + )) + } + + /// Validate one exact retained command receipt against the current lifecycle without mutation. + /// + /// Exact tenant, idempotency-key, task, and event equality must match the retained receipt, and + /// that receipt's accepted transition must equal this lifecycle's most recently accepted + /// transition. Stale, foreign, divergent-history, or state-only restored lifecycles fail closed. + /// This validates retry identity and lifecycle position only; it does not authenticate persisted + /// evidence, authorize redispatch, or suppress browser/network side effects. + pub fn validate_replay( + &self, + receipt: &BapCommandReceipt, + idempotency_key: &str, + tenant_id: &str, + task_id: &str, + event: BapTaskEvent, + ) -> Result<(), BapCommandReceiptError> { + validate_idempotency_key(idempotency_key)?; + validate_tenant_id(tenant_id)?; + validate_task_id(task_id)?; + if !receipt.matches(idempotency_key, tenant_id, task_id, event) { + return Err(BapCommandReceiptError::IdempotencyConflict); + } + let transition = receipt.transition(); + if self.state != transition.current_state() + || self.transition_sequence != transition.sequence() + || self.last_transition != Some(transition) + { + return Err(BapCommandReceiptError::ReplayStateMismatch); + } + Ok(()) + } + + /// Apply a new command or replay an exact retained command receipt without a second transition. + /// + /// A caller that has already looked up a retained receipt may supply it here. Exact tenant, + /// idempotency-key, task, and event equality plus an exact match between the receipt's accepted + /// transition and this lifecycle's most recently accepted transition returns that immutable receipt + /// without mutating the lifecycle again. Command mismatch or stale/foreign/divergent lifecycle + /// history fails closed. `None` follows the normal validation and transition path in + /// [`Self::apply_with_receipt`]. This helper does not provide receipt storage, concurrent exclusion, + /// authentication, authorization, or suppression of browser/network side effects; those remain + /// responsibilities of their owning runtime boundaries. + pub fn apply_or_replay( + &mut self, + existing_receipt: Option<&BapCommandReceipt>, + idempotency_key: &str, + tenant_id: &str, + task_id: &str, + event: BapTaskEvent, + ) -> Result { + if let Some(receipt) = existing_receipt { + self.validate_replay(receipt, idempotency_key, tenant_id, task_id, event)?; + return Ok(receipt.clone()); + } + self.apply_with_receipt(idempotency_key, tenant_id, task_id, event) } } +fn validate_idempotency_key(value: &str) -> Result<(), BapCommandReceiptError> { + if value.len() > MAX_BAP_IDEMPOTENCY_KEY_BYTES { + return Err(BapCommandReceiptError::IdempotencyKeyLimitExceeded); + } + if value.is_empty() || !value.bytes().all(valid_identifier_byte) { + return Err(BapCommandReceiptError::InvalidIdempotencyKey); + } + Ok(()) +} + +fn validate_tenant_id(value: &str) -> Result<(), BapCommandReceiptError> { + if value.len() > MAX_BAP_TENANT_ID_BYTES { + return Err(BapCommandReceiptError::TenantIdLimitExceeded); + } + if value.is_empty() || !value.bytes().all(valid_identifier_byte) { + return Err(BapCommandReceiptError::InvalidTenantId); + } + Ok(()) +} + +fn validate_task_id(value: &str) -> Result<(), BapCommandReceiptError> { + if value.len() > MAX_BAP_TASK_ID_BYTES { + return Err(BapCommandReceiptError::TaskIdLimitExceeded); + } + if value.is_empty() || !value.bytes().all(valid_identifier_byte) { + return Err(BapCommandReceiptError::InvalidTaskId); + } + Ok(()) +} + +const fn valid_identifier_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') +} + const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bool { match state { BapTaskState::Created => transition_sequence == 0, diff --git a/crates/originweave-bap/tests/idempotency_receipt.rs b/crates/originweave-bap/tests/idempotency_receipt.rs new file mode 100644 index 000000000..ad1dd7134 --- /dev/null +++ b/crates/originweave-bap/tests/idempotency_receipt.rs @@ -0,0 +1,281 @@ +#![allow(clippy::expect_used)] + +use std::error::Error as _; + +use originweave_bap::{ + BapCommandReceiptError, BapTaskEvent, BapTaskLifecycle, BapTaskState, + MAX_BAP_IDEMPOTENCY_KEY_BYTES, MAX_BAP_TASK_ID_BYTES, MAX_BAP_TENANT_ID_BYTES, +}; + +#[test] +fn receipt_binds_tenant_task_event_and_transition_for_replay_identification() { + let mut task = BapTaskLifecycle::new(); + let receipt = task + .apply_with_receipt("request-1", "tenant-1", "task-1", BapTaskEvent::Admit) + .expect("receipt"); + + assert_eq!(receipt.idempotency_key(), "request-1"); + assert_eq!(receipt.tenant_id(), "tenant-1"); + assert_eq!(receipt.task_id(), "task-1"); + assert_eq!(receipt.event(), BapTaskEvent::Admit); + assert_eq!(receipt.transition().current_state(), BapTaskState::Admitted); + assert!(receipt.matches("request-1", "tenant-1", "task-1", BapTaskEvent::Admit)); + assert!(!receipt.matches("request-2", "tenant-1", "task-1", BapTaskEvent::Admit)); + assert!(!receipt.matches("request-1", "tenant-2", "task-1", BapTaskEvent::Admit)); + assert!(!receipt.matches("request-1", "tenant-1", "task-2", BapTaskEvent::Admit)); + assert!(!receipt.matches("request-1", "tenant-1", "task-1", BapTaskEvent::Start)); +} + +#[test] +fn receipt_cannot_be_minted_from_an_already_accepted_transition() { + let source = include_str!("../src/lib.rs"); + let receipt_impl = source + .split("impl BapCommandReceipt {") + .nth(1) + .expect("receipt impl") + .split("/// Deterministic fail-closed BAP task-lifecycle kernel.") + .next() + .expect("receipt impl boundary"); + + assert!( + !receipt_impl.contains("pub fn new("), + "public receipt construction can rebind an accepted transition to arbitrary retry/task metadata", + ); +} + +#[test] +fn receipt_rejects_unbounded_or_ambiguous_identifiers() { + let mut task = BapTaskLifecycle::new(); + for key in [ + "", + "request with space", + &"x".repeat(MAX_BAP_IDEMPOTENCY_KEY_BYTES + 1), + ] { + assert_eq!( + task.apply_with_receipt(key, "tenant-1", "task-1", BapTaskEvent::Admit), + if key.len() > MAX_BAP_IDEMPOTENCY_KEY_BYTES { + Err(BapCommandReceiptError::IdempotencyKeyLimitExceeded) + } else { + Err(BapCommandReceiptError::InvalidIdempotencyKey) + } + ); + } + for tenant_id in [ + "", + "tenant with space", + &"x".repeat(MAX_BAP_TENANT_ID_BYTES + 1), + ] { + assert_eq!( + task.apply_with_receipt("request-1", tenant_id, "task-1", BapTaskEvent::Admit), + if tenant_id.len() > MAX_BAP_TENANT_ID_BYTES { + Err(BapCommandReceiptError::TenantIdLimitExceeded) + } else { + Err(BapCommandReceiptError::InvalidTenantId) + } + ); + } + for task_id in [ + "", + "task with space", + &"x".repeat(MAX_BAP_TASK_ID_BYTES + 1), + ] { + assert_eq!( + task.apply_with_receipt("request-1", "tenant-1", task_id, BapTaskEvent::Admit), + if task_id.len() > MAX_BAP_TASK_ID_BYTES { + Err(BapCommandReceiptError::TaskIdLimitExceeded) + } else { + Err(BapCommandReceiptError::InvalidTaskId) + } + ); + } +} + +#[test] +fn receipt_preserves_lifecycle_failure_without_mutating_the_task() { + let mut task = BapTaskLifecycle::new(); + assert_eq!( + task.apply_with_receipt("request-1", "tenant-1", "task-1", BapTaskEvent::Start), + Err(BapCommandReceiptError::TransitionRejected { + error: originweave_bap::BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::Start, + }, + }) + ); + assert_eq!(task.state(), BapTaskState::Created); + assert_eq!(task.transition_sequence(), 0); +} + +#[test] +fn exact_retry_replays_retained_receipt_without_reapplying_transition() { + let mut task = BapTaskLifecycle::new(); + let receipt = task + .apply_or_replay(None, "request-1", "tenant-1", "task-1", BapTaskEvent::Admit) + .expect("initial receipt"); + + assert_eq!(task.state(), BapTaskState::Admitted); + assert_eq!(task.transition_sequence(), 1); + + for _ in 0..100 { + let replay = task + .apply_or_replay( + Some(&receipt), + "request-1", + "tenant-1", + "task-1", + BapTaskEvent::Admit, + ) + .expect("exact retry"); + assert_eq!(replay, receipt); + assert_eq!(task.state(), BapTaskState::Admitted); + assert_eq!(task.transition_sequence(), 1); + } +} + +#[test] +fn retained_receipt_from_a_different_lifecycle_fails_closed() { + let mut source_task = BapTaskLifecycle::new(); + let receipt = source_task + .apply_or_replay(None, "request-1", "tenant-1", "task-1", BapTaskEvent::Admit) + .expect("source receipt"); + + let mut unrelated_task = BapTaskLifecycle::new(); + assert_eq!( + unrelated_task.apply_or_replay( + Some(&receipt), + "request-1", + "tenant-1", + "task-1", + BapTaskEvent::Admit, + ), + Err(BapCommandReceiptError::ReplayStateMismatch) + ); + assert_eq!(unrelated_task.state(), BapTaskState::Created); + assert_eq!(unrelated_task.transition_sequence(), 0); +} + +#[test] +fn stale_receipt_after_lifecycle_advances_fails_closed() { + let mut task = BapTaskLifecycle::new(); + let receipt = task + .apply_or_replay(None, "request-1", "tenant-1", "task-1", BapTaskEvent::Admit) + .expect("initial receipt"); + task.apply(BapTaskEvent::Start).expect("start task"); + + assert_eq!( + task.apply_or_replay( + Some(&receipt), + "request-1", + "tenant-1", + "task-1", + BapTaskEvent::Admit, + ), + Err(BapCommandReceiptError::ReplayStateMismatch) + ); + assert_eq!(task.state(), BapTaskState::Running); + assert_eq!(task.transition_sequence(), 2); +} + +#[test] +fn replay_requires_exact_sequence_even_when_current_state_matches() { + let mut task = BapTaskLifecycle::new(); + task.apply(BapTaskEvent::Admit).expect("admit task"); + let receipt = task + .apply_or_replay(None, "request-2", "tenant-1", "task-1", BapTaskEvent::Start) + .expect("start receipt"); + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait for approval"); + task.apply(BapTaskEvent::Resume).expect("resume task"); + + assert_eq!(task.state(), BapTaskState::Running); + assert_eq!(task.transition_sequence(), 4); + assert_eq!(receipt.transition().current_state(), BapTaskState::Running); + assert_eq!(receipt.transition().sequence(), 2); + assert_eq!( + task.apply_or_replay( + Some(&receipt), + "request-2", + "tenant-1", + "task-1", + BapTaskEvent::Start, + ), + Err(BapCommandReceiptError::ReplayStateMismatch) + ); + assert_eq!(task.state(), BapTaskState::Running); + assert_eq!(task.transition_sequence(), 4); +} + +#[test] +fn conflicting_retry_fails_closed_without_mutating_lifecycle() { + let mut task = BapTaskLifecycle::new(); + let receipt = task + .apply_or_replay(None, "request-1", "tenant-1", "task-1", BapTaskEvent::Admit) + .expect("initial receipt"); + + for (idempotency_key, tenant_id, task_id, event) in [ + ("request-2", "tenant-1", "task-1", BapTaskEvent::Admit), + ("request-1", "tenant-2", "task-1", BapTaskEvent::Admit), + ("request-1", "tenant-1", "task-2", BapTaskEvent::Admit), + ("request-1", "tenant-1", "task-1", BapTaskEvent::Start), + ] { + assert_eq!( + task.apply_or_replay(Some(&receipt), idempotency_key, tenant_id, task_id, event,), + Err(BapCommandReceiptError::IdempotencyConflict) + ); + assert_eq!(task.state(), BapTaskState::Admitted); + assert_eq!(task.transition_sequence(), 1); + } +} + +#[test] +fn receipt_errors_have_standard_error_contracts() { + let error = BapCommandReceiptError::InvalidTaskId; + assert_eq!(error.to_string(), "BAP task ID is invalid"); + assert!(error.source().is_none()); + assert_eq!( + BapCommandReceiptError::InvalidIdempotencyKey.to_string(), + "BAP idempotency key is invalid" + ); + assert_eq!( + BapCommandReceiptError::IdempotencyKeyLimitExceeded.to_string(), + "BAP idempotency key exceeds its byte limit" + ); + assert_eq!( + BapCommandReceiptError::InvalidTenantId.to_string(), + "BAP tenant ID is invalid" + ); + assert_eq!( + BapCommandReceiptError::TenantIdLimitExceeded.to_string(), + "BAP tenant ID exceeds its byte limit" + ); + assert_eq!( + BapCommandReceiptError::TaskIdLimitExceeded.to_string(), + "BAP task ID exceeds its byte limit" + ); + assert_eq!( + BapCommandReceiptError::IdempotencyConflict.to_string(), + "BAP idempotency key conflicts with the retained command receipt" + ); + assert!( + BapCommandReceiptError::IdempotencyConflict + .source() + .is_none() + ); + assert_eq!( + BapCommandReceiptError::ReplayStateMismatch.to_string(), + "BAP retained command receipt does not match the current lifecycle state" + ); + assert!( + BapCommandReceiptError::ReplayStateMismatch + .source() + .is_none() + ); + let transition = BapCommandReceiptError::TransitionRejected { + error: originweave_bap::BapTaskTransitionError::SequenceExhausted, + }; + assert_eq!( + transition.to_string(), + "BAP task transition sequence is exhausted" + ); + assert!(transition.source().is_some()); +} diff --git a/crates/originweave-bap/tests/idempotency_replay_validation.rs b/crates/originweave-bap/tests/idempotency_replay_validation.rs new file mode 100644 index 000000000..4e48f11b3 --- /dev/null +++ b/crates/originweave-bap/tests/idempotency_replay_validation.rs @@ -0,0 +1,74 @@ +#![allow(clippy::expect_used)] + +use originweave_bap::{ + BapCommandReceiptError, BapTaskEvent, BapTaskLifecycle, BapTaskState, + MAX_BAP_IDEMPOTENCY_KEY_BYTES, MAX_BAP_TASK_ID_BYTES, +}; + +#[test] +fn replay_validates_retry_identifiers_before_receipt_comparison() { + let mut task = BapTaskLifecycle::new(); + let receipt = task + .apply_or_replay(None, "request-1", "tenant-1", "task-1", BapTaskEvent::Admit) + .expect("initial receipt"); + + let oversized_key = "x".repeat(MAX_BAP_IDEMPOTENCY_KEY_BYTES + 1); + assert_eq!( + task.apply_or_replay( + Some(&receipt), + &oversized_key, + "tenant-1", + "task-1", + BapTaskEvent::Admit, + ), + Err(BapCommandReceiptError::IdempotencyKeyLimitExceeded), + ); + + assert_eq!( + task.apply_or_replay( + Some(&receipt), + "request-1", + "tenant with space", + "task-1", + BapTaskEvent::Admit, + ), + Err(BapCommandReceiptError::InvalidTenantId), + ); + + let oversized_task_id = "x".repeat(MAX_BAP_TASK_ID_BYTES + 1); + assert_eq!( + task.apply_or_replay( + Some(&receipt), + "request-1", + "tenant-1", + &oversized_task_id, + BapTaskEvent::Admit, + ), + Err(BapCommandReceiptError::TaskIdLimitExceeded), + ); + + assert_eq!(task.state(), BapTaskState::Admitted); + assert_eq!(task.transition_sequence(), 1); +} + +#[test] +fn exact_receipt_can_be_validated_without_mutable_lifecycle_access() { + let mut task = BapTaskLifecycle::new(); + let receipt = task + .apply_with_receipt("request-1", "tenant-1", "task-1", BapTaskEvent::Admit) + .expect("initial receipt"); + let task = task; + + assert_eq!( + task.validate_replay( + &receipt, + "request-1", + "tenant-1", + "task-1", + BapTaskEvent::Admit, + ), + Ok(()), + ); + assert_eq!(task.state(), BapTaskState::Admitted); + assert_eq!(task.transition_sequence(), 1); +} diff --git a/crates/originweave-bap/tests/idempotency_transition_identity.rs b/crates/originweave-bap/tests/idempotency_transition_identity.rs new file mode 100644 index 000000000..645389b65 --- /dev/null +++ b/crates/originweave-bap/tests/idempotency_transition_identity.rs @@ -0,0 +1,87 @@ +#![allow(clippy::expect_used)] + +use originweave_bap::{BapCommandReceiptError, BapTaskEvent, BapTaskLifecycle, BapTaskState}; + +#[test] +fn replay_rejects_same_state_and_sequence_from_a_different_transition_path() { + let mut source_task = BapTaskLifecycle::new(); + source_task + .apply(BapTaskEvent::Admit) + .expect("admit source"); + source_task + .apply(BapTaskEvent::Start) + .expect("start source"); + source_task + .apply(BapTaskEvent::WaitForApproval) + .expect("wait source"); + let receipt = source_task + .apply_or_replay( + None, + "request-resume", + "tenant-1", + "task-1", + BapTaskEvent::Resume, + ) + .expect("source resume receipt"); + + let mut other_task = BapTaskLifecycle::new(); + other_task.apply(BapTaskEvent::Admit).expect("admit other"); + other_task.apply(BapTaskEvent::Start).expect("start other"); + other_task + .apply(BapTaskEvent::WaitForExternalInput) + .expect("wait other"); + other_task + .apply(BapTaskEvent::Resume) + .expect("resume other"); + + assert_eq!(source_task.state(), BapTaskState::Running); + assert_eq!(other_task.state(), BapTaskState::Running); + assert_eq!(source_task.transition_sequence(), 4); + assert_eq!(other_task.transition_sequence(), 4); + assert_eq!( + receipt.transition().previous_state(), + BapTaskState::WaitingForApproval + ); + + assert_eq!( + other_task.apply_or_replay( + Some(&receipt), + "request-resume", + "tenant-1", + "task-1", + BapTaskEvent::Resume, + ), + Err(BapCommandReceiptError::ReplayStateMismatch) + ); + assert_eq!(other_task.state(), BapTaskState::Running); + assert_eq!(other_task.transition_sequence(), 4); +} + +#[test] +fn restored_snapshot_without_last_transition_identity_cannot_replay_receipt() { + let mut source_task = BapTaskLifecycle::new(); + let receipt = source_task + .apply_or_replay( + None, + "request-admit", + "tenant-1", + "task-1", + BapTaskEvent::Admit, + ) + .expect("source admit receipt"); + + let mut restored = + BapTaskLifecycle::restore(BapTaskState::Admitted, 1).expect("reachable admitted snapshot"); + assert_eq!( + restored.apply_or_replay( + Some(&receipt), + "request-admit", + "tenant-1", + "task-1", + BapTaskEvent::Admit, + ), + Err(BapCommandReceiptError::ReplayStateMismatch) + ); + assert_eq!(restored.state(), BapTaskState::Admitted); + assert_eq!(restored.transition_sequence(), 1); +} diff --git a/crates/originweave-bap/tests/receipt_debug_redaction.rs b/crates/originweave-bap/tests/receipt_debug_redaction.rs new file mode 100644 index 000000000..5b3b0d4a0 --- /dev/null +++ b/crates/originweave-bap/tests/receipt_debug_redaction.rs @@ -0,0 +1,22 @@ +#![allow(clippy::expect_used)] + +use originweave_bap::{BapTaskEvent, BapTaskLifecycle}; + +#[test] +fn command_receipt_debug_does_not_disclose_retry_tenant_or_task_identifiers() { + let mut task = BapTaskLifecycle::new(); + let receipt = task + .apply_with_receipt( + "retry-secret-marker", + "private-tenant-marker", + "private-task-marker", + BapTaskEvent::Admit, + ) + .expect("receipt"); + + let debug = format!("{receipt:?}"); + assert!(debug.contains("idempotency_key_byte_count")); + assert!(!debug.contains("retry-secret-marker")); + assert!(!debug.contains("private-tenant-marker")); + assert!(!debug.contains("private-task-marker")); +} diff --git a/crates/originweave-bap/tests/reconciliation_receipt_recovery.rs b/crates/originweave-bap/tests/reconciliation_receipt_recovery.rs new file mode 100644 index 000000000..deabdf4ff --- /dev/null +++ b/crates/originweave-bap/tests/reconciliation_receipt_recovery.rs @@ -0,0 +1,115 @@ +#![allow(clippy::expect_used)] + +use originweave_bap::{ + BapCommandReceipt, BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransition, + BapTaskTransitionError, +}; + +#[test] +fn reconciliation_receipt_replays_after_transition_backed_restore() { + let mut lifecycle = BapTaskLifecycle::new(); + lifecycle.apply(BapTaskEvent::Admit).expect("admit"); + lifecycle.apply(BapTaskEvent::Start).expect("start"); + let receipt = lifecycle + .apply_with_receipt( + "reconcile-1", + "tenant-a", + "task-a", + BapTaskEvent::RequireReconciliation, + ) + .expect("require reconciliation"); + + assert_eq!(lifecycle.state(), BapTaskState::ReconciliationRequired); + let transition = BapTaskTransition::restore( + BapTaskState::Running, + BapTaskState::ReconciliationRequired, + 3, + BapTaskEvent::RequireReconciliation, + ) + .expect("restore reconciliation transition"); + assert_eq!(transition, receipt.transition()); + + let restored_receipt = + BapCommandReceipt::restore("reconcile-1", "tenant-a", "task-a", transition) + .expect("restore receipt"); + let mut restored = BapTaskLifecycle::restore_with_transition( + BapTaskState::ReconciliationRequired, + 3, + Some(transition), + ) + .expect("restore lifecycle"); + + let replay = restored + .apply_or_replay( + Some(&restored_receipt), + "reconcile-1", + "tenant-a", + "task-a", + BapTaskEvent::RequireReconciliation, + ) + .expect("replay reconciliation command"); + assert_eq!(replay, restored_receipt); + assert_eq!(restored.transition_sequence(), 3); + + let resolution = restored + .apply(BapTaskEvent::ResolveReconciliation) + .expect("resolve reconciliation"); + assert_eq!( + resolution.previous_state(), + BapTaskState::ReconciliationRequired + ); + assert_eq!(resolution.current_state(), BapTaskState::Running); + assert_eq!(resolution.sequence(), 4); +} + +#[test] +fn dead_letter_receipt_replays_but_terminal_state_stays_closed() { + let mut lifecycle = BapTaskLifecycle::new(); + lifecycle.apply(BapTaskEvent::Admit).expect("admit"); + lifecycle.apply(BapTaskEvent::Start).expect("start"); + lifecycle + .apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation"); + let receipt = lifecycle + .apply_with_receipt( + "dead-letter-1", + "tenant-a", + "task-a", + BapTaskEvent::DeadLetter, + ) + .expect("dead letter"); + + assert_eq!(lifecycle.state(), BapTaskState::DeadLettered); + let transition = BapTaskTransition::restore( + BapTaskState::ReconciliationRequired, + BapTaskState::DeadLettered, + 4, + BapTaskEvent::DeadLetter, + ) + .expect("restore dead-letter transition"); + assert_eq!(transition, receipt.transition()); + + let restored_receipt = + BapCommandReceipt::restore("dead-letter-1", "tenant-a", "task-a", transition) + .expect("restore receipt"); + let mut restored = + BapTaskLifecycle::restore_with_transition(BapTaskState::DeadLettered, 4, Some(transition)) + .expect("restore lifecycle"); + let replay = restored + .apply_or_replay( + Some(&restored_receipt), + "dead-letter-1", + "tenant-a", + "task-a", + BapTaskEvent::DeadLetter, + ) + .expect("replay dead-letter command"); + assert_eq!(replay, restored_receipt); + + assert_eq!( + restored.apply(BapTaskEvent::ResolveReconciliation), + Err(BapTaskTransitionError::TerminalState { + state: BapTaskState::DeadLettered, + }) + ); +} diff --git a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs new file mode 100644 index 000000000..560b27328 --- /dev/null +++ b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs @@ -0,0 +1,258 @@ +#![allow(clippy::expect_used)] + +use originweave_bap::{ + BapCommandReceipt, BapCommandReceiptError, BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, + BapTaskState, BapTaskTransition, MAX_BAP_IDEMPOTENCY_KEY_BYTES, MAX_BAP_TASK_ID_BYTES, + MAX_BAP_TENANT_ID_BYTES, +}; + +#[test] +fn exact_transition_evidence_restores_receipt_replay_without_second_mutation() { + let mut lifecycle = BapTaskLifecycle::new(); + let receipt = lifecycle + .apply_with_receipt("retry-1", "tenant-a", "task-a", BapTaskEvent::Admit) + .expect("initial command must be accepted"); + let accepted = receipt.transition(); + + let restored_transition = BapTaskTransition::restore( + accepted.previous_state(), + accepted.current_state(), + accepted.sequence(), + accepted.event(), + ) + .expect("exact persisted transition evidence must restore"); + let mut restored = BapTaskLifecycle::restore_with_transition( + accepted.current_state(), + accepted.sequence(), + Some(restored_transition), + ) + .expect("exact state and transition evidence must restore the lifecycle"); + + let replay = restored + .apply_or_replay( + Some(&receipt), + "retry-1", + "tenant-a", + "task-a", + BapTaskEvent::Admit, + ) + .expect("exact retry must replay after authenticated transition recovery"); + + assert_eq!(replay, receipt); + assert_eq!(restored.state(), BapTaskState::Admitted); + assert_eq!(restored.transition_sequence(), 1); +} + +#[test] +fn persisted_receipt_fields_can_be_reconstructed_for_cross_process_replay() { + let mut lifecycle = BapTaskLifecycle::new(); + let issued = lifecycle + .apply_with_receipt("retry-1", "tenant-a", "task-a", BapTaskEvent::Admit) + .expect("initial command must be accepted"); + let accepted = issued.transition(); + + let restored_transition = BapTaskTransition::restore( + accepted.previous_state(), + accepted.current_state(), + accepted.sequence(), + accepted.event(), + ) + .expect("persisted transition evidence must restore"); + let restored_receipt = BapCommandReceipt::restore( + issued.idempotency_key(), + issued.tenant_id(), + issued.task_id(), + restored_transition, + ) + .expect("persisted receipt fields must restore after process loss"); + let mut restored_lifecycle = BapTaskLifecycle::restore_with_transition( + accepted.current_state(), + accepted.sequence(), + Some(restored_transition), + ) + .expect("persisted lifecycle evidence must restore"); + + let replay = restored_lifecycle + .apply_or_replay( + Some(&restored_receipt), + "retry-1", + "tenant-a", + "task-a", + BapTaskEvent::Admit, + ) + .expect("restored receipt must replay without repeating the transition"); + + assert_eq!(replay, restored_receipt); + assert_eq!(restored_lifecycle.state(), BapTaskState::Admitted); + assert_eq!(restored_lifecycle.transition_sequence(), 1); +} + +#[test] +fn persisted_receipt_restore_revalidates_retry_metadata() { + let mut lifecycle = BapTaskLifecycle::new(); + let transition = lifecycle + .apply(BapTaskEvent::Admit) + .expect("admit must succeed"); + + assert_eq!( + BapCommandReceipt::restore("", "tenant-a", "task-a", transition), + Err(BapCommandReceiptError::InvalidIdempotencyKey) + ); + assert_eq!( + BapCommandReceipt::restore( + &"x".repeat(MAX_BAP_IDEMPOTENCY_KEY_BYTES + 1), + "tenant-a", + "task-a", + transition, + ), + Err(BapCommandReceiptError::IdempotencyKeyLimitExceeded) + ); + assert_eq!( + BapCommandReceipt::restore("retry-1", "", "task-a", transition), + Err(BapCommandReceiptError::InvalidTenantId) + ); + assert_eq!( + BapCommandReceipt::restore( + "retry-1", + &"x".repeat(MAX_BAP_TENANT_ID_BYTES + 1), + "task-a", + transition, + ), + Err(BapCommandReceiptError::TenantIdLimitExceeded) + ); + assert_eq!( + BapCommandReceipt::restore("retry-1", "tenant-a", "", transition), + Err(BapCommandReceiptError::InvalidTaskId) + ); + assert_eq!( + BapCommandReceipt::restore( + "retry-1", + "tenant-a", + &"x".repeat(MAX_BAP_TASK_ID_BYTES + 1), + transition, + ), + Err(BapCommandReceiptError::TaskIdLimitExceeded) + ); +} + +#[test] +fn recovery_requires_transition_evidence_after_any_accepted_transition() { + let invalid_snapshot = BapTaskRestoreError::InvalidSnapshot { + state: BapTaskState::Created, + transition_sequence: 1, + }; + assert_eq!( + BapTaskLifecycle::restore_with_transition(BapTaskState::Created, 1, None), + Err(invalid_snapshot) + ); + + let missing = BapTaskRestoreError::MissingTransitionEvidence { + state: BapTaskState::Admitted, + transition_sequence: 1, + }; + assert_eq!( + missing.to_string(), + "BAP task snapshot state Admitted with transition sequence 1 is missing last-transition evidence" + ); + assert_eq!( + BapTaskLifecycle::restore_with_transition(BapTaskState::Admitted, 1, None), + Err(missing) + ); + + assert_eq!( + BapTaskLifecycle::restore_with_transition(BapTaskState::Created, 0, None) + .expect("created state needs no prior transition") + .transition_sequence(), + 0 + ); +} + +#[test] +fn transition_restore_rejects_zero_unreachable_invalid_and_mismatched_evidence() { + let invalid = |state, transition_sequence| BapTaskRestoreError::InvalidTransitionEvidence { + state, + transition_sequence, + }; + assert_eq!( + invalid(BapTaskState::Running, 2).to_string(), + "BAP task transition evidence for state Running with transition sequence 2 is invalid" + ); + + assert_eq!( + BapTaskTransition::restore( + BapTaskState::Created, + BapTaskState::Admitted, + 0, + BapTaskEvent::Admit, + ), + Err(invalid(BapTaskState::Admitted, 0)) + ); + assert_eq!( + BapTaskTransition::restore( + BapTaskState::Admitted, + BapTaskState::Running, + 4, + BapTaskEvent::Start, + ), + Err(invalid(BapTaskState::Running, 4)) + ); + assert_eq!( + BapTaskTransition::restore( + BapTaskState::Created, + BapTaskState::Running, + 1, + BapTaskEvent::Start, + ), + Err(invalid(BapTaskState::Running, 1)) + ); + assert_eq!( + BapTaskTransition::restore( + BapTaskState::Created, + BapTaskState::Running, + 1, + BapTaskEvent::Admit, + ), + Err(invalid(BapTaskState::Running, 1)) + ); +} + +#[test] +fn lifecycle_restore_rejects_transition_from_a_different_snapshot() { + let mut lifecycle = BapTaskLifecycle::new(); + let admitted = lifecycle + .apply(BapTaskEvent::Admit) + .expect("admit must succeed"); + + assert_eq!( + BapTaskLifecycle::restore_with_transition(BapTaskState::Created, 0, Some(admitted)), + Err(BapTaskRestoreError::InvalidTransitionEvidence { + state: BapTaskState::Created, + transition_sequence: 0, + }) + ); + + lifecycle + .apply(BapTaskEvent::Start) + .expect("start must succeed"); + assert_eq!( + BapTaskLifecycle::restore_with_transition(BapTaskState::Running, 2, Some(admitted)), + Err(BapTaskRestoreError::InvalidTransitionEvidence { + state: BapTaskState::Running, + transition_sequence: 2, + }) + ); + + lifecycle + .apply(BapTaskEvent::WaitForApproval) + .expect("wait must succeed"); + let resumed = lifecycle + .apply(BapTaskEvent::Resume) + .expect("resume must succeed"); + assert_eq!( + BapTaskLifecycle::restore_with_transition(BapTaskState::Running, 2, Some(resumed)), + Err(BapTaskRestoreError::InvalidTransitionEvidence { + state: BapTaskState::Running, + transition_sequence: 2, + }) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index f750922fd..ce6833b53 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -36,7 +36,7 @@ The protocol does not: - accept raw page text as policy; - collapse origin/destination/route/TCP/TLS/HTTP authority into one URL field; - treat protocol authentication alone as task authorization; -- guarantee every future Chromium/CDP/WebMCP feature. +- guarantee every future Chromium/CDP/WebMCP/MCP feature. ## 4. Versioning @@ -109,6 +109,14 @@ Rules: - idempotency retention is bounded and declared; - secret-handle max-use semantics remain independent of request idempotency. +The rules above define the product-wide target contract. The active BAP lifecycle +implements a narrower in-memory retry identity: `BapCommandReceipt` compares only +caller-supplied `tenant_id`, `idempotency_key`, `task_id`, `BapTaskEvent`, and exact +accepted transition evidence. It does not define a `task_id` session scope, operation +kind, or semantic request-contract validation, and the tenant identifier is not +authentication or authorization evidence. The receipt is not persisted and does not +claim durable deduplication or suppression of an ambiguous external side effect. + ## 8. Deadline and cancellation Requests use one end-to-end deadline. Lower layers consume the remaining budget rather than reset a fresh unlimited timeout. diff --git a/docs/TRD.md b/docs/TRD.md index 0e60e5ca5..0076a0a63 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -126,6 +126,13 @@ Navigation, document replacement, or another adapter-defined actionable-document Commands that can be replayed through retries or operator recovery require explicit idempotency semantics. Idempotency keys are scoped at least by tenant/task/action contract and cannot turn a semantically different action into the same request. +The active `originweave-bap` lifecycle lane provides only the bounded in-memory +portion of this contract: an immutable command receipt binds a tenant namespace, +key, and task ID to one accepted lifecycle transition. The caller-supplied tenant +namespace scopes retry identity only; it is not authenticated tenant authority. +Durable storage, authenticated tenant binding, concurrent deduplication, and +externally visible side-effect suppression remain unimplemented. + ## 6. Network authority stack ### 6.1 Origin diff --git a/docs/traceability/README.md b/docs/traceability/README.md index e30b9eda1..c5e097963 100644 --- a/docs/traceability/README.md +++ b/docs/traceability/README.md @@ -66,6 +66,7 @@ ADR lifecycle is separate and remains `Proposed`, `Accepted`, `Superseded`, `Dep | Raw secrets never enter model context | PARTIAL | PRD-DATA-001; ADR 0002; TRD Section 9 | Core secret-delivery policy exists; trusted broker/runtime completion remains Planned | | Sensitive disclosure is purpose- and classification-bound | PARTIAL | ADR 0007; PRD-DATA-002; issue #10 | Purpose-bound policy/evidence foundations are on protected main; active PR #45 adds credential-free handle-lifecycle evidence and #46 adds bounded in-process authoritative use reservation, while trusted storage/revocation/value resolution/cross-process lifecycle/model-disclosure remain open | | Evidence/provenance are product outputs, not debug leftovers | PARTIAL | ADR 0003; PRD Section 9.6 | `originweave-evidence` foundations exist; complete durable Evidence Trail/WARC/PROV adapters remain Planned | +| BAP lifecycle retries remain explicitly identifiable | IMPLEMENTED_ON_ACTIVE_PR | issue #200; active `originweave-bap` lane | The in-memory command receipt binds a bounded tenant namespace, idempotency key, and task identifier to one accepted transition; the namespace is not authentication, and durable deduplication and side-effect suppression remain Planned | | Human interaction outranks inference/background collection | PARTIAL | `ARCHITECTURE.md`; PRD-RES-002 | Deterministic resource mitigation/CPU-worker admission foundations exist; platform telemetry/actuation remain Planned | | Structured observation precedes raw HTML/screenshot fallback | ACCEPTED_ARCHITECTURE | PRD-OBS-003; TRD Section 7 | Active PR #52 supplies a non-shipped bounded semantic value primitive; real browser observation and fallback adapters remain Planned | | WebDriver BiDi / CDP / WebMCP / MCP are adapters, not internal authority | ACCEPTED_ARCHITECTURE | PRD Section 9.8; TRD Section 12 | Protocol adapter implementation remains Planned/active under issue #28; active PR #40 may not be called shipped |