From d1fbb2a1c340c95b2f0f836c177877a313f90514 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:19:37 +0900 Subject: [PATCH 01/46] feat(bap): bind lifecycle receipts to idempotency keys --- ARCHITECTURE.md | 4 + CHANGELOG.md | 1 + crates/originweave-bap/src/lib.rs | 162 ++++++++++++++++++ .../tests/idempotency_receipt.rs | 113 ++++++++++++ docs/API_CONTRACT.md | 4 + docs/TRD.md | 5 + docs/traceability/README.md | 1 + 7 files changed, 290 insertions(+) create mode 100644 crates/originweave-bap/tests/idempotency_receipt.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9b23ef9f0..3ba3fa591 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -136,6 +136,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 idempotency key and task identifier to one accepted lifecycle event; it can identify an exact retry but is not durable deduplication, authentication, policy authority, transport, browser state, or side-effect suppression. + ## 6. Planned modules ```text diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd9bfe1b..5e4fb9531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Added an active BAP in-memory command receipt that binds bounded idempotency keys and task identities to accepted lifecycle transitions without claiming durable deduplication or side-effect suppression. - 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index b2dbc6ba1..f3da2ecfc 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -8,6 +8,11 @@ #![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 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 { @@ -143,6 +148,7 @@ pub struct BapTaskTransition { previous_state: BapTaskState, current_state: BapTaskState, sequence: u64, + event: BapTaskEvent, } impl BapTaskTransition { @@ -163,6 +169,115 @@ 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 + } +} + +/// 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 task identifier was empty or contained unsupported input. + InvalidTaskId, + /// The task identifier exceeded its byte bound. + TaskIdLimitExceeded, + /// 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::InvalidTaskId => write!(formatter, "BAP task ID is invalid"), + Self::TaskIdLimitExceeded => write!(formatter, "BAP task ID exceeds its byte limit"), + 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::InvalidTaskId + | Self::TaskIdLimitExceeded => None, + } + } +} + +/// An immutable receipt binding one accepted lifecycle command to its retry key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BapCommandReceipt { + idempotency_key: String, + task_id: String, + transition: BapTaskTransition, +} + +impl BapCommandReceipt { + /// Validate and create a receipt for an already accepted lifecycle transition. + pub fn new( + idempotency_key: &str, + task_id: &str, + transition: BapTaskTransition, + ) -> Result { + validate_idempotency_key(idempotency_key)?; + validate_task_id(task_id)?; + Ok(Self::from_validated(idempotency_key, task_id, transition)) + } + + fn from_validated(idempotency_key: &str, task_id: &str, transition: BapTaskTransition) -> Self { + Self { + idempotency_key: idempotency_key.to_owned(), + task_id: task_id.to_owned(), + transition, + } + } + + /// Return the opaque retry key supplied by the caller. + #[must_use] + pub fn idempotency_key(&self) -> &str { + &self.idempotency_key + } + + /// 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 task, key, and lifecycle event. + #[must_use] + pub fn matches(&self, idempotency_key: &str, task_id: &str, event: BapTaskEvent) -> bool { + self.idempotency_key == idempotency_key && self.task_id == task_id && self.event() == event + } } /// Deterministic fail-closed BAP task-lifecycle kernel. @@ -273,8 +388,55 @@ impl BapTaskLifecycle { previous_state, current_state: next_state, sequence, + event, }) } + + /// Apply one lifecycle event and bind the accepted transition to a retry receipt. + /// + /// This remains an in-memory contract: it identifies an exact retry but does + /// not provide durable deduplication or side-effect suppression. + pub fn apply_with_receipt( + &mut self, + idempotency_key: &str, + task_id: &str, + event: BapTaskEvent, + ) -> Result { + validate_idempotency_key(idempotency_key)?; + validate_task_id(task_id)?; + let transition = self + .apply(event) + .map_err(|error| BapCommandReceiptError::TransitionRejected { error })?; + Ok(BapCommandReceipt::from_validated( + idempotency_key, + task_id, + transition, + )) + } +} + +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_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 { diff --git a/crates/originweave-bap/tests/idempotency_receipt.rs b/crates/originweave-bap/tests/idempotency_receipt.rs new file mode 100644 index 000000000..061e4f49b --- /dev/null +++ b/crates/originweave-bap/tests/idempotency_receipt.rs @@ -0,0 +1,113 @@ +#![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, +}; + +#[test] +fn receipt_binds_task_event_and_transition_for_replay_identification() { + let mut task = BapTaskLifecycle::new(); + let receipt = task + .apply_with_receipt("request-1", "task-1", BapTaskEvent::Admit) + .expect("receipt"); + + assert_eq!(receipt.idempotency_key(), "request-1"); + assert_eq!(receipt.task_id(), "task-1"); + assert_eq!(receipt.event(), BapTaskEvent::Admit); + assert_eq!(receipt.transition().current_state(), BapTaskState::Admitted); + let direct = + originweave_bap::BapCommandReceipt::new("request-2", "task-1", receipt.transition()) + .expect("direct receipt"); + assert_eq!(direct.idempotency_key(), "request-2"); + assert_eq!( + originweave_bap::BapCommandReceipt::new("", "task-1", receipt.transition()), + Err(BapCommandReceiptError::InvalidIdempotencyKey) + ); + assert_eq!( + originweave_bap::BapCommandReceipt::new("request-2", "", receipt.transition()), + Err(BapCommandReceiptError::InvalidTaskId) + ); + assert!(receipt.matches("request-1", "task-1", BapTaskEvent::Admit)); + assert!(!receipt.matches("request-2", "task-1", BapTaskEvent::Admit)); + assert!(!receipt.matches("request-1", "task-2", BapTaskEvent::Admit)); + assert!(!receipt.matches("request-1", "task-1", BapTaskEvent::Start)); +} + +#[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, "task-1", BapTaskEvent::Admit), + if key.len() > MAX_BAP_IDEMPOTENCY_KEY_BYTES { + Err(BapCommandReceiptError::IdempotencyKeyLimitExceeded) + } else { + Err(BapCommandReceiptError::InvalidIdempotencyKey) + } + ); + } + for task_id in [ + "", + "task with space", + &"x".repeat(MAX_BAP_TASK_ID_BYTES + 1), + ] { + assert_eq!( + task.apply_with_receipt("request-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", "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 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::TaskIdLimitExceeded.to_string(), + "BAP task ID exceeds its byte limit" + ); + 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/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index f750922fd..68731fa83 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -109,6 +109,10 @@ Rules: - idempotency retention is bounded and declared; - secret-handle max-use semantics remain independent of request idempotency. +The active BAP lifecycle implementation exposes an in-memory command receipt that +identifies an exact key/task/event retry. It does not persist the receipt or claim +that a retry has been deduplicated until a durable runtime adapter exists. + ## 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..30ddae6a9 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -126,6 +126,11 @@ 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 key and task ID to +one accepted lifecycle transition. Durable storage, tenant scope, 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..4dd6b380e 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 idempotency key and task identifier to one accepted transition; 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 | From 75bbeb7a54238305d7bb634b642cca083ce2c41a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:23:01 -0700 Subject: [PATCH 02/46] test(bap): reject post-hoc receipt minting --- .../tests/idempotency_receipt.rs | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/crates/originweave-bap/tests/idempotency_receipt.rs b/crates/originweave-bap/tests/idempotency_receipt.rs index 061e4f49b..07f03e75d 100644 --- a/crates/originweave-bap/tests/idempotency_receipt.rs +++ b/crates/originweave-bap/tests/idempotency_receipt.rs @@ -18,24 +18,29 @@ fn receipt_binds_task_event_and_transition_for_replay_identification() { assert_eq!(receipt.task_id(), "task-1"); assert_eq!(receipt.event(), BapTaskEvent::Admit); assert_eq!(receipt.transition().current_state(), BapTaskState::Admitted); - let direct = - originweave_bap::BapCommandReceipt::new("request-2", "task-1", receipt.transition()) - .expect("direct receipt"); - assert_eq!(direct.idempotency_key(), "request-2"); - assert_eq!( - originweave_bap::BapCommandReceipt::new("", "task-1", receipt.transition()), - Err(BapCommandReceiptError::InvalidIdempotencyKey) - ); - assert_eq!( - originweave_bap::BapCommandReceipt::new("request-2", "", receipt.transition()), - Err(BapCommandReceiptError::InvalidTaskId) - ); assert!(receipt.matches("request-1", "task-1", BapTaskEvent::Admit)); assert!(!receipt.matches("request-2", "task-1", BapTaskEvent::Admit)); assert!(!receipt.matches("request-1", "task-2", BapTaskEvent::Admit)); assert!(!receipt.matches("request-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(); From 202aa554cd51184760226a16c7fb92f073986ed9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:24:15 -0700 Subject: [PATCH 03/46] fix(bap): mint receipts only at accepted command boundary --- crates/originweave-bap/src/lib.rs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index f3da2ecfc..7a0f0db00 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -230,17 +230,6 @@ pub struct BapCommandReceipt { } impl BapCommandReceipt { - /// Validate and create a receipt for an already accepted lifecycle transition. - pub fn new( - idempotency_key: &str, - task_id: &str, - transition: BapTaskTransition, - ) -> Result { - validate_idempotency_key(idempotency_key)?; - validate_task_id(task_id)?; - Ok(Self::from_validated(idempotency_key, task_id, transition)) - } - fn from_validated(idempotency_key: &str, task_id: &str, transition: BapTaskTransition) -> Self { Self { idempotency_key: idempotency_key.to_owned(), @@ -395,7 +384,9 @@ impl BapTaskLifecycle { /// Apply one lifecycle event and bind the accepted transition to a retry receipt. /// /// This remains an in-memory contract: it identifies an exact retry but does - /// not provide durable deduplication or side-effect suppression. + /// not provide durable deduplication or side-effect suppression. Receipts can + /// only be minted at this accepted-command boundary; callers cannot rebind an + /// already accepted transition to different retry or task metadata afterward. pub fn apply_with_receipt( &mut self, idempotency_key: &str, From 3e0dc5da9d4a8c5e8db9ee8cacb878dd0d050f2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:30:47 -0700 Subject: [PATCH 04/46] test(bap): require tenant-scoped command receipts --- .../tests/idempotency_receipt.rs | 74 ++++++++++++++++--- 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/crates/originweave-bap/tests/idempotency_receipt.rs b/crates/originweave-bap/tests/idempotency_receipt.rs index 07f03e75d..0a664b136 100644 --- a/crates/originweave-bap/tests/idempotency_receipt.rs +++ b/crates/originweave-bap/tests/idempotency_receipt.rs @@ -4,24 +4,51 @@ use std::error::Error as _; use originweave_bap::{ BapCommandReceiptError, BapTaskEvent, BapTaskLifecycle, BapTaskState, - MAX_BAP_IDEMPOTENCY_KEY_BYTES, MAX_BAP_TASK_ID_BYTES, + MAX_BAP_IDEMPOTENCY_KEY_BYTES, MAX_BAP_TASK_ID_BYTES, MAX_BAP_TENANT_ID_BYTES, }; #[test] -fn receipt_binds_task_event_and_transition_for_replay_identification() { +fn receipt_binds_tenant_task_event_and_transition_for_replay_identification() { let mut task = BapTaskLifecycle::new(); let receipt = task - .apply_with_receipt("request-1", "task-1", BapTaskEvent::Admit) + .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", "task-1", BapTaskEvent::Admit)); - assert!(!receipt.matches("request-2", "task-1", BapTaskEvent::Admit)); - assert!(!receipt.matches("request-1", "task-2", BapTaskEvent::Admit)); - assert!(!receipt.matches("request-1", "task-1", BapTaskEvent::Start)); + 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] @@ -50,7 +77,7 @@ fn receipt_rejects_unbounded_or_ambiguous_identifiers() { &"x".repeat(MAX_BAP_IDEMPOTENCY_KEY_BYTES + 1), ] { assert_eq!( - task.apply_with_receipt(key, "task-1", BapTaskEvent::Admit), + task.apply_with_receipt(key, "tenant-1", "task-1", BapTaskEvent::Admit), if key.len() > MAX_BAP_IDEMPOTENCY_KEY_BYTES { Err(BapCommandReceiptError::IdempotencyKeyLimitExceeded) } else { @@ -58,13 +85,27 @@ fn receipt_rejects_unbounded_or_ambiguous_identifiers() { } ); } + 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", task_id, BapTaskEvent::Admit), + 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 { @@ -78,7 +119,12 @@ fn receipt_rejects_unbounded_or_ambiguous_identifiers() { fn receipt_preserves_lifecycle_failure_without_mutating_the_task() { let mut task = BapTaskLifecycle::new(); assert_eq!( - task.apply_with_receipt("request-1", "task-1", BapTaskEvent::Start), + task.apply_with_receipt( + "request-1", + "tenant-1", + "task-1", + BapTaskEvent::Start + ), Err(BapCommandReceiptError::TransitionRejected { error: originweave_bap::BapTaskTransitionError::InvalidTransition { from: BapTaskState::Created, @@ -103,6 +149,14 @@ fn receipt_errors_have_standard_error_contracts() { 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" From 86f7a6b51bb3108c6f23f496b2052f59dcec382a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:32:37 -0700 Subject: [PATCH 05/46] test(bap): format tenant-scope RED regression --- .../tests/idempotency_receipt.rs | 42 +++---------------- 1 file changed, 6 insertions(+), 36 deletions(-) diff --git a/crates/originweave-bap/tests/idempotency_receipt.rs b/crates/originweave-bap/tests/idempotency_receipt.rs index 0a664b136..542126844 100644 --- a/crates/originweave-bap/tests/idempotency_receipt.rs +++ b/crates/originweave-bap/tests/idempotency_receipt.rs @@ -19,36 +19,11 @@ fn receipt_binds_tenant_task_event_and_transition_for_replay_identification() { 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 - )); + 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] @@ -119,12 +94,7 @@ fn receipt_rejects_unbounded_or_ambiguous_identifiers() { 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 - ), + task.apply_with_receipt("request-1", "tenant-1", "task-1", BapTaskEvent::Start), Err(BapCommandReceiptError::TransitionRejected { error: originweave_bap::BapTaskTransitionError::InvalidTransition { from: BapTaskState::Created, From 3e11147b358d99cc50becfbedec88924345b2983 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:35:37 -0700 Subject: [PATCH 06/46] fix(bap): scope command receipts by tenant namespace --- crates/originweave-bap/src/lib.rs | 68 +++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 7a0f0db00..fe0fb031a 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -10,6 +10,8 @@ /// 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; @@ -184,6 +186,10 @@ pub enum BapCommandReceiptError { 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. @@ -202,6 +208,10 @@ impl std::fmt::Display for BapCommandReceiptError { 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::TransitionRejected { error } => error.fmt(formatter), @@ -215,24 +225,33 @@ impl std::error::Error for BapCommandReceiptError { Self::TransitionRejected { error } => Some(error), Self::InvalidIdempotencyKey | Self::IdempotencyKeyLimitExceeded + | Self::InvalidTenantId + | Self::TenantIdLimitExceeded | Self::InvalidTaskId | Self::TaskIdLimitExceeded => None, } } } -/// An immutable receipt binding one accepted lifecycle command to its retry key. +/// An immutable receipt binding one accepted lifecycle command to its retry namespace and key. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BapCommandReceipt { idempotency_key: String, + tenant_id: String, task_id: String, transition: BapTaskTransition, } impl BapCommandReceipt { - fn from_validated(idempotency_key: &str, task_id: &str, transition: BapTaskTransition) -> Self { + 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, } @@ -244,6 +263,14 @@ impl BapCommandReceipt { &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 { @@ -262,10 +289,19 @@ impl BapCommandReceipt { self.transition } - /// Return whether a retry has the exact same task, key, and lifecycle event. + /// Return whether a retry has the exact same tenant, task, key, and lifecycle event. #[must_use] - pub fn matches(&self, idempotency_key: &str, task_id: &str, event: BapTaskEvent) -> bool { - self.idempotency_key == idempotency_key && self.task_id == task_id && self.event() == event + 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 } } @@ -383,23 +419,27 @@ impl BapTaskLifecycle { /// Apply one lifecycle event and bind the accepted transition to a retry receipt. /// - /// This remains an in-memory contract: it identifies an exact retry but does - /// not provide durable deduplication or side-effect suppression. Receipts can - /// only be minted at this accepted-command boundary; callers cannot rebind an - /// already accepted transition to different retry or task metadata afterward. + /// 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, )) @@ -416,6 +456,16 @@ fn validate_idempotency_key(value: &str) -> Result<(), BapCommandReceiptError> { 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); From ebc637e5ba658adb4f3bfa708c73bf4554e11a3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:40:21 -0700 Subject: [PATCH 07/46] docs(api): make BAP tenant-scoped receipt truth explicit --- docs/API_CONTRACT.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 68731fa83..44078868f 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 @@ -110,8 +110,10 @@ Rules: - secret-handle max-use semantics remain independent of request idempotency. The active BAP lifecycle implementation exposes an in-memory command receipt that -identifies an exact key/task/event retry. It does not persist the receipt or claim -that a retry has been deduplicated until a durable runtime adapter exists. +identifies an exact tenant/key/task/event retry namespace. The caller-supplied tenant +identifier scopes retry identity only; it is not authentication or authorization +evidence. The receipt is not persisted and does not claim that a retry has been +deduplicated until a durable runtime adapter exists. ## 8. Deadline and cancellation From f5eed0c2133823028e0d855ef5ca92da32f957c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:42:59 +0900 Subject: [PATCH 08/46] docs(bap): document tenant-scoped receipts --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 2 +- docs/TRD.md | 8 +++++--- docs/traceability/README.md | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3ba3fa591..cd405b761 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -138,7 +138,7 @@ Owns universally value-redacted network evidence and source-bound provenance rec ### `originweave-bap` -The active BAP lane owns the in-memory task lifecycle and immutable command-receipt contract. A receipt binds a bounded idempotency key and task identifier to one accepted lifecycle event; it can identify an exact retry but is not durable deduplication, authentication, policy authority, transport, browser state, or side-effect suppression. +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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4fb9531..fc4410a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed -- Added an active BAP in-memory command receipt that binds bounded idempotency keys and task identities to accepted lifecycle transitions without claiming durable deduplication or side-effect suppression. +- 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. - 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/docs/TRD.md b/docs/TRD.md index 30ddae6a9..0076a0a63 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -127,9 +127,11 @@ 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 key and task ID to -one accepted lifecycle transition. Durable storage, tenant scope, concurrent -deduplication, and externally visible side-effect suppression remain unimplemented. +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 diff --git a/docs/traceability/README.md b/docs/traceability/README.md index 4dd6b380e..c5e097963 100644 --- a/docs/traceability/README.md +++ b/docs/traceability/README.md @@ -66,7 +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 idempotency key and task identifier to one accepted transition; durable deduplication and side-effect suppression 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 | From 5bfc1883a337f6ea38323a60d317c1f4947d646b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:04:39 -0700 Subject: [PATCH 09/46] test(bap): require idempotent receipt replay --- .../tests/idempotency_receipt.rs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/crates/originweave-bap/tests/idempotency_receipt.rs b/crates/originweave-bap/tests/idempotency_receipt.rs index 542126844..ca8a4312e 100644 --- a/crates/originweave-bap/tests/idempotency_receipt.rs +++ b/crates/originweave-bap/tests/idempotency_receipt.rs @@ -106,6 +106,72 @@ fn receipt_preserves_lifecycle_failure_without_mutating_the_task() { 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 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; @@ -131,6 +197,11 @@ fn receipt_errors_have_standard_error_contracts() { 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()); let transition = BapCommandReceiptError::TransitionRejected { error: originweave_bap::BapTaskTransitionError::SequenceExhausted, }; From 20f50c8a6628be366e6db46e7f314cb3e2f3dcec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:06:10 -0700 Subject: [PATCH 10/46] test(bap): format receipt replay regression --- .../tests/idempotency_receipt.rs | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/crates/originweave-bap/tests/idempotency_receipt.rs b/crates/originweave-bap/tests/idempotency_receipt.rs index ca8a4312e..441218812 100644 --- a/crates/originweave-bap/tests/idempotency_receipt.rs +++ b/crates/originweave-bap/tests/idempotency_receipt.rs @@ -110,13 +110,7 @@ fn receipt_preserves_lifecycle_failure_without_mutating_the_task() { 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, - ) + .apply_or_replay(None, "request-1", "tenant-1", "task-1", BapTaskEvent::Admit) .expect("initial receipt"); assert_eq!(task.state(), BapTaskState::Admitted); @@ -142,13 +136,7 @@ fn exact_retry_replays_retained_receipt_without_reapplying_transition() { 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, - ) + .apply_or_replay(None, "request-1", "tenant-1", "task-1", BapTaskEvent::Admit) .expect("initial receipt"); for (idempotency_key, tenant_id, task_id, event) in [ @@ -158,13 +146,7 @@ fn conflicting_retry_fails_closed_without_mutating_lifecycle() { ("request-1", "tenant-1", "task-1", BapTaskEvent::Start), ] { assert_eq!( - task.apply_or_replay( - Some(&receipt), - idempotency_key, - tenant_id, - task_id, - event, - ), + task.apply_or_replay(Some(&receipt), idempotency_key, tenant_id, task_id, event,), Err(BapCommandReceiptError::IdempotencyConflict) ); assert_eq!(task.state(), BapTaskState::Admitted); @@ -201,7 +183,11 @@ fn receipt_errors_have_standard_error_contracts() { BapCommandReceiptError::IdempotencyConflict.to_string(), "BAP idempotency key conflicts with the retained command receipt" ); - assert!(BapCommandReceiptError::IdempotencyConflict.source().is_none()); + assert!( + BapCommandReceiptError::IdempotencyConflict + .source() + .is_none() + ); let transition = BapCommandReceiptError::TransitionRejected { error: originweave_bap::BapTaskTransitionError::SequenceExhausted, }; From f6e3a3adcfb9cc7a60ef1d79e2aeee27ba54c084 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:08:35 -0700 Subject: [PATCH 11/46] feat(bap): replay exact idempotent receipts --- crates/originweave-bap/src/lib.rs | 34 ++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index fe0fb031a..4d0d56cd6 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -194,6 +194,8 @@ pub enum BapCommandReceiptError { 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 lifecycle event could not be accepted for the current task state. TransitionRejected { /// The lifecycle failure preserved by the receipt boundary. @@ -214,6 +216,10 @@ impl std::fmt::Display for BapCommandReceiptError { } 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::TransitionRejected { error } => error.fmt(formatter), } } @@ -228,7 +234,8 @@ impl std::error::Error for BapCommandReceiptError { | Self::InvalidTenantId | Self::TenantIdLimitExceeded | Self::InvalidTaskId - | Self::TaskIdLimitExceeded => None, + | Self::TaskIdLimitExceeded + | Self::IdempotencyConflict => None, } } } @@ -444,6 +451,31 @@ impl BapTaskLifecycle { transition, )) } + + /// 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 returns that immutable receipt without mutating the + /// lifecycle again. Any supplied mismatch 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 { + if receipt.matches(idempotency_key, tenant_id, task_id, event) { + return Ok(receipt.clone()); + } + return Err(BapCommandReceiptError::IdempotencyConflict); + } + self.apply_with_receipt(idempotency_key, tenant_id, task_id, event) + } } fn validate_idempotency_key(value: &str) -> Result<(), BapCommandReceiptError> { From 67821cec1efc73a5c45811b73d6352b62a3c1061 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:05:52 -0700 Subject: [PATCH 12/46] test(bap): reject replay on unrelated lifecycle state --- .../tests/idempotency_receipt.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/originweave-bap/tests/idempotency_receipt.rs b/crates/originweave-bap/tests/idempotency_receipt.rs index 441218812..ba524a6ba 100644 --- a/crates/originweave-bap/tests/idempotency_receipt.rs +++ b/crates/originweave-bap/tests/idempotency_receipt.rs @@ -132,6 +132,28 @@ fn exact_retry_replays_retained_receipt_without_reapplying_transition() { } } +#[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 conflicting_retry_fails_closed_without_mutating_lifecycle() { let mut task = BapTaskLifecycle::new(); From e8fc7ec1e7540203e660e859ff9945e89d1f980c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:09:40 -0700 Subject: [PATCH 13/46] fix(bap): bind idempotent replay to lifecycle state --- crates/originweave-bap/src/lib.rs | 33 ++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 4d0d56cd6..0ff506ca8 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -196,6 +196,8 @@ pub enum BapCommandReceiptError { 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. @@ -220,6 +222,10 @@ impl std::fmt::Display for BapCommandReceiptError { 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), } } @@ -235,7 +241,8 @@ impl std::error::Error for BapCommandReceiptError { | Self::TenantIdLimitExceeded | Self::InvalidTaskId | Self::TaskIdLimitExceeded - | Self::IdempotencyConflict => None, + | Self::IdempotencyConflict + | Self::ReplayStateMismatch => None, } } } @@ -455,11 +462,13 @@ impl BapTaskLifecycle { /// 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 returns that immutable receipt without mutating the - /// lifecycle again. Any supplied mismatch 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. + /// idempotency-key, task, and event equality plus an exact match between the receipt's accepted + /// transition and this lifecycle's current state/sequence returns that immutable receipt without + /// mutating the lifecycle again. Command mismatch or stale/foreign lifecycle state 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>, @@ -469,10 +478,16 @@ impl BapTaskLifecycle { event: BapTaskEvent, ) -> Result { if let Some(receipt) = existing_receipt { - if receipt.matches(idempotency_key, tenant_id, task_id, event) { - return Ok(receipt.clone()); + 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() + { + return Err(BapCommandReceiptError::ReplayStateMismatch); } - return Err(BapCommandReceiptError::IdempotencyConflict); + return Ok(receipt.clone()); } self.apply_with_receipt(idempotency_key, tenant_id, task_id, event) } From 8fafe5d0addf1bfcb12ca941fd75f61c3288b1a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:10:43 -0700 Subject: [PATCH 14/46] test(bap): cover stale receipt replay refusal --- .../tests/idempotency_receipt.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/originweave-bap/tests/idempotency_receipt.rs b/crates/originweave-bap/tests/idempotency_receipt.rs index ba524a6ba..e11ea5ae3 100644 --- a/crates/originweave-bap/tests/idempotency_receipt.rs +++ b/crates/originweave-bap/tests/idempotency_receipt.rs @@ -154,6 +154,28 @@ fn retained_receipt_from_a_different_lifecycle_fails_closed() { 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 conflicting_retry_fails_closed_without_mutating_lifecycle() { let mut task = BapTaskLifecycle::new(); @@ -210,6 +232,11 @@ fn receipt_errors_have_standard_error_contracts() { .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, }; From e7e32a54c72655797b427447f46081ab1acdf020 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:13:18 -0700 Subject: [PATCH 15/46] style(bap): apply canonical rustfmt --- crates/originweave-bap/tests/idempotency_receipt.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/originweave-bap/tests/idempotency_receipt.rs b/crates/originweave-bap/tests/idempotency_receipt.rs index e11ea5ae3..e3a005305 100644 --- a/crates/originweave-bap/tests/idempotency_receipt.rs +++ b/crates/originweave-bap/tests/idempotency_receipt.rs @@ -236,7 +236,11 @@ fn receipt_errors_have_standard_error_contracts() { BapCommandReceiptError::ReplayStateMismatch.to_string(), "BAP retained command receipt does not match the current lifecycle state" ); - assert!(BapCommandReceiptError::ReplayStateMismatch.source().is_none()); + assert!( + BapCommandReceiptError::ReplayStateMismatch + .source() + .is_none() + ); let transition = BapCommandReceiptError::TransitionRejected { error: originweave_bap::BapTaskTransitionError::SequenceExhausted, }; From ccd2be3c89fa71107113f27e771e72a8d433013b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:17:13 -0700 Subject: [PATCH 16/46] test(bap): cover replay sequence mismatch branch --- .../tests/idempotency_receipt.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/originweave-bap/tests/idempotency_receipt.rs b/crates/originweave-bap/tests/idempotency_receipt.rs index e3a005305..ad1dd7134 100644 --- a/crates/originweave-bap/tests/idempotency_receipt.rs +++ b/crates/originweave-bap/tests/idempotency_receipt.rs @@ -176,6 +176,35 @@ fn stale_receipt_after_lifecycle_advances_fails_closed() { 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(); From 17e49c67c64dc1b952f3f15696adecd3ae8cf96e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 21:59:56 -0700 Subject: [PATCH 17/46] test(bap): validate retry identifiers before replay comparison --- .../tests/idempotency_replay_validation.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 crates/originweave-bap/tests/idempotency_replay_validation.rs 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..dc73f339d --- /dev/null +++ b/crates/originweave-bap/tests/idempotency_replay_validation.rs @@ -0,0 +1,52 @@ +#![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); +} From 9f4195204d9f449c936d4c5f7d19ac54060fd601 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:03:28 -0700 Subject: [PATCH 18/46] fix(bap): validate retry identifiers before replay --- crates/originweave-bap/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 0ff506ca8..ec7d9a245 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -477,6 +477,9 @@ impl BapTaskLifecycle { task_id: &str, event: BapTaskEvent, ) -> Result { + validate_idempotency_key(idempotency_key)?; + validate_tenant_id(tenant_id)?; + validate_task_id(task_id)?; if let Some(receipt) = existing_receipt { if !receipt.matches(idempotency_key, tenant_id, task_id, event) { return Err(BapCommandReceiptError::IdempotencyConflict); From 021da64574c5460d990e41eafdb159002a9656a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:33:49 -0700 Subject: [PATCH 19/46] test(bap): reject replay across divergent transition paths --- .../tests/idempotency_transition_identity.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 crates/originweave-bap/tests/idempotency_transition_identity.rs 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..e456cf45d --- /dev/null +++ b/crates/originweave-bap/tests/idempotency_transition_identity.rs @@ -0,0 +1,52 @@ +#![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); +} From 6b13236885511a0a71349a2899285833179ce44a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:36:55 -0700 Subject: [PATCH 20/46] fix(bap): bind receipt replay to exact transition history --- crates/originweave-bap/src/lib.rs | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index ec7d9a245..5e010c45b 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -329,6 +329,7 @@ impl BapCommandReceipt { pub struct BapTaskLifecycle { state: BapTaskState, transition_sequence: u64, + last_transition: Option, } impl Default for BapTaskLifecycle { @@ -344,6 +345,7 @@ impl BapTaskLifecycle { Self { state: BapTaskState::Created, transition_sequence: 0, + last_transition: None, } } @@ -351,7 +353,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, @@ -365,6 +370,7 @@ impl BapTaskLifecycle { Ok(Self { state, transition_sequence, + last_transition: None, }) } @@ -421,14 +427,16 @@ 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. @@ -463,12 +471,12 @@ impl BapTaskLifecycle { /// /// 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 current state/sequence returns that immutable receipt without - /// mutating the lifecycle again. Command mismatch or stale/foreign lifecycle state 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. + /// 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>, @@ -487,6 +495,7 @@ impl BapTaskLifecycle { let transition = receipt.transition(); if self.state != transition.current_state() || self.transition_sequence != transition.sequence() + || self.last_transition != Some(transition) { return Err(BapCommandReceiptError::ReplayStateMismatch); } From b6d4d42fd120ac937f6b4e2fead257b28a3ca1ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:38:01 -0700 Subject: [PATCH 21/46] test(bap): fail closed on receipt replay after partial restore --- .../tests/idempotency_transition_identity.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/originweave-bap/tests/idempotency_transition_identity.rs b/crates/originweave-bap/tests/idempotency_transition_identity.rs index e456cf45d..d13991072 100644 --- a/crates/originweave-bap/tests/idempotency_transition_identity.rs +++ b/crates/originweave-bap/tests/idempotency_transition_identity.rs @@ -50,3 +50,32 @@ fn replay_rejects_same_state_and_sequence_from_a_different_transition_path() { 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); +} From d2411d927a4ad88534de15d98a29374b5750b6a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:40:07 -0700 Subject: [PATCH 22/46] docs(bap): record exact-transition replay hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc4410a03..3819a509a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed - 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. +- 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. - 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. From 9ce97c963d264f6e3c1aa2c7d0bf02dc51dc052f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:43:02 -0700 Subject: [PATCH 23/46] style(bap): apply canonical rustfmt --- .../tests/idempotency_transition_identity.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/originweave-bap/tests/idempotency_transition_identity.rs b/crates/originweave-bap/tests/idempotency_transition_identity.rs index d13991072..645389b65 100644 --- a/crates/originweave-bap/tests/idempotency_transition_identity.rs +++ b/crates/originweave-bap/tests/idempotency_transition_identity.rs @@ -5,8 +5,12 @@ use originweave_bap::{BapCommandReceiptError, BapTaskEvent, BapTaskLifecycle, Ba #[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::Admit) + .expect("admit source"); + source_task + .apply(BapTaskEvent::Start) + .expect("start source"); source_task .apply(BapTaskEvent::WaitForApproval) .expect("wait source"); @@ -26,7 +30,9 @@ fn replay_rejects_same_state_and_sequence_from_a_different_transition_path() { other_task .apply(BapTaskEvent::WaitForExternalInput) .expect("wait other"); - other_task.apply(BapTaskEvent::Resume).expect("resume other"); + other_task + .apply(BapTaskEvent::Resume) + .expect("resume other"); assert_eq!(source_task.state(), BapTaskState::Running); assert_eq!(other_task.state(), BapTaskState::Running); @@ -64,8 +70,8 @@ fn restored_snapshot_without_last_transition_identity_cannot_replay_receipt() { ) .expect("source admit receipt"); - let mut restored = BapTaskLifecycle::restore(BapTaskState::Admitted, 1) - .expect("reachable admitted snapshot"); + let mut restored = + BapTaskLifecycle::restore(BapTaskState::Admitted, 1).expect("reachable admitted snapshot"); assert_eq!( restored.apply_or_replay( Some(&receipt), From a2cdf1b297b4eb02a2caac7ead1bee2d979fac99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:11:35 -0700 Subject: [PATCH 24/46] test(bap): require credential-safe receipt debug output --- .../tests/receipt_debug_redaction.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 crates/originweave-bap/tests/receipt_debug_redaction.rs 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..281108c09 --- /dev/null +++ b/crates/originweave-bap/tests/receipt_debug_redaction.rs @@ -0,0 +1,21 @@ +#![allow(clippy::expect_used)] + +use originweave_bap::{BapTaskEvent, BapTaskLifecycle}; + +#[test] +fn command_receipt_debug_does_not_disclose_retry_or_tenant_identifiers() { + let mut task = BapTaskLifecycle::new(); + let receipt = task + .apply_with_receipt( + "retry-secret-marker", + "private-tenant-marker", + "task-1", + 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")); +} From 7253da12027cfeba0f6d3332ba8376aab9b1ef24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:16:47 -0700 Subject: [PATCH 25/46] fix(bap): redact receipt retry diagnostics --- crates/originweave-bap/src/lib.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 5e010c45b..9280c967c 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -248,7 +248,7 @@ impl std::error::Error for BapCommandReceiptError { } /// An immutable receipt binding one accepted lifecycle command to its retry namespace and key. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct BapCommandReceipt { idempotency_key: String, tenant_id: String, @@ -256,6 +256,17 @@ pub struct BapCommandReceipt { 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", &self.task_id) + .field("transition", &self.transition) + .finish() + } +} + impl BapCommandReceipt { fn from_validated( idempotency_key: &str, @@ -556,4 +567,4 @@ const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bo transition_sequence >= 1 } } -} +} \ No newline at end of file From 526e1cb2b26735309845d1b89a89c96a2e160da6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:20:31 -0700 Subject: [PATCH 26/46] style(bap): apply canonical Rust formatting --- crates/originweave-bap/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 9280c967c..2c0563dc2 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -567,4 +567,4 @@ const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bo transition_sequence >= 1 } } -} \ No newline at end of file +} From 7e2b40fd94d3f4df4a251bd2918bd6c8835f5b31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:24:18 -0700 Subject: [PATCH 27/46] test(bap): require task identifier debug redaction --- crates/originweave-bap/tests/receipt_debug_redaction.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/originweave-bap/tests/receipt_debug_redaction.rs b/crates/originweave-bap/tests/receipt_debug_redaction.rs index 281108c09..5b3b0d4a0 100644 --- a/crates/originweave-bap/tests/receipt_debug_redaction.rs +++ b/crates/originweave-bap/tests/receipt_debug_redaction.rs @@ -3,13 +3,13 @@ use originweave_bap::{BapTaskEvent, BapTaskLifecycle}; #[test] -fn command_receipt_debug_does_not_disclose_retry_or_tenant_identifiers() { +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", - "task-1", + "private-task-marker", BapTaskEvent::Admit, ) .expect("receipt"); @@ -18,4 +18,5 @@ fn command_receipt_debug_does_not_disclose_retry_or_tenant_identifiers() { 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")); } From 825dd15b498a3e7a524a8031adbd6733b63bf685 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:17:18 -0700 Subject: [PATCH 28/46] fix(bap): redact task identifiers from receipt debug --- crates/originweave-bap/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 2c0563dc2..98b9b83a3 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -261,7 +261,7 @@ impl std::fmt::Debug for BapCommandReceipt { formatter .debug_struct("BapCommandReceipt") .field("idempotency_key_byte_count", &self.idempotency_key.len()) - .field("task_id", &self.task_id) + .field("task_id_byte_count", &self.task_id.len()) .field("transition", &self.transition) .finish() } @@ -567,4 +567,4 @@ const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bo transition_sequence >= 1 } } -} +} \ No newline at end of file From a5d1a6a1c0ee594ae2a139464f57afde535784a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:47:52 -0700 Subject: [PATCH 29/46] chore(bap): apply canonical rustfmt newline --- crates/originweave-bap/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 98b9b83a3..c77ac7b8a 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -567,4 +567,4 @@ const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bo transition_sequence >= 1 } } -} \ No newline at end of file +} From dbfea3b4e0198ddfbb264b0b0de61e439ae3a6dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:31:20 -0700 Subject: [PATCH 30/46] test(bap): require exact transition evidence for recovery --- .../task_recovery_transition_evidence.rs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 crates/originweave-bap/tests/task_recovery_transition_evidence.rs 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..15c0f4cee --- /dev/null +++ b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs @@ -0,0 +1,130 @@ +use originweave_bap::{ + BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, BapTaskState, BapTaskTransition, +}; + +#[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 recovery_requires_transition_evidence_after_any_accepted_transition() { + assert_eq!( + BapTaskLifecycle::restore_with_transition(BapTaskState::Admitted, 1, None), + Err(BapTaskRestoreError::MissingTransitionEvidence { + state: BapTaskState::Admitted, + transition_sequence: 1, + }) + ); + + 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!( + 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, + }) + ); +} From 81a9c4a968047e8ae77ff7c8980a5fcc0a894dd8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:34:28 -0700 Subject: [PATCH 31/46] test(bap): cover transition recovery errors --- .../tests/task_recovery_transition_evidence.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs index 15c0f4cee..b84d4e9bb 100644 --- a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs +++ b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs @@ -41,12 +41,17 @@ fn exact_transition_evidence_restores_receipt_replay_without_second_mutation() { #[test] fn recovery_requires_transition_evidence_after_any_accepted_transition() { + 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(BapTaskRestoreError::MissingTransitionEvidence { - state: BapTaskState::Admitted, - transition_sequence: 1, - }) + Err(missing) ); assert_eq!( @@ -63,6 +68,10 @@ fn transition_restore_rejects_zero_unreachable_invalid_and_mismatched_evidence() 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( From 4ce264ffdcae152876661ee16598d6562e66bdf3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:35:20 -0700 Subject: [PATCH 32/46] test(bap): cover recovery sequence mismatch --- .../tests/task_recovery_transition_evidence.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs index b84d4e9bb..033c95a77 100644 --- a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs +++ b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs @@ -136,4 +136,18 @@ fn lifecycle_restore_rejects_transition_from_a_different_snapshot() { 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, + }) + ); } From e4b45642fe81e396a1ecfb5ae1de1a6eb9817804 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:36:06 -0700 Subject: [PATCH 33/46] feat(bap): restore exact transition recovery evidence --- crates/originweave-bap/src/lib.rs | 97 +++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index c77ac7b8a..c0c4a458b 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -126,6 +126,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 { @@ -138,6 +152,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" + ), } } } @@ -177,6 +205,32 @@ impl BapTaskTransition { 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. @@ -385,6 +439,49 @@ impl BapTaskLifecycle { }) } + /// 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, + }); + }; + let transition = BapTaskTransition::restore( + transition.previous_state(), + transition.current_state(), + transition.sequence(), + transition.event(), + )?; + 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 { From cb812a41845cf06bbe4b713e99558448ce56094d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:38:04 -0700 Subject: [PATCH 34/46] test(bap): align recovery regression with strict clippy --- .../originweave-bap/tests/task_recovery_transition_evidence.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs index 033c95a77..f8e8689a3 100644 --- a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs +++ b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs @@ -1,3 +1,5 @@ +#![allow(clippy::expect_used)] + use originweave_bap::{ BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, BapTaskState, BapTaskTransition, }; From 3001c47326d5bfc221effe76768c2d4cb4503229 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:40:54 -0700 Subject: [PATCH 35/46] refactor(bap): trust validated transition typestate on recovery --- crates/originweave-bap/src/lib.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index c0c4a458b..cbbb13090 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -466,12 +466,6 @@ impl BapTaskLifecycle { transition_sequence, }); }; - let transition = BapTaskTransition::restore( - transition.previous_state(), - transition.current_state(), - transition.sequence(), - transition.event(), - )?; if transition.current_state() != state || transition.sequence() != transition_sequence { return Err(BapTaskRestoreError::InvalidTransitionEvidence { state, From 37daf0df5bd0c2557af5d43f32ab6b38043ddf4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:43:58 -0700 Subject: [PATCH 36/46] test(bap): cover invalid recovery snapshot propagation --- .../tests/task_recovery_transition_evidence.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs index f8e8689a3..cad29d711 100644 --- a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs +++ b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs @@ -43,6 +43,15 @@ fn exact_transition_evidence_restores_receipt_replay_without_second_mutation() { #[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, From 794e532dd70c1f03416b87016ec8f10d565ee6cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:00:43 -0700 Subject: [PATCH 37/46] test(bap): require persisted receipt reconstruction --- .../task_recovery_transition_evidence.rs | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs index cad29d711..779c6ab6f 100644 --- a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs +++ b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs @@ -1,7 +1,8 @@ #![allow(clippy::expect_used)] use originweave_bap::{ - BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, BapTaskState, BapTaskTransition, + BapCommandReceipt, BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, BapTaskState, + BapTaskTransition, }; #[test] @@ -41,6 +42,50 @@ fn exact_transition_evidence_restores_receipt_replay_without_second_mutation() { 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 recovery_requires_transition_evidence_after_any_accepted_transition() { let invalid_snapshot = BapTaskRestoreError::InvalidSnapshot { From df89f56a7976447da76214c7d8300633d600639d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:05:31 -0700 Subject: [PATCH 38/46] fix(bap): reconstruct persisted idempotency receipts --- crates/originweave-bap/src/lib.rs | 22 ++++++++ .../task_recovery_transition_evidence.rs | 53 ++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index cbbb13090..708961c1c 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -336,6 +336,28 @@ impl BapCommandReceipt { } } + /// 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 { diff --git a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs index 779c6ab6f..3b1c70eae 100644 --- a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs +++ b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs @@ -1,8 +1,9 @@ #![allow(clippy::expect_used)] use originweave_bap::{ - BapCommandReceipt, BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, BapTaskState, - BapTaskTransition, + BapCommandReceipt, BapCommandReceiptError, BapTaskEvent, BapTaskLifecycle, + BapTaskRestoreError, BapTaskState, BapTaskTransition, MAX_BAP_IDEMPOTENCY_KEY_BYTES, + MAX_BAP_TASK_ID_BYTES, MAX_BAP_TENANT_ID_BYTES, }; #[test] @@ -86,6 +87,54 @@ fn persisted_receipt_fields_can_be_reconstructed_for_cross_process_replay() { 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 { From 602ec205585656910f28f8a87086b459a8f7e42e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:07:21 -0700 Subject: [PATCH 39/46] test(bap): align persisted receipt recovery formatting --- .../tests/task_recovery_transition_evidence.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs index 3b1c70eae..560b27328 100644 --- a/crates/originweave-bap/tests/task_recovery_transition_evidence.rs +++ b/crates/originweave-bap/tests/task_recovery_transition_evidence.rs @@ -1,9 +1,9 @@ #![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, + BapCommandReceipt, BapCommandReceiptError, BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, + BapTaskState, BapTaskTransition, MAX_BAP_IDEMPOTENCY_KEY_BYTES, MAX_BAP_TASK_ID_BYTES, + MAX_BAP_TENANT_ID_BYTES, }; #[test] From 72fba9b59b1064deaf169fd00bdd4fa9205ac913 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 19:20:28 -0700 Subject: [PATCH 40/46] test(bap): cover reconciliation receipt recovery --- .../tests/reconciliation_receipt_recovery.rs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 crates/originweave-bap/tests/reconciliation_receipt_recovery.rs 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..ef78d9805 --- /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, + }) + ); +} From 0773c159e3bda05f30c596508c1f9b7d71d17a7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 19:21:47 -0700 Subject: [PATCH 41/46] style(bap): apply canonical formatting to reconciliation recovery test --- .../tests/reconciliation_receipt_recovery.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/originweave-bap/tests/reconciliation_receipt_recovery.rs b/crates/originweave-bap/tests/reconciliation_receipt_recovery.rs index ef78d9805..deabdf4ff 100644 --- a/crates/originweave-bap/tests/reconciliation_receipt_recovery.rs +++ b/crates/originweave-bap/tests/reconciliation_receipt_recovery.rs @@ -54,7 +54,10 @@ fn reconciliation_receipt_replays_after_transition_backed_restore() { let resolution = restored .apply(BapTaskEvent::ResolveReconciliation) .expect("resolve reconciliation"); - assert_eq!(resolution.previous_state(), BapTaskState::ReconciliationRequired); + assert_eq!( + resolution.previous_state(), + BapTaskState::ReconciliationRequired + ); assert_eq!(resolution.current_state(), BapTaskState::Running); assert_eq!(resolution.sequence(), 4); } @@ -89,12 +92,9 @@ fn dead_letter_receipt_replays_but_terminal_state_stays_closed() { 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 mut restored = + BapTaskLifecycle::restore_with_transition(BapTaskState::DeadLettered, 4, Some(transition)) + .expect("restore lifecycle"); let replay = restored .apply_or_replay( Some(&restored_receipt), From 9977e0c8218ff8fdbeb259c14fed478931d9bff7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:08:03 -0700 Subject: [PATCH 42/46] test(bap): require read-only replay validation --- .../tests/idempotency_replay_validation.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/originweave-bap/tests/idempotency_replay_validation.rs b/crates/originweave-bap/tests/idempotency_replay_validation.rs index dc73f339d..4e48f11b3 100644 --- a/crates/originweave-bap/tests/idempotency_replay_validation.rs +++ b/crates/originweave-bap/tests/idempotency_replay_validation.rs @@ -50,3 +50,25 @@ fn replay_validates_retry_identifiers_before_receipt_comparison() { 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); +} From 719878f7e7c3bbd16a8bbdbce2f9824407c32d35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:10:39 -0700 Subject: [PATCH 43/46] feat(bap): add read-only replay validation --- crates/originweave-bap/src/lib.rs | 45 ++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 1219b24ca..f9e6c184f 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -621,6 +621,37 @@ impl BapTaskLifecycle { )) } + /// 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, @@ -639,20 +670,8 @@ impl BapTaskLifecycle { task_id: &str, event: BapTaskEvent, ) -> Result { - validate_idempotency_key(idempotency_key)?; - validate_tenant_id(tenant_id)?; - validate_task_id(task_id)?; if let Some(receipt) = existing_receipt { - 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); - } + 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) From 34c5e716b6c4fce00fe068ed9139a9a71d128ed0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 11:14:13 -0700 Subject: [PATCH 44/46] docs(bap): restore idempotency changelog after restack --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f747adeae..82284d334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- 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. +- 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. From 3694dc438edf06dabbec0e1b40458e5f757cd05a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 11:44:13 -0700 Subject: [PATCH 45/46] docs(api): narrow BAP receipt authority claim --- docs/API_CONTRACT.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 44078868f..ce6833b53 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -109,11 +109,13 @@ Rules: - idempotency retention is bounded and declared; - secret-handle max-use semantics remain independent of request idempotency. -The active BAP lifecycle implementation exposes an in-memory command receipt that -identifies an exact tenant/key/task/event retry namespace. The caller-supplied tenant -identifier scopes retry identity only; it is not authentication or authorization -evidence. The receipt is not persisted and does not claim that a retry has been -deduplicated until a durable runtime adapter exists. +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 From 52a918577958a5701e1146c7eb8b62fe8f8ccd44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 11:45:18 -0700 Subject: [PATCH 46/46] docs(changelog): classify BAP receipt as added --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82284d334..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,7 +49,6 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed -- 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. - 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.