From 73253d4aec079ab4954ba41a616fc4a158a68013 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:03:08 -0700 Subject: [PATCH 01/20] test(bap): require fail-closed external outcome recovery --- .../tests/recovery_outcome_classification.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 crates/originweave-bap/tests/recovery_outcome_classification.rs diff --git a/crates/originweave-bap/tests/recovery_outcome_classification.rs b/crates/originweave-bap/tests/recovery_outcome_classification.rs new file mode 100644 index 00000000..a01a3eb5 --- /dev/null +++ b/crates/originweave-bap/tests/recovery_outcome_classification.rs @@ -0,0 +1,58 @@ +use originweave_bap::{ + BapCommandRecovery, BapExternalSideEffectOutcome, BapRecoveryAction, BapTaskEvent, + BapTaskLifecycle, +}; + +fn accepted_receipt() -> originweave_bap::BapCommandReceipt { + let mut lifecycle = BapTaskLifecycle::new(); + let receipt = lifecycle.apply_with_receipt( + "retry-key", + "tenant-a", + "task-a", + BapTaskEvent::Admit, + ); + assert!(receipt.is_ok(), "{receipt:?}"); + let Ok(receipt) = receipt else { + unreachable!("asserted valid command receipt") + }; + receipt +} + +#[test] +fn crash_recovery_distinguishes_external_side_effect_outcomes_without_unsafe_replay() { + let cases = [ + ( + BapExternalSideEffectOutcome::ConfirmedNoSideEffect, + BapRecoveryAction::RevalidateBeforeRedispatch, + true, + ), + ( + BapExternalSideEffectOutcome::ConfirmedSideEffect, + BapRecoveryAction::VerifyConfirmedSideEffect, + false, + ), + ( + BapExternalSideEffectOutcome::UnknownOutcome, + BapRecoveryAction::ReconcileBeforeFurtherAction, + false, + ), + ( + BapExternalSideEffectOutcome::ReconciliationRequired, + BapRecoveryAction::ReconcileBeforeFurtherAction, + false, + ), + ]; + + for (outcome, expected_action, expected_redispatch) in cases { + let recovery = BapCommandRecovery::new(accepted_receipt(), outcome); + assert_eq!(recovery.external_outcome(), outcome); + assert_eq!(recovery.required_action(), expected_action); + assert_eq!(recovery.permits_redispatch(), expected_redispatch); + assert_eq!(recovery.receipt().task_id(), "task-a"); + + let debug = format!("{recovery:?}"); + assert!(!debug.contains("retry-key")); + assert!(!debug.contains("tenant-a")); + assert!(!debug.contains("task-a")); + } +} From ce485c77d298b2aef725f514801e715d414fc915 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:06:23 -0700 Subject: [PATCH 02/20] feat(bap): classify external crash-recovery outcomes --- crates/originweave-bap/src/public_api.rs | 114 +++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 crates/originweave-bap/src/public_api.rs diff --git a/crates/originweave-bap/src/public_api.rs b/crates/originweave-bap/src/public_api.rs new file mode 100644 index 00000000..a824dd71 --- /dev/null +++ b/crates/originweave-bap/src/public_api.rs @@ -0,0 +1,114 @@ +//! Stable internal Browser Agent Protocol lifecycle and crash-recovery contracts. +//! +//! The public recovery types deliberately separate caller-supplied external +//! side-effect classification from task success or authority. Durable runtimes +//! remain responsible for authenticating recovery evidence and for revalidating +//! tenant, policy, destination, secret, and browser authority before any retry. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +#[path = "lib.rs"] +mod lifecycle; + +pub use lifecycle::*; + +/// Caller-supplied classification of an external side effect during crash recovery. +/// +/// This value is not proof that the classified outcome occurred. A durable +/// runtime or reconciler must authenticate and persist the evidence that +/// supports the classification. Unknown or explicitly unreconciled outcomes +/// fail closed and cannot authorize redispatch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapExternalSideEffectOutcome { + /// The recovery authority confirmed that the interrupted command caused no external side effect. + ConfirmedNoSideEffect, + /// The recovery authority confirmed that the interrupted command caused its external side effect. + ConfirmedSideEffect, + /// The recovery authority cannot determine whether the external side effect occurred. + UnknownOutcome, + /// Recovery evidence explicitly requires reconciliation before further action. + ReconciliationRequired, +} + +/// Required fail-closed handling for one classified external recovery outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapRecoveryAction { + /// Revalidate normal authority and policy before considering command redispatch. + RevalidateBeforeRedispatch, + /// Verify the confirmed external side effect and its post-condition without redispatching it. + VerifyConfirmedSideEffect, + /// Reconcile external state before any retry, success, or terminal decision. + ReconcileBeforeFurtherAction, +} + +impl BapExternalSideEffectOutcome { + /// Map the classification to the minimum required recovery action. + #[must_use] + pub const fn required_action(self) -> BapRecoveryAction { + match self { + Self::ConfirmedNoSideEffect => BapRecoveryAction::RevalidateBeforeRedispatch, + Self::ConfirmedSideEffect => BapRecoveryAction::VerifyConfirmedSideEffect, + Self::UnknownOutcome | Self::ReconciliationRequired => { + BapRecoveryAction::ReconcileBeforeFurtherAction + } + } + } +} + +/// Receipt-bound crash-recovery classification for one accepted BAP command. +/// +/// Binding the external outcome to the immutable command receipt prevents a +/// recovery classification from floating free of the retry namespace, task, +/// lifecycle event, and accepted transition. Construction does not authenticate +/// the classification or grant authority; callers must validate the external +/// evidence at their durable trust boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BapCommandRecovery { + receipt: BapCommandReceipt, + external_outcome: BapExternalSideEffectOutcome, +} + +impl BapCommandRecovery { + /// Bind one caller-supplied external outcome classification to an accepted command receipt. + #[must_use] + pub const fn new( + receipt: BapCommandReceipt, + external_outcome: BapExternalSideEffectOutcome, + ) -> Self { + Self { + receipt, + external_outcome, + } + } + + /// Return the immutable command receipt whose interrupted side effect is being classified. + #[must_use] + pub const fn receipt(&self) -> &BapCommandReceipt { + &self.receipt + } + + /// Return the caller-supplied external side-effect classification. + #[must_use] + pub const fn external_outcome(&self) -> BapExternalSideEffectOutcome { + self.external_outcome + } + + /// Return the minimum fail-closed handling required by the external outcome. + #[must_use] + pub const fn required_action(&self) -> BapRecoveryAction { + self.external_outcome.required_action() + } + + /// Return whether redispatch may be considered after normal authority and policy revalidation. + /// + /// Only a confirmed absence of the external side effect permits consideration + /// of redispatch. `true` is not authorization to redispatch. + #[must_use] + pub const fn permits_redispatch(&self) -> bool { + matches!( + self.required_action(), + BapRecoveryAction::RevalidateBeforeRedispatch + ) + } +} From 60729ab2611f2d372a76616bb9e38aa3e1181f4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:06:47 -0700 Subject: [PATCH 03/20] feat(bap): expose recovery facade as library root --- crates/originweave-bap/Cargo.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/originweave-bap/Cargo.toml b/crates/originweave-bap/Cargo.toml index 39e8e38f..c26b1824 100644 --- a/crates/originweave-bap/Cargo.toml +++ b/crates/originweave-bap/Cargo.toml @@ -8,5 +8,8 @@ authors.workspace = true repository.workspace = true homepage.workspace = true +[lib] +path = "src/public_api.rs" + [lints] -workspace = true +workspace = true \ No newline at end of file From 9a9cd814baeb21f2577f1b98d1ca1c0ab8532a56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:07:15 -0700 Subject: [PATCH 04/20] test(bap): align recovery outcome regression formatting --- .../tests/recovery_outcome_classification.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/originweave-bap/tests/recovery_outcome_classification.rs b/crates/originweave-bap/tests/recovery_outcome_classification.rs index a01a3eb5..b99d6b2a 100644 --- a/crates/originweave-bap/tests/recovery_outcome_classification.rs +++ b/crates/originweave-bap/tests/recovery_outcome_classification.rs @@ -5,12 +5,8 @@ use originweave_bap::{ fn accepted_receipt() -> originweave_bap::BapCommandReceipt { let mut lifecycle = BapTaskLifecycle::new(); - let receipt = lifecycle.apply_with_receipt( - "retry-key", - "tenant-a", - "task-a", - BapTaskEvent::Admit, - ); + let receipt = + lifecycle.apply_with_receipt("retry-key", "tenant-a", "task-a", BapTaskEvent::Admit); assert!(receipt.is_ok(), "{receipt:?}"); let Ok(receipt) = receipt else { unreachable!("asserted valid command receipt") From 0c4191b1dfc6badbaae0602dd9f7abb54149eda6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:58:02 -0700 Subject: [PATCH 05/20] test(bap): reject stale recovery redispatch signal --- .../tests/recovery_outcome_classification.rs | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/originweave-bap/tests/recovery_outcome_classification.rs b/crates/originweave-bap/tests/recovery_outcome_classification.rs index b99d6b2a..fe670f83 100644 --- a/crates/originweave-bap/tests/recovery_outcome_classification.rs +++ b/crates/originweave-bap/tests/recovery_outcome_classification.rs @@ -1,9 +1,9 @@ use originweave_bap::{ - BapCommandRecovery, BapExternalSideEffectOutcome, BapRecoveryAction, BapTaskEvent, - BapTaskLifecycle, + BapCommandReceipt, BapCommandReceiptError, BapCommandRecovery, BapExternalSideEffectOutcome, + BapRecoveryAction, BapTaskEvent, BapTaskLifecycle, BapTaskState, }; -fn accepted_receipt() -> originweave_bap::BapCommandReceipt { +fn accepted_receipt() -> (BapTaskLifecycle, BapCommandReceipt) { let mut lifecycle = BapTaskLifecycle::new(); let receipt = lifecycle.apply_with_receipt("retry-key", "tenant-a", "task-a", BapTaskEvent::Admit); @@ -11,7 +11,7 @@ fn accepted_receipt() -> originweave_bap::BapCommandReceipt { let Ok(receipt) = receipt else { unreachable!("asserted valid command receipt") }; - receipt + (lifecycle, receipt) } #[test] @@ -40,10 +40,16 @@ fn crash_recovery_distinguishes_external_side_effect_outcomes_without_unsafe_rep ]; for (outcome, expected_action, expected_redispatch) in cases { - let recovery = BapCommandRecovery::new(accepted_receipt(), outcome); + let (mut lifecycle, receipt) = accepted_receipt(); + let recovery = BapCommandRecovery::new(receipt, outcome); assert_eq!(recovery.external_outcome(), outcome); assert_eq!(recovery.required_action(), expected_action); - assert_eq!(recovery.permits_redispatch(), expected_redispatch); + assert_eq!( + recovery.permits_redispatch(&mut lifecycle), + Ok(expected_redispatch) + ); + assert_eq!(lifecycle.state(), BapTaskState::Admitted); + assert_eq!(lifecycle.transition_sequence(), 1); assert_eq!(recovery.receipt().task_id(), "task-a"); let debug = format!("{recovery:?}"); @@ -52,3 +58,21 @@ fn crash_recovery_distinguishes_external_side_effect_outcomes_without_unsafe_rep assert!(!debug.contains("task-a")); } } + +#[test] +fn stale_recovery_receipt_cannot_signal_redispatch() { + let (mut lifecycle, receipt) = accepted_receipt(); + let recovery = BapCommandRecovery::new( + receipt, + BapExternalSideEffectOutcome::ConfirmedNoSideEffect, + ); + + lifecycle.apply(BapTaskEvent::Start).expect("advance task"); + + assert_eq!( + recovery.permits_redispatch(&mut lifecycle), + Err(BapCommandReceiptError::ReplayStateMismatch) + ); + assert_eq!(lifecycle.state(), BapTaskState::Running); + assert_eq!(lifecycle.transition_sequence(), 2); +} From a6e5a09e78b642e3658af37b79800372c20179dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:59:21 -0700 Subject: [PATCH 06/20] test(bap): format stale recovery regression --- .../tests/recovery_outcome_classification.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/originweave-bap/tests/recovery_outcome_classification.rs b/crates/originweave-bap/tests/recovery_outcome_classification.rs index fe670f83..5a74b351 100644 --- a/crates/originweave-bap/tests/recovery_outcome_classification.rs +++ b/crates/originweave-bap/tests/recovery_outcome_classification.rs @@ -62,10 +62,8 @@ fn crash_recovery_distinguishes_external_side_effect_outcomes_without_unsafe_rep #[test] fn stale_recovery_receipt_cannot_signal_redispatch() { let (mut lifecycle, receipt) = accepted_receipt(); - let recovery = BapCommandRecovery::new( - receipt, - BapExternalSideEffectOutcome::ConfirmedNoSideEffect, - ); + let recovery = + BapCommandRecovery::new(receipt, BapExternalSideEffectOutcome::ConfirmedNoSideEffect); lifecycle.apply(BapTaskEvent::Start).expect("advance task"); From 700a372172c4e8c95a4c323112cc057786cc2d7d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:00:50 -0700 Subject: [PATCH 07/20] fix(bap): validate recovery receipt before redispatch signal --- crates/originweave-bap/src/public_api.rs | 30 ++++++++++++++++++------ 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/originweave-bap/src/public_api.rs b/crates/originweave-bap/src/public_api.rs index a824dd71..9a470650 100644 --- a/crates/originweave-bap/src/public_api.rs +++ b/crates/originweave-bap/src/public_api.rs @@ -100,15 +100,31 @@ impl BapCommandRecovery { self.external_outcome.required_action() } - /// Return whether redispatch may be considered after normal authority and policy revalidation. + /// Return whether redispatch may be considered for the current exact lifecycle state. /// - /// Only a confirmed absence of the external side effect permits consideration - /// of redispatch. `true` is not authorization to redispatch. - #[must_use] - pub const fn permits_redispatch(&self) -> bool { - matches!( + /// The retained receipt must still match the lifecycle's exact most recently accepted + /// transition before a confirmed absence of the external side effect can produce `true`. + /// Stale, foreign, state-only restored, or divergent lifecycle history therefore fails + /// closed with the underlying typed receipt error instead of emitting a redispatch signal. + /// The supplied lifecycle is not mutated when an existing receipt is validated. + /// + /// `Ok(true)` is still not authorization to redispatch. The caller must separately + /// authenticate recovery evidence and revalidate tenant, policy, destination, secret, + /// browser, and any other current authority before dispatching the command again. + pub fn permits_redispatch( + &self, + lifecycle: &mut BapTaskLifecycle, + ) -> Result { + lifecycle.apply_or_replay( + Some(&self.receipt), + self.receipt.idempotency_key(), + self.receipt.tenant_id(), + self.receipt.task_id(), + self.receipt.event(), + )?; + Ok(matches!( self.required_action(), BapRecoveryAction::RevalidateBeforeRedispatch - ) + )) } } From e15b3f71d3b431c95d0b5c4fc9bf08c4a4d2a06c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:04:24 -0700 Subject: [PATCH 08/20] test(bap): satisfy strict panic lint in recovery regression --- .../originweave-bap/tests/recovery_outcome_classification.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-bap/tests/recovery_outcome_classification.rs b/crates/originweave-bap/tests/recovery_outcome_classification.rs index 5a74b351..c3d48653 100644 --- a/crates/originweave-bap/tests/recovery_outcome_classification.rs +++ b/crates/originweave-bap/tests/recovery_outcome_classification.rs @@ -65,7 +65,8 @@ fn stale_recovery_receipt_cannot_signal_redispatch() { let recovery = BapCommandRecovery::new(receipt, BapExternalSideEffectOutcome::ConfirmedNoSideEffect); - lifecycle.apply(BapTaskEvent::Start).expect("advance task"); + let advance = lifecycle.apply(BapTaskEvent::Start); + assert!(advance.is_ok(), "{advance:?}"); assert_eq!( recovery.permits_redispatch(&mut lifecycle), From 4e72b6fc5074b67ed8d05c2c2f75000ed4eb8f0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:08:24 -0700 Subject: [PATCH 09/20] docs(changelog): record fail-closed recovery receipt validation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3819a509..88a1d9bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. +- Crash-recovery redispatch classification now validates its retained BAP command receipt against the lifecycle's exact most recently accepted transition before signaling that a confirmed-no-side-effect command may be reconsidered; stale or divergent recovery evidence fails closed with the typed receipt error and never becomes redispatch authorization. - 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 cf245ff94821da2f3070586927b6ff75ad86bbfe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:14:09 -0700 Subject: [PATCH 10/20] test(bap): require read-only recovery validation --- .../tests/recovery_outcome_classification.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/originweave-bap/tests/recovery_outcome_classification.rs b/crates/originweave-bap/tests/recovery_outcome_classification.rs index c3d48653..3b5e6b20 100644 --- a/crates/originweave-bap/tests/recovery_outcome_classification.rs +++ b/crates/originweave-bap/tests/recovery_outcome_classification.rs @@ -59,6 +59,17 @@ fn crash_recovery_distinguishes_external_side_effect_outcomes_without_unsafe_rep } } +#[test] +fn recovery_validation_requires_only_read_only_lifecycle_access() { + let (lifecycle, receipt) = accepted_receipt(); + let recovery = + BapCommandRecovery::new(receipt, BapExternalSideEffectOutcome::ConfirmedNoSideEffect); + + assert_eq!(recovery.permits_redispatch(&lifecycle), Ok(true)); + assert_eq!(lifecycle.state(), BapTaskState::Admitted); + assert_eq!(lifecycle.transition_sequence(), 1); +} + #[test] fn stale_recovery_receipt_cannot_signal_redispatch() { let (mut lifecycle, receipt) = accepted_receipt(); From 894084118f6f457e0781936681ac9b17e8a2aff5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:15:30 -0700 Subject: [PATCH 11/20] fix(bap): keep recovery validation read-only --- crates/originweave-bap/src/public_api.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/originweave-bap/src/public_api.rs b/crates/originweave-bap/src/public_api.rs index 9a470650..2240b2b1 100644 --- a/crates/originweave-bap/src/public_api.rs +++ b/crates/originweave-bap/src/public_api.rs @@ -106,17 +106,18 @@ impl BapCommandRecovery { /// transition before a confirmed absence of the external side effect can produce `true`. /// Stale, foreign, state-only restored, or divergent lifecycle history therefore fails /// closed with the underlying typed receipt error instead of emitting a redispatch signal. - /// The supplied lifecycle is not mutated when an existing receipt is validated. + /// Validation requires only read access to the lifecycle and cannot mutate an already accepted + /// transition or consume mutable execution authority. /// /// `Ok(true)` is still not authorization to redispatch. The caller must separately /// authenticate recovery evidence and revalidate tenant, policy, destination, secret, /// browser, and any other current authority before dispatching the command again. pub fn permits_redispatch( &self, - lifecycle: &mut BapTaskLifecycle, + lifecycle: &BapTaskLifecycle, ) -> Result { - lifecycle.apply_or_replay( - Some(&self.receipt), + lifecycle.validate_replay( + &self.receipt, self.receipt.idempotency_key(), self.receipt.tenant_id(), self.receipt.task_id(), From 2eb1e12b21a1123a46cabfaf2a0c39b38019e351 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:18:27 -0700 Subject: [PATCH 12/20] test(bap): use read-only recovery validation --- .../tests/recovery_outcome_classification.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-bap/tests/recovery_outcome_classification.rs b/crates/originweave-bap/tests/recovery_outcome_classification.rs index 3b5e6b20..a0a4d203 100644 --- a/crates/originweave-bap/tests/recovery_outcome_classification.rs +++ b/crates/originweave-bap/tests/recovery_outcome_classification.rs @@ -40,12 +40,12 @@ fn crash_recovery_distinguishes_external_side_effect_outcomes_without_unsafe_rep ]; for (outcome, expected_action, expected_redispatch) in cases { - let (mut lifecycle, receipt) = accepted_receipt(); + let (lifecycle, receipt) = accepted_receipt(); let recovery = BapCommandRecovery::new(receipt, outcome); assert_eq!(recovery.external_outcome(), outcome); assert_eq!(recovery.required_action(), expected_action); assert_eq!( - recovery.permits_redispatch(&mut lifecycle), + recovery.permits_redispatch(&lifecycle), Ok(expected_redispatch) ); assert_eq!(lifecycle.state(), BapTaskState::Admitted); @@ -80,7 +80,7 @@ fn stale_recovery_receipt_cannot_signal_redispatch() { assert!(advance.is_ok(), "{advance:?}"); assert_eq!( - recovery.permits_redispatch(&mut lifecycle), + recovery.permits_redispatch(&lifecycle), Err(BapCommandReceiptError::ReplayStateMismatch) ); assert_eq!(lifecycle.state(), BapTaskState::Running); From babe479efd06de7db50830012353690904a90af9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:03:28 -0700 Subject: [PATCH 13/20] test(bap): deny crash redispatch for terminal tasks --- .../tests/recovery_outcome_classification.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/originweave-bap/tests/recovery_outcome_classification.rs b/crates/originweave-bap/tests/recovery_outcome_classification.rs index a0a4d203..c90066e6 100644 --- a/crates/originweave-bap/tests/recovery_outcome_classification.rs +++ b/crates/originweave-bap/tests/recovery_outcome_classification.rs @@ -86,3 +86,38 @@ fn stale_recovery_receipt_cannot_signal_redispatch() { assert_eq!(lifecycle.state(), BapTaskState::Running); assert_eq!(lifecycle.transition_sequence(), 2); } + +#[test] +fn terminal_lifecycle_never_signals_redispatch_even_for_confirmed_no_side_effect() { + for terminal_event in [ + BapTaskEvent::Succeed, + BapTaskEvent::Fail, + BapTaskEvent::Cancel, + BapTaskEvent::Expire, + BapTaskEvent::DeadLetter, + ] { + let mut lifecycle = BapTaskLifecycle::new(); + let admit = lifecycle.apply(BapTaskEvent::Admit); + assert!(admit.is_ok(), "{admit:?}"); + let start = lifecycle.apply(BapTaskEvent::Start); + assert!(start.is_ok(), "{start:?}"); + + let receipt = lifecycle.apply_with_receipt( + "terminal-retry-key", + "tenant-a", + "task-a", + terminal_event, + ); + assert!(receipt.is_ok(), "{receipt:?}"); + let Ok(receipt) = receipt else { + unreachable!("asserted valid terminal command receipt") + }; + assert!(lifecycle.state().is_terminal()); + + let recovery = BapCommandRecovery::new( + receipt, + BapExternalSideEffectOutcome::ConfirmedNoSideEffect, + ); + assert_eq!(recovery.permits_redispatch(&lifecycle), Ok(false)); + } +} From 573718b00077b1dc02c29770499186395b032127 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:05:37 -0700 Subject: [PATCH 14/20] style(bap): format terminal recovery regression --- .../tests/recovery_outcome_classification.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/originweave-bap/tests/recovery_outcome_classification.rs b/crates/originweave-bap/tests/recovery_outcome_classification.rs index c90066e6..21e58a37 100644 --- a/crates/originweave-bap/tests/recovery_outcome_classification.rs +++ b/crates/originweave-bap/tests/recovery_outcome_classification.rs @@ -114,10 +114,8 @@ fn terminal_lifecycle_never_signals_redispatch_even_for_confirmed_no_side_effect }; assert!(lifecycle.state().is_terminal()); - let recovery = BapCommandRecovery::new( - receipt, - BapExternalSideEffectOutcome::ConfirmedNoSideEffect, - ); + let recovery = + BapCommandRecovery::new(receipt, BapExternalSideEffectOutcome::ConfirmedNoSideEffect); assert_eq!(recovery.permits_redispatch(&lifecycle), Ok(false)); } } From db9b3bb59fb672c9ad3bdbae0ad93f3803a95544 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:07:34 -0700 Subject: [PATCH 15/20] fix(bap): forbid terminal crash redispatch signals --- crates/originweave-bap/src/public_api.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/originweave-bap/src/public_api.rs b/crates/originweave-bap/src/public_api.rs index 2240b2b1..b44a41b5 100644 --- a/crates/originweave-bap/src/public_api.rs +++ b/crates/originweave-bap/src/public_api.rs @@ -106,6 +106,8 @@ impl BapCommandRecovery { /// transition before a confirmed absence of the external side effect can produce `true`. /// Stale, foreign, state-only restored, or divergent lifecycle history therefore fails /// closed with the underlying typed receipt error instead of emitting a redispatch signal. + /// An exact receipt for a terminal lifecycle also returns `Ok(false)` because a completed, + /// failed, cancelled, expired, or dead-lettered task cannot resume command dispatch. /// Validation requires only read access to the lifecycle and cannot mutate an already accepted /// transition or consume mutable execution authority. /// @@ -123,6 +125,9 @@ impl BapCommandRecovery { self.receipt.task_id(), self.receipt.event(), )?; + if lifecycle.state().is_terminal() { + return Ok(false); + } Ok(matches!( self.required_action(), BapRecoveryAction::RevalidateBeforeRedispatch From 0280cfe5b5d4fb98e846fc0552bd6d0a70d7e778 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 19:06:56 -0700 Subject: [PATCH 16/20] test(bap): require recovery evidence digest binding --- .../tests/recovery_outcome_classification.rs | 68 ++++++++++++++++--- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/crates/originweave-bap/tests/recovery_outcome_classification.rs b/crates/originweave-bap/tests/recovery_outcome_classification.rs index 21e58a37..acf10ab2 100644 --- a/crates/originweave-bap/tests/recovery_outcome_classification.rs +++ b/crates/originweave-bap/tests/recovery_outcome_classification.rs @@ -1,8 +1,12 @@ use originweave_bap::{ BapCommandReceipt, BapCommandReceiptError, BapCommandRecovery, BapExternalSideEffectOutcome, - BapRecoveryAction, BapTaskEvent, BapTaskLifecycle, BapTaskState, + BapRecoveryAction, BapRecoveryEvidenceDigest, BapRecoveryEvidenceDigestError, BapTaskEvent, + BapTaskLifecycle, BapTaskState, }; +const RECOVERY_EVIDENCE_DIGEST: &str = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + fn accepted_receipt() -> (BapTaskLifecycle, BapCommandReceipt) { let mut lifecycle = BapTaskLifecycle::new(); let receipt = @@ -14,6 +18,15 @@ fn accepted_receipt() -> (BapTaskLifecycle, BapCommandReceipt) { (lifecycle, receipt) } +fn recovery_evidence_digest() -> BapRecoveryEvidenceDigest { + let digest = BapRecoveryEvidenceDigest::parse(RECOVERY_EVIDENCE_DIGEST); + assert!(digest.is_ok(), "{digest:?}"); + let Ok(digest) = digest else { + unreachable!("asserted valid recovery evidence digest") + }; + digest +} + #[test] fn crash_recovery_distinguishes_external_side_effect_outcomes_without_unsafe_replay() { let cases = [ @@ -41,7 +54,7 @@ fn crash_recovery_distinguishes_external_side_effect_outcomes_without_unsafe_rep for (outcome, expected_action, expected_redispatch) in cases { let (lifecycle, receipt) = accepted_receipt(); - let recovery = BapCommandRecovery::new(receipt, outcome); + let recovery = BapCommandRecovery::new(receipt, outcome, recovery_evidence_digest()); assert_eq!(recovery.external_outcome(), outcome); assert_eq!(recovery.required_action(), expected_action); assert_eq!( @@ -51,6 +64,10 @@ fn crash_recovery_distinguishes_external_side_effect_outcomes_without_unsafe_rep assert_eq!(lifecycle.state(), BapTaskState::Admitted); assert_eq!(lifecycle.transition_sequence(), 1); assert_eq!(recovery.receipt().task_id(), "task-a"); + assert_eq!( + recovery.evidence_digest().as_str(), + RECOVERY_EVIDENCE_DIGEST + ); let debug = format!("{recovery:?}"); assert!(!debug.contains("retry-key")); @@ -59,11 +76,40 @@ fn crash_recovery_distinguishes_external_side_effect_outcomes_without_unsafe_rep } } +#[test] +fn recovery_evidence_digest_requires_exact_lowercase_sha256_identity() { + let valid = recovery_evidence_digest(); + assert_eq!(valid.as_str(), RECOVERY_EVIDENCE_DIGEST); + + for invalid in [ + "", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "sha256_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sha512:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ] { + assert_eq!( + BapRecoveryEvidenceDigest::parse(invalid), + Err(BapRecoveryEvidenceDigestError::InvalidFormat) + ); + } + + let error = BapRecoveryEvidenceDigestError::InvalidFormat; + assert_eq!( + error.to_string(), + "recovery evidence digest must be sha256: followed by 64 lowercase hexadecimal digits" + ); + assert!(std::error::Error::source(&error).is_none()); +} + #[test] fn recovery_validation_requires_only_read_only_lifecycle_access() { let (lifecycle, receipt) = accepted_receipt(); - let recovery = - BapCommandRecovery::new(receipt, BapExternalSideEffectOutcome::ConfirmedNoSideEffect); + let recovery = BapCommandRecovery::new( + receipt, + BapExternalSideEffectOutcome::ConfirmedNoSideEffect, + recovery_evidence_digest(), + ); assert_eq!(recovery.permits_redispatch(&lifecycle), Ok(true)); assert_eq!(lifecycle.state(), BapTaskState::Admitted); @@ -73,8 +119,11 @@ fn recovery_validation_requires_only_read_only_lifecycle_access() { #[test] fn stale_recovery_receipt_cannot_signal_redispatch() { let (mut lifecycle, receipt) = accepted_receipt(); - let recovery = - BapCommandRecovery::new(receipt, BapExternalSideEffectOutcome::ConfirmedNoSideEffect); + let recovery = BapCommandRecovery::new( + receipt, + BapExternalSideEffectOutcome::ConfirmedNoSideEffect, + recovery_evidence_digest(), + ); let advance = lifecycle.apply(BapTaskEvent::Start); assert!(advance.is_ok(), "{advance:?}"); @@ -114,8 +163,11 @@ fn terminal_lifecycle_never_signals_redispatch_even_for_confirmed_no_side_effect }; assert!(lifecycle.state().is_terminal()); - let recovery = - BapCommandRecovery::new(receipt, BapExternalSideEffectOutcome::ConfirmedNoSideEffect); + let recovery = BapCommandRecovery::new( + receipt, + BapExternalSideEffectOutcome::ConfirmedNoSideEffect, + recovery_evidence_digest(), + ); assert_eq!(recovery.permits_redispatch(&lifecycle), Ok(false)); } } From db2127a26793a4aca2fb4180b9ae3ddd7014f22e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 19:10:03 -0700 Subject: [PATCH 17/20] fix(bap): bind recovery classification to evidence digest --- crates/originweave-bap/src/public_api.rs | 81 +++++++++++++++++++++--- 1 file changed, 72 insertions(+), 9 deletions(-) diff --git a/crates/originweave-bap/src/public_api.rs b/crates/originweave-bap/src/public_api.rs index b44a41b5..f656f859 100644 --- a/crates/originweave-bap/src/public_api.rs +++ b/crates/originweave-bap/src/public_api.rs @@ -56,29 +56,85 @@ impl BapExternalSideEffectOutcome { } } -/// Receipt-bound crash-recovery classification for one accepted BAP command. +/// Validation failure for one crash-recovery evidence digest identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapRecoveryEvidenceDigestError { + /// The digest was not canonical lowercase SHA-256 identity evidence. + InvalidFormat, +} + +impl std::fmt::Display for BapRecoveryEvidenceDigestError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidFormat => formatter.write_str( + "recovery evidence digest must be sha256: followed by 64 lowercase hexadecimal digits", + ), + } + } +} + +impl std::error::Error for BapRecoveryEvidenceDigestError {} + +/// Canonical SHA-256 identity for durable crash-recovery evidence. /// -/// Binding the external outcome to the immutable command receipt prevents a -/// recovery classification from floating free of the retry namespace, task, -/// lifecycle event, and accepted transition. Construction does not authenticate -/// the classification or grant authority; callers must validate the external -/// evidence at their durable trust boundary. +/// The digest identifies the exact evidence object a durable recovery boundary must authenticate +/// before relying on an external side-effect classification. Possession of this identity does not +/// authenticate the evidence, prove the classified outcome, or grant retry, browser, network, +/// secret, approval, or storage authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BapRecoveryEvidenceDigest(String); + +impl BapRecoveryEvidenceDigest { + /// Parse one exact `sha256:` identity with 64 lowercase hexadecimal digits. + pub fn parse(value: &str) -> Result { + let Some(hex_digest) = value.strip_prefix("sha256:") else { + return Err(BapRecoveryEvidenceDigestError::InvalidFormat); + }; + if hex_digest.len() != 64 { + return Err(BapRecoveryEvidenceDigestError::InvalidFormat); + } + if hex_digest + .bytes() + .any(|byte| !matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + return Err(BapRecoveryEvidenceDigestError::InvalidFormat); + } + Ok(Self(value.to_owned())) + } + + /// Return the canonical lowercase SHA-256 identity. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Receipt- and evidence-bound crash-recovery classification for one accepted BAP command. +/// +/// Binding the external outcome to both the immutable command receipt and exact recovery-evidence +/// digest prevents a recovery classification from floating free of the retry namespace, task, +/// lifecycle event, accepted transition, or the durable evidence object that supports the outcome. +/// Construction does not authenticate the classification or evidence and grants no authority; +/// callers must validate the evidence at their durable trust boundary. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BapCommandRecovery { receipt: BapCommandReceipt, external_outcome: BapExternalSideEffectOutcome, + evidence_digest: BapRecoveryEvidenceDigest, } impl BapCommandRecovery { - /// Bind one caller-supplied external outcome classification to an accepted command receipt. + /// Bind one external outcome classification and evidence identity to an accepted command receipt. #[must_use] pub const fn new( receipt: BapCommandReceipt, external_outcome: BapExternalSideEffectOutcome, + evidence_digest: BapRecoveryEvidenceDigest, ) -> Self { Self { receipt, external_outcome, + evidence_digest, } } @@ -94,6 +150,12 @@ impl BapCommandRecovery { self.external_outcome } + /// Return the exact recovery-evidence digest bound to this classification. + #[must_use] + pub const fn evidence_digest(&self) -> &BapRecoveryEvidenceDigest { + &self.evidence_digest + } + /// Return the minimum fail-closed handling required by the external outcome. #[must_use] pub const fn required_action(&self) -> BapRecoveryAction { @@ -112,8 +174,9 @@ impl BapCommandRecovery { /// transition or consume mutable execution authority. /// /// `Ok(true)` is still not authorization to redispatch. The caller must separately - /// authenticate recovery evidence and revalidate tenant, policy, destination, secret, - /// browser, and any other current authority before dispatching the command again. + /// authenticate the exact recovery evidence identified by [`Self::evidence_digest`] and + /// revalidate tenant, policy, destination, secret, browser, and any other current authority + /// before dispatching the command again. pub fn permits_redispatch( &self, lifecycle: &BapTaskLifecycle, From f79999681866ecf0e5fe17d895170f3f6cae7361 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 19:13:11 -0700 Subject: [PATCH 18/20] docs(bap): record recovery evidence identity binding --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a1d9bd..51b67fd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - 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. -- Crash-recovery redispatch classification now validates its retained BAP command receipt against the lifecycle's exact most recently accepted transition before signaling that a confirmed-no-side-effect command may be reconsidered; stale or divergent recovery evidence fails closed with the typed receipt error and never becomes redispatch authorization. +- Crash-recovery redispatch classification now binds the exact accepted BAP command receipt and a canonical lowercase SHA-256 recovery-evidence identity, validates the receipt against the lifecycle's exact most recently accepted transition, and keeps the digest as identity rather than authentication or retry authority; malformed evidence identities and stale or divergent receipt state fail closed. - 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 be3186e1a016275fbf67c7a3da278b11d73a6b87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 16:07:38 -0700 Subject: [PATCH 19/20] test(bap): reject redispatch while reconciliation-held --- .../tests/recovery_outcome_classification.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/originweave-bap/tests/recovery_outcome_classification.rs b/crates/originweave-bap/tests/recovery_outcome_classification.rs index acf10ab2..98480b6e 100644 --- a/crates/originweave-bap/tests/recovery_outcome_classification.rs +++ b/crates/originweave-bap/tests/recovery_outcome_classification.rs @@ -136,6 +136,37 @@ fn stale_recovery_receipt_cannot_signal_redispatch() { assert_eq!(lifecycle.transition_sequence(), 2); } +#[test] +fn reconciliation_hold_never_signals_redispatch_before_explicit_resolution() { + let mut lifecycle = BapTaskLifecycle::new(); + let admit = lifecycle.apply(BapTaskEvent::Admit); + assert!(admit.is_ok(), "{admit:?}"); + let start = lifecycle.apply(BapTaskEvent::Start); + assert!(start.is_ok(), "{start:?}"); + + let receipt = lifecycle.apply_with_receipt( + "reconcile-retry-key", + "tenant-a", + "task-a", + BapTaskEvent::RequireReconciliation, + ); + assert!(receipt.is_ok(), "{receipt:?}"); + let Ok(receipt) = receipt else { + unreachable!("asserted valid reconciliation command receipt") + }; + assert_eq!(lifecycle.state(), BapTaskState::ReconciliationRequired); + assert_eq!(lifecycle.transition_sequence(), 3); + + let recovery = BapCommandRecovery::new( + receipt, + BapExternalSideEffectOutcome::ConfirmedNoSideEffect, + recovery_evidence_digest(), + ); + assert_eq!(recovery.permits_redispatch(&lifecycle), Ok(false)); + assert_eq!(lifecycle.state(), BapTaskState::ReconciliationRequired); + assert_eq!(lifecycle.transition_sequence(), 3); +} + #[test] fn terminal_lifecycle_never_signals_redispatch_even_for_confirmed_no_side_effect() { for terminal_event in [ From 8a3463bdb224f1c1226811f88c3d6f3a15d96f8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 16:11:10 -0700 Subject: [PATCH 20/20] fix(bap): keep reconciliation state non-replayable --- crates/originweave-bap/src/public_api.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/originweave-bap/src/public_api.rs b/crates/originweave-bap/src/public_api.rs index f656f859..f80350e3 100644 --- a/crates/originweave-bap/src/public_api.rs +++ b/crates/originweave-bap/src/public_api.rs @@ -169,7 +169,11 @@ impl BapCommandRecovery { /// Stale, foreign, state-only restored, or divergent lifecycle history therefore fails /// closed with the underlying typed receipt error instead of emitting a redispatch signal. /// An exact receipt for a terminal lifecycle also returns `Ok(false)` because a completed, - /// failed, cancelled, expired, or dead-lettered task cannot resume command dispatch. + /// failed, cancelled, expired, or dead-lettered task cannot resume command dispatch. An exact + /// receipt for `ReconciliationRequired` likewise returns `Ok(false)`: an explicit reconciliation + /// hold cannot be bypassed merely because later recovery evidence classifies the interrupted + /// external operation as having caused no side effect. Resolving that hold is a separate + /// lifecycle transition, which also makes this retained receipt stale for subsequent replay. /// Validation requires only read access to the lifecycle and cannot mutate an already accepted /// transition or consume mutable execution authority. /// @@ -188,7 +192,9 @@ impl BapCommandRecovery { self.receipt.task_id(), self.receipt.event(), )?; - if lifecycle.state().is_terminal() { + if lifecycle.state().is_terminal() + || lifecycle.state() == BapTaskState::ReconciliationRequired + { return Ok(false); } Ok(matches!(