diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd344946..ef0b82831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,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 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. - Aligned the hourly product-development branch-coverage toolchain and its one-shot materializer with the reviewed `nightly-2026-08-18` pin, and corrected the official Dependabot Rust-toolchain reference. - Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result. - Separated resolved-address authorization from direct transport evidence; an approved IP now becomes a usable stream only after the operating system reports the exact requested IP and port. diff --git a/crates/originweave-bap/Cargo.toml b/crates/originweave-bap/Cargo.toml index 39e8e38f7..5fcbf5dd2 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 diff --git a/crates/originweave-bap/src/public_api.rs b/crates/originweave-bap/src/public_api.rs new file mode 100644 index 000000000..f80350e33 --- /dev/null +++ b/crates/originweave-bap/src/public_api.rs @@ -0,0 +1,205 @@ +//! 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 + } + } + } +} + +/// 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. +/// +/// 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 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, + } + } + + /// 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 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 { + self.external_outcome.required_action() + } + + /// Return whether redispatch may be considered for the current exact lifecycle state. + /// + /// 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. + /// 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. 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. + /// + /// `Ok(true)` is still not authorization to redispatch. The caller must separately + /// 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, + ) -> Result { + lifecycle.validate_replay( + &self.receipt, + self.receipt.idempotency_key(), + self.receipt.tenant_id(), + self.receipt.task_id(), + self.receipt.event(), + )?; + if lifecycle.state().is_terminal() + || lifecycle.state() == BapTaskState::ReconciliationRequired + { + return Ok(false); + } + Ok(matches!( + self.required_action(), + BapRecoveryAction::RevalidateBeforeRedispatch + )) + } +} 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 000000000..98480b6e9 --- /dev/null +++ b/crates/originweave-bap/tests/recovery_outcome_classification.rs @@ -0,0 +1,204 @@ +use originweave_bap::{ + BapCommandReceipt, BapCommandReceiptError, BapCommandRecovery, BapExternalSideEffectOutcome, + 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 = + 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") + }; + (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 = [ + ( + 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 (lifecycle, receipt) = accepted_receipt(); + let recovery = BapCommandRecovery::new(receipt, outcome, recovery_evidence_digest()); + assert_eq!(recovery.external_outcome(), outcome); + assert_eq!(recovery.required_action(), expected_action); + assert_eq!( + recovery.permits_redispatch(&lifecycle), + Ok(expected_redispatch) + ); + 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")); + assert!(!debug.contains("tenant-a")); + assert!(!debug.contains("task-a")); + } +} + +#[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, + recovery_evidence_digest(), + ); + + 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(); + let recovery = BapCommandRecovery::new( + receipt, + BapExternalSideEffectOutcome::ConfirmedNoSideEffect, + recovery_evidence_digest(), + ); + + let advance = lifecycle.apply(BapTaskEvent::Start); + assert!(advance.is_ok(), "{advance:?}"); + + assert_eq!( + recovery.permits_redispatch(&lifecycle), + Err(BapCommandReceiptError::ReplayStateMismatch) + ); + assert_eq!(lifecycle.state(), BapTaskState::Running); + 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 [ + 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, + recovery_evidence_digest(), + ); + assert_eq!(recovery.permits_redispatch(&lifecycle), Ok(false)); + } +}