diff --git a/CHANGELOG.md b/CHANGELOG.md index 116c02fe6..7f7bda991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. +- Resumable BAP lifecycle restoration with monotonic sequence recovery and fail-closed sequence exhaustion. - Authoritative product documentation graph spanning PRD, TRD, ADR lifecycle/index, product-wide UML, conceptual ERD, requirement/decision traceability, threat modeling, product-wide test strategy, operability, API/protocol, release/rollback, and current primary-source standards doctoring, with machine-checkable repository contracts that keep conversation-derived future work distinct from protected-main implementation claims. - Purpose-bound data-governance and privacy baseline that rejects both blanket masking and ambient raw-value propagation, defines field-scoped just-in-time disclosure, opaque-handle/trusted-broker boundaries, model/provider/region policy, retention/deletion/residency/break-glass controls, truthful CSAP/SOC 2 readiness language, and machine-checkable documentation contracts without inventing an OriginWeave-owned production database. - Proposed product-wide target-architecture ADRs for the Rust control plane, isolated execution modes, typed actions, semantic observation/stale-node authority, prompt-injection and secret separation, resource-governor priority, provenance evidence, browser/protocol adapters, crawler policy, and hourly automation operational closure; these remain Proposed rather than shipped claims until protected review and merge. diff --git a/Cargo.lock b/Cargo.lock index e2ada3c4e..1ffe5d1ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,6 +263,10 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "originweave-bap" +version = "0.1.0" + [[package]] name = "originweave-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index fc723f3a4..0d5ab469c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/originweave-core", + "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-resource", "crates/originweave-evidence", diff --git a/crates/originweave-bap/Cargo.toml b/crates/originweave-bap/Cargo.toml new file mode 100644 index 000000000..39e8e38f7 --- /dev/null +++ b/crates/originweave-bap/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "originweave-bap" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[lints] +workspace = true diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs new file mode 100644 index 000000000..404a88c10 --- /dev/null +++ b/crates/originweave-bap/src/lib.rs @@ -0,0 +1,329 @@ +//! Stable internal Browser Agent Protocol lifecycle contracts. +//! +//! This crate intentionally owns no transport, browser, network, model, secret, +//! approval, or persistence authority. External protocol adapters may project +//! these states, but protocol metadata cannot mint or change OriginWeave task +//! authority. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +/// Durable logical state of one governed BAP task. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskState { + /// The task record exists but has not entered admission control. + Created, + /// Admission control accepted the task but execution has not started. + Admitted, + /// The task is actively executing governed work. + Running, + /// Execution is suspended until an approval decision is available. + WaitingForApproval, + /// Execution is suspended until required external input is available. + WaitingForExternalInput, + /// Execution is suspended at a compatible recoverable checkpoint. + Checkpointed, + /// Execution is suspended until an explicit reconciliation decision is recorded. + /// + /// The lifecycle state does not itself persist or authenticate reconciliation + /// evidence. A durable owner must preserve the complete evidence that caused + /// the task to enter this state before resolution is considered. + ReconciliationRequired, + /// The declared post-condition completed successfully. + Succeeded, + /// The task reached a terminal execution failure. + Failed, + /// Cancellation completed and the task cannot resume. + Cancelled, + /// The task exceeded its allowed lifetime and cannot resume. + Expired, + /// The task was terminally removed from automatic execution after governed handling. + /// + /// Durable dead-letter evidence remains the responsibility of the persistence + /// boundary; this in-memory marker must not be treated as the evidence itself. + DeadLettered, +} + +impl BapTaskState { + /// Return whether this state is final and must never transition again. + #[must_use] + pub const fn is_terminal(self) -> bool { + matches!( + self, + Self::Succeeded | Self::Failed | Self::Cancelled | Self::Expired | Self::DeadLettered + ) + } +} + +/// One requested task-lifecycle event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskEvent { + /// Admit a newly created task. + Admit, + /// Start an admitted task. + Start, + /// Suspend a running task until approval is available. + WaitForApproval, + /// Suspend a running task until external input is available. + WaitForExternalInput, + /// Suspend a running task at a recoverable checkpoint. + Checkpoint, + /// Resume a normal suspended task into governed execution. + Resume, + /// Suspend a running task because its external outcome requires reconciliation. + RequireReconciliation, + /// Explicitly resolve a reconciliation hold and return the task to governed execution. + ResolveReconciliation, + /// Terminally remove a running or reconciliation-held task from automatic execution. + DeadLetter, + /// Record successful completion after the declared post-condition is verified. + Succeed, + /// Record terminal task failure. + Fail, + /// Record terminal cancellation. + Cancel, + /// Record terminal expiry. + Expire, +} + +/// A fail-closed lifecycle transition failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskTransitionError { + /// The requested event is not valid from the current non-terminal state. + InvalidTransition { + /// Current state that rejected the event. + from: BapTaskState, + /// Event that was rejected. + event: BapTaskEvent, + }, + /// The lifecycle sequence reached its maximum representable value. + SequenceExhausted, + /// A terminal task cannot be reopened or mutated by lifecycle events. + TerminalState { + /// Final state that rejected all further events. + state: BapTaskState, + }, +} + +impl std::fmt::Display for BapTaskTransitionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidTransition { from, event } => { + write!( + formatter, + "BAP task event {event:?} is invalid from state {from:?}" + ) + } + Self::SequenceExhausted => { + write!(formatter, "BAP task transition sequence is exhausted") + } + Self::TerminalState { state } => { + write!(formatter, "BAP task state {state:?} is terminal") + } + } + } +} + +impl std::error::Error for BapTaskTransitionError {} + +/// A fail-closed lifecycle recovery failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskRestoreError { + /// The supplied state and transition sequence cannot arise from this state machine. + InvalidSnapshot { + /// Logical state supplied by the durable recovery boundary. + state: BapTaskState, + /// Last accepted transition sequence supplied by the durable recovery boundary. + transition_sequence: u64, + }, +} + +impl std::fmt::Display for BapTaskRestoreError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidSnapshot { + state, + transition_sequence, + } => write!( + formatter, + "BAP task snapshot state {state:?} with transition sequence {transition_sequence} is unreachable" + ), + } + } +} + +impl std::error::Error for BapTaskRestoreError {} + +/// Immutable receipt for one accepted in-memory lifecycle transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BapTaskTransition { + previous_state: BapTaskState, + current_state: BapTaskState, + sequence: u64, +} + +impl BapTaskTransition { + /// Return the state before the accepted transition. + #[must_use] + pub const fn previous_state(self) -> BapTaskState { + self.previous_state + } + + /// Return the state after the accepted transition. + #[must_use] + pub const fn current_state(self) -> BapTaskState { + self.current_state + } + + /// Return the monotonic transition sequence for this lifecycle instance. + #[must_use] + pub const fn sequence(self) -> u64 { + self.sequence + } +} + +/// Deterministic fail-closed BAP task-lifecycle kernel. +/// +/// This value is intentionally an in-memory state-transition primitive. A +/// durable repository must persist accepted transitions and impose its own +/// bounded sequence/retention contract before commercial task recovery can be +/// claimed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BapTaskLifecycle { + state: BapTaskState, + transition_sequence: u64, +} + +impl Default for BapTaskLifecycle { + fn default() -> Self { + Self::new() + } +} + +impl BapTaskLifecycle { + /// Create one lifecycle in the `created` state with no accepted transitions. + #[must_use] + pub const fn new() -> Self { + Self { + state: BapTaskState::Created, + transition_sequence: 0, + } + } + + /// Restore a lifecycle state and its last accepted transition sequence. + /// + /// 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. + pub const fn restore( + state: BapTaskState, + transition_sequence: u64, + ) -> Result { + if !reachable_snapshot(state, transition_sequence) { + return Err(BapTaskRestoreError::InvalidSnapshot { + state, + transition_sequence, + }); + } + Ok(Self { + state, + transition_sequence, + }) + } + + /// Return the current logical task state. + #[must_use] + pub const fn state(self) -> BapTaskState { + self.state + } + + /// Return the number of accepted lifecycle transitions. + #[must_use] + pub const fn transition_sequence(self) -> u64 { + self.transition_sequence + } + + /// Apply one reviewed lifecycle event without granting execution authority. + /// + /// Rejected events leave both state and sequence unchanged. Terminal states + /// reject every later event before evaluating any normal transition rule. + /// Reconciliation cannot use the generic `Resume` event: it requires the + /// explicit `ResolveReconciliation` event so ambiguous external outcomes + /// cannot silently re-enter execution. + pub fn apply( + &mut self, + event: BapTaskEvent, + ) -> Result { + if self.state.is_terminal() { + return Err(BapTaskTransitionError::TerminalState { state: self.state }); + } + + let next_state = match (self.state, event) { + (BapTaskState::Created, BapTaskEvent::Admit) => BapTaskState::Admitted, + (BapTaskState::Admitted, BapTaskEvent::Start) => BapTaskState::Running, + (BapTaskState::Running, BapTaskEvent::WaitForApproval) => { + BapTaskState::WaitingForApproval + } + (BapTaskState::Running, BapTaskEvent::WaitForExternalInput) => { + BapTaskState::WaitingForExternalInput + } + (BapTaskState::Running, BapTaskEvent::Checkpoint) => BapTaskState::Checkpointed, + ( + BapTaskState::WaitingForApproval + | BapTaskState::WaitingForExternalInput + | BapTaskState::Checkpointed, + BapTaskEvent::Resume, + ) => BapTaskState::Running, + (BapTaskState::Running, BapTaskEvent::RequireReconciliation) => { + BapTaskState::ReconciliationRequired + } + (BapTaskState::ReconciliationRequired, BapTaskEvent::ResolveReconciliation) => { + BapTaskState::Running + } + ( + BapTaskState::Running | BapTaskState::ReconciliationRequired, + BapTaskEvent::DeadLetter, + ) => BapTaskState::DeadLettered, + (BapTaskState::Running, BapTaskEvent::Succeed) => BapTaskState::Succeeded, + (_, BapTaskEvent::Fail) => BapTaskState::Failed, + (_, BapTaskEvent::Cancel) => BapTaskState::Cancelled, + (_, BapTaskEvent::Expire) => BapTaskState::Expired, + (from, event) => { + return Err(BapTaskTransitionError::InvalidTransition { from, event }); + } + }; + + let Some(sequence) = self.transition_sequence.checked_add(1) else { + return Err(BapTaskTransitionError::SequenceExhausted); + }; + let previous_state = self.state; + self.state = next_state; + self.transition_sequence = sequence; + Ok(BapTaskTransition { + previous_state, + current_state: next_state, + sequence, + }) + } +} + +const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bool { + match state { + BapTaskState::Created => transition_sequence == 0, + BapTaskState::Admitted => transition_sequence == 1, + BapTaskState::Running => transition_sequence >= 2 && transition_sequence.is_multiple_of(2), + BapTaskState::WaitingForApproval + | BapTaskState::WaitingForExternalInput + | BapTaskState::Checkpointed + | BapTaskState::ReconciliationRequired => { + transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) + } + BapTaskState::Succeeded => { + transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) + } + BapTaskState::Failed | BapTaskState::Cancelled | BapTaskState::Expired => { + transition_sequence >= 1 + } + BapTaskState::DeadLettered => transition_sequence >= 3, + } +} diff --git a/crates/originweave-bap/tests/task_lifecycle.rs b/crates/originweave-bap/tests/task_lifecycle.rs new file mode 100644 index 000000000..01013682a --- /dev/null +++ b/crates/originweave-bap/tests/task_lifecycle.rs @@ -0,0 +1,253 @@ +#![allow(clippy::expect_used)] + +use originweave_bap::{BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError}; + +#[test] +fn default_starts_a_new_created_lifecycle() { + assert_eq!(BapTaskLifecycle::default(), BapTaskLifecycle::new()); +} + +#[test] +fn bap_task_lifecycle_follows_the_reviewed_resumable_path() { + let mut task = BapTaskLifecycle::new(); + assert_eq!(task.state(), BapTaskState::Created); + assert!(!task.state().is_terminal()); + assert_eq!(task.transition_sequence(), 0); + + let admitted = task.apply(BapTaskEvent::Admit).expect("admit"); + assert_eq!(admitted.previous_state(), BapTaskState::Created); + assert_eq!(admitted.current_state(), BapTaskState::Admitted); + assert_eq!(admitted.sequence(), 1); + + task.apply(BapTaskEvent::Start).expect("start"); + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait for approval"); + assert_eq!(task.state(), BapTaskState::WaitingForApproval); + + task.apply(BapTaskEvent::Resume).expect("resume approval"); + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + assert_eq!(task.state(), BapTaskState::Checkpointed); + + task.apply(BapTaskEvent::Resume).expect("resume checkpoint"); + let succeeded = task.apply(BapTaskEvent::Succeed).expect("succeed"); + assert_eq!(succeeded.current_state(), BapTaskState::Succeeded); + assert!(task.state().is_terminal()); + assert_eq!(task.transition_sequence(), 7); +} + +#[test] +fn waiting_for_external_input_can_resume_but_cannot_succeed_directly() { + let mut task = running_task(); + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait for input"); + + let error = task + .apply(BapTaskEvent::Succeed) + .expect_err("waiting task must not skip resume and post-condition work"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::WaitingForExternalInput, + event: BapTaskEvent::Succeed, + } + ); + assert_eq!(task.state(), BapTaskState::WaitingForExternalInput); + assert_eq!(task.transition_sequence(), 3); + + task.apply(BapTaskEvent::Resume).expect("resume input"); + assert_eq!(task.state(), BapTaskState::Running); +} + +#[test] +fn invalid_transition_is_fail_closed_and_does_not_advance_history() { + let mut task = BapTaskLifecycle::new(); + + let error = task + .apply(BapTaskEvent::Start) + .expect_err("created task must be admitted first"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::Start, + } + ); + assert_eq!(task.state(), BapTaskState::Created); + assert_eq!(task.transition_sequence(), 0); +} + +#[test] +fn terminal_task_never_reopens_or_advances_history() { + for terminal_event in [ + BapTaskEvent::Succeed, + BapTaskEvent::Fail, + BapTaskEvent::Cancel, + BapTaskEvent::Expire, + ] { + let mut task = if terminal_event == BapTaskEvent::Succeed { + running_task() + } else { + BapTaskLifecycle::new() + }; + task.apply(terminal_event).expect("enter terminal state"); + let terminal_state = task.state(); + let terminal_sequence = task.transition_sequence(); + + for later_event in [ + BapTaskEvent::Admit, + BapTaskEvent::Start, + BapTaskEvent::Resume, + BapTaskEvent::Cancel, + ] { + assert_eq!( + task.apply(later_event), + Err(BapTaskTransitionError::TerminalState { + state: terminal_state, + }) + ); + assert_eq!(task.state(), terminal_state); + assert_eq!(task.transition_sequence(), terminal_sequence); + } + } +} + +#[test] +fn cancellation_and_expiry_cover_pre_dispatch_and_suspended_states() { + for state in [ + BapTaskState::Created, + BapTaskState::Admitted, + BapTaskState::Running, + BapTaskState::WaitingForApproval, + BapTaskState::WaitingForExternalInput, + BapTaskState::Checkpointed, + BapTaskState::ReconciliationRequired, + ] { + for terminal_event in [BapTaskEvent::Cancel, BapTaskEvent::Expire] { + let mut task = task_in_state(state); + assert_eq!(task.state(), state); + task.apply(terminal_event).expect("terminal interruption"); + assert!(task.state().is_terminal()); + } + } +} + +#[test] +fn reconciliation_requires_explicit_resolution_and_dead_letter_is_terminal() { + let mut task = running_task(); + let required = task + .apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation"); + assert_eq!(required.previous_state(), BapTaskState::Running); + assert_eq!( + required.current_state(), + BapTaskState::ReconciliationRequired + ); + assert!(!task.state().is_terminal()); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::ReconciliationRequired, + event: BapTaskEvent::Resume, + }) + ); + assert_eq!( + task.apply(BapTaskEvent::Succeed), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::ReconciliationRequired, + event: BapTaskEvent::Succeed, + }) + ); + assert_eq!(task.transition_sequence(), 3); + + task.apply(BapTaskEvent::ResolveReconciliation) + .expect("resolve reconciliation"); + assert_eq!(task.state(), BapTaskState::Running); + + task.apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation again"); + let dead_lettered = task + .apply(BapTaskEvent::DeadLetter) + .expect("dead-letter unresolved task"); + assert_eq!(dead_lettered.current_state(), BapTaskState::DeadLettered); + assert!(task.state().is_terminal()); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::TerminalState { + state: BapTaskState::DeadLettered, + }) + ); +} + +#[test] +fn running_task_may_dead_letter_but_pre_dispatch_task_may_not() { + let mut running = running_task(); + let transition = running + .apply(BapTaskEvent::DeadLetter) + .expect("dead-letter running task"); + assert_eq!(transition.previous_state(), BapTaskState::Running); + assert_eq!(transition.current_state(), BapTaskState::DeadLettered); + assert_eq!(transition.sequence(), 3); + assert!(running.state().is_terminal()); + + let mut created = BapTaskLifecycle::new(); + assert_eq!( + created.apply(BapTaskEvent::DeadLetter), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::DeadLetter, + }) + ); + assert_eq!(created.state(), BapTaskState::Created); + assert_eq!(created.transition_sequence(), 0); +} + +fn running_task() -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + task.apply(BapTaskEvent::Admit).expect("admit"); + task.apply(BapTaskEvent::Start).expect("start"); + task +} + +fn task_in_state(target: BapTaskState) -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + if target == BapTaskState::Created { + return task; + } + + task.apply(BapTaskEvent::Admit).expect("admit"); + if target == BapTaskState::Admitted { + return task; + } + + task.apply(BapTaskEvent::Start).expect("start"); + match target { + BapTaskState::Running => {} + BapTaskState::WaitingForApproval => { + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait approval"); + } + BapTaskState::WaitingForExternalInput => { + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait external"); + } + BapTaskState::Checkpointed => { + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + } + BapTaskState::ReconciliationRequired => { + task.apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation"); + } + BapTaskState::Created + | BapTaskState::Admitted + | BapTaskState::Succeeded + | BapTaskState::Failed + | BapTaskState::Cancelled + | BapTaskState::Expired + | BapTaskState::DeadLettered => { + unreachable!("task_in_state only constructs non-terminal lifecycle states") + } + } + task +} diff --git a/crates/originweave-bap/tests/task_lifecycle_recovery.rs b/crates/originweave-bap/tests/task_lifecycle_recovery.rs new file mode 100644 index 000000000..67deae949 --- /dev/null +++ b/crates/originweave-bap/tests/task_lifecycle_recovery.rs @@ -0,0 +1,142 @@ +#![allow(clippy::expect_used)] + +use std::error::Error as _; + +use originweave_bap::{ + BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, BapTaskState, BapTaskTransitionError, +}; + +#[test] +fn restored_lifecycle_preserves_state_and_monotonic_sequence() { + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, 41) + .expect("valid checkpoint snapshot"); + + assert_eq!(task.state(), BapTaskState::Checkpointed); + assert_eq!(task.transition_sequence(), 41); + + let resumed = task + .apply(BapTaskEvent::Resume) + .expect("resume restored task"); + assert_eq!(resumed.previous_state(), BapTaskState::Checkpointed); + assert_eq!(resumed.current_state(), BapTaskState::Running); + assert_eq!(resumed.sequence(), 42); +} + +#[test] +fn impossible_restored_snapshots_fail_closed() { + for (state, sequence) in [ + (BapTaskState::Created, 1), + (BapTaskState::Admitted, 0), + (BapTaskState::Admitted, 2), + (BapTaskState::Running, 1), + (BapTaskState::Running, 3), + (BapTaskState::WaitingForApproval, 2), + (BapTaskState::WaitingForApproval, 4), + (BapTaskState::WaitingForExternalInput, 2), + (BapTaskState::WaitingForExternalInput, 4), + (BapTaskState::Checkpointed, 2), + (BapTaskState::Checkpointed, 4), + (BapTaskState::ReconciliationRequired, 2), + (BapTaskState::ReconciliationRequired, 4), + (BapTaskState::Succeeded, 2), + (BapTaskState::Succeeded, 4), + (BapTaskState::Failed, 0), + (BapTaskState::Cancelled, 0), + (BapTaskState::Expired, 0), + (BapTaskState::DeadLettered, 2), + ] { + assert_eq!( + BapTaskLifecycle::restore(state, sequence), + Err(BapTaskRestoreError::InvalidSnapshot { + state, + transition_sequence: sequence, + }), + "state={state:?}, sequence={sequence}", + ); + } +} + +#[test] +fn valid_restored_snapshot_classes_remain_accepted() { + for (state, sequence) in [ + (BapTaskState::Created, 0), + (BapTaskState::Admitted, 1), + (BapTaskState::Running, 2), + (BapTaskState::Running, 4), + (BapTaskState::WaitingForApproval, 3), + (BapTaskState::WaitingForExternalInput, 5), + (BapTaskState::Checkpointed, 7), + (BapTaskState::ReconciliationRequired, 3), + (BapTaskState::Succeeded, 3), + (BapTaskState::Failed, 1), + (BapTaskState::Cancelled, 2), + (BapTaskState::Expired, 4), + (BapTaskState::DeadLettered, 3), + (BapTaskState::DeadLettered, 4), + ] { + let task = BapTaskLifecycle::restore(state, sequence).expect("reachable snapshot"); + assert_eq!(task.state(), state); + assert_eq!(task.transition_sequence(), sequence); + } +} + +#[test] +fn exhausted_sequence_fails_closed_without_mutating_state() { + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, u64::MAX) + .expect("valid exhausted checkpoint snapshot"); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::SequenceExhausted), + ); + assert_eq!(task.state(), BapTaskState::Checkpointed); + assert_eq!(task.transition_sequence(), u64::MAX); +} + +#[test] +fn restored_terminal_lifecycle_remains_terminal() { + let mut task = + BapTaskLifecycle::restore(BapTaskState::Succeeded, 9).expect("valid terminal snapshot"); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::TerminalState { + state: BapTaskState::Succeeded, + }), + ); + assert_eq!(task.transition_sequence(), 9); +} + +#[test] +fn lifecycle_failures_use_the_standard_rust_error_contract() { + let mut created = BapTaskLifecycle::new(); + let invalid_transition = created + .apply(BapTaskEvent::Start) + .expect_err("created task must reject start"); + assert_eq!( + invalid_transition.to_string(), + "BAP task event Start is invalid from state Created" + ); + assert!(invalid_transition.source().is_none()); + + let exhausted = BapTaskTransitionError::SequenceExhausted; + assert_eq!( + exhausted.to_string(), + "BAP task transition sequence is exhausted" + ); + assert!(exhausted.source().is_none()); + + let terminal = BapTaskTransitionError::TerminalState { + state: BapTaskState::Cancelled, + }; + assert_eq!(terminal.to_string(), "BAP task state Cancelled is terminal"); + assert!(terminal.source().is_none()); + + let restore = BapTaskLifecycle::restore(BapTaskState::Created, 1) + .expect_err("unreachable snapshot must fail"); + assert_eq!( + restore.to_string(), + "BAP task snapshot state Created with transition sequence 1 is unreachable" + ); + assert!(restore.source().is_none()); +} diff --git a/docs/README.md b/docs/README.md index 775dd0de6..1ea57ad29 100644 --- a/docs/README.md +++ b/docs/README.md @@ -87,4 +87,12 @@ Proposed ADRs are reviewable architecture memory, not shipped behavior and not a The second group exists only on this documentation branch until the branch integrates. After integration, the heading remains useful historical provenance; it does not promote either ADR from Proposed to Accepted and it does not claim that the described runtime capability is implemented. +### Proposed decisions introduced by active feature work + +- [ADR 0016: BAP task lifecycle and state authority](adr/0016-bap-task-lifecycle-authority.md) + +ADR 0016 is owned by this active BAP lifecycle feature branch and remains Proposed. Its presence here makes the branch documentation graph complete without presenting the decision or implementation as protected-main truth before integration. + +After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016 from Proposed or assert implementation maturity. + See the [ADR index](adr/README.md) for status rules, required decision structure, supersession rules, and active feature ADRs. The index and each ADR's own status metadata must agree; a PR body, chat transcript, automation prompt, or stale issue reference cannot change ADR status. diff --git a/docs/adr/0016-bap-task-lifecycle-authority.md b/docs/adr/0016-bap-task-lifecycle-authority.md new file mode 100644 index 000000000..54fae8607 --- /dev/null +++ b/docs/adr/0016-bap-task-lifecycle-authority.md @@ -0,0 +1,123 @@ +# ADR 0016: BAP task lifecycle and state authority + +- **Status:** Proposed +- **Date:** 2026-08-22 +- **Supersedes:** None +- **Superseded by:** None + +## Context + +OriginWeave needs a deterministic lifecycle primitive for governed browser-agent work before durable BAP transport, persistence, idempotency, or crash recovery can be added safely. A task state is security-relevant because downstream components may use it to decide whether work may start, resume, complete, reconcile, or terminate. If adapters, persistence layers, browser drivers, or recovery code can mint state independently, OriginWeave would inherit ambient execution authority from whichever boundary supplied the most convenient state value. + +The `originweave-bap` crate therefore introduces a typed in-memory state machine with monotonic transition receipts and fail-closed recovery validation. The crate deliberately owns no browser, network, model, secret, approval, persistence, tenant-authentication, or protocol authority. External protocols may project lifecycle intent into this kernel, but protocol metadata cannot bypass its transition rules or upgrade a task's authority. + +## Decision drivers + +- Keep task-state authority explicit and deterministic rather than distributed across protocol adapters. +- Prevent stale, unreachable, or terminal lifecycle snapshots from reopening governed work. +- Preserve a monotonic transition sequence suitable for later durable replay evidence without claiming persistence today. +- Separate lifecycle state from browser, network, secret, model, approval, and tenant authority. +- Make waiting, checkpoint, reconciliation, completion, cancellation, expiry, and dead-letter behavior typed and testable. +- Keep recovery validation fail closed when a supplied state/sequence pair cannot arise from the reviewed state machine. + +## Assumptions and authority boundaries + +- The lifecycle is an in-memory logical primitive; it is not a durable task repository. +- Creating or restoring a lifecycle does not authenticate a caller, tenant, browser session, document, origin, destination, secret, model, approval, or external side effect. +- A transition receipt proves only what this in-memory lifecycle instance accepted. It is not durable audit evidence until a separate authenticated persistence boundary stores it. +- Waiting for approval is a lifecycle condition, not proof that approval exists. A later approval authority must independently authenticate and authorize any decision before resumption. +- `Succeeded` is entered only after a caller asserts that its separately governed post-condition has been verified; the lifecycle does not itself verify that post-condition. +- Reconciliation and dead-letter states preserve control-flow intent only. Durable reconciliation evidence remains the responsibility of a later persistence/recovery boundary. + +## Options considered + +### Let each BAP or MCP adapter own its own state machine + +Rejected. Adapter-local state machines would duplicate policy, make recovery semantics drift by protocol, and allow external protocol metadata to become implicit OriginWeave execution authority. + +### Store task state as an unrestricted string or integer + +Rejected. Untyped state admits unknown values, weakens exhaustive transition review, and makes invalid or stale recovery snapshots difficult to reject deterministically. + +### Allow restored state to resume whenever the state name looks resumable + +Rejected. State-only recovery loses monotonic history. A state/sequence pair that cannot be reached through the reviewed transitions must fail closed rather than becoming execution authority. + +### Centralize logical lifecycle transitions in a typed Rust kernel + +Selected. + +## Decision + +If Accepted, OriginWeave applies these lifecycle rules: + +1. **One typed kernel owns logical BAP task state.** `originweave-bap` is the canonical state-transition authority for the task lifecycle represented by this contract. Protocol adapters may request transitions but do not mint lifecycle state directly. +2. **Transitions are explicit and fail closed.** The kernel accepts only reviewed event/state combinations. Invalid events preserve the existing state and sequence and return a typed error. +3. **Terminal states never reopen.** `Succeeded`, `Failed`, `Cancelled`, `Expired`, and `DeadLettered` reject later lifecycle events. +4. **Waiting and checkpoint states require explicit resumption.** Approval wait, external-input wait, and checkpoint states do not silently become running work. +5. **Reconciliation is distinct from normal suspension.** A task in `ReconciliationRequired` cannot use the ordinary resume path; it requires explicit reconciliation resolution or governed dead-letter handling. +6. **Transition sequence is monotonic and bounded.** Every accepted transition advances the sequence exactly once. Sequence exhaustion fails closed instead of wrapping. +7. **Recovery validates reachability.** A supplied state/sequence snapshot must be reachable under the same reviewed state machine. Unreachable snapshots are rejected with a typed restore error. +8. **Lifecycle state grants no ambient authority.** A `Running`, resumable, or otherwise valid lifecycle state does not authorize browser I/O, network destinations, secret resolution, model access, approvals, external protocol operations, or tenant access. Those authorities must be revalidated by their owning boundaries. +9. **Durability is a separate owner.** This contract does not claim atomic persistence, idempotency, locking, authenticated replay evidence, side-effect reconciliation, or crash-safe recovery. Later durable components must bind those concerns to lifecycle receipts without weakening this state authority. +10. **External protocol state is projected, not inherited.** BAP, MCP, WebDriver BiDi, CDP, or other adapters may translate reviewed external events into typed lifecycle requests only after their own authentication and policy checks. External state labels cannot overwrite the kernel directly. + +## Consequences + +OriginWeave gains one reviewable state authority that later transport, idempotency, persistence, and recovery slices can compose without duplicating transition semantics. Invalid transitions and unreachable recovery snapshots have deterministic typed failures, while terminal and reconciliation states have explicit closure behavior. + +The trade-off is that adapters and durable stores must perform explicit mapping and validation instead of assigning state directly. The current slice also cannot claim commercial crash recovery until durable authenticated evidence and side-effect reconciliation are implemented separately. + +## Failure and degraded behavior + +- An invalid event returns a typed transition error and leaves state/history unchanged. +- A terminal lifecycle rejects all later events rather than reopening work. +- Sequence exhaustion returns a typed failure rather than wrapping or silently reusing an identifier. +- An unreachable restored state/sequence pair is rejected rather than normalized into a nearby valid state. +- Missing browser, tenant, policy, destination, secret, approval, persistence, or recovery authority is not converted into lifecycle success. +- If a future adapter cannot map external protocol state without ambiguity, it must fail closed or require reconciliation rather than inventing a lifecycle transition. + +## Security / privacy / governance impact + +This decision narrows authority. It prevents external protocol metadata, stale snapshots, or arbitrary state assignment from becoming execution authority and keeps lifecycle state separate from sensitive-data, secret, browser, network, model, approval, and tenant boundaries. The lifecycle stores no secret values or personal-data payloads by itself. Any future persistent representation must independently satisfy OriginWeave data-governance, retention, tenant-isolation, integrity, and evidence requirements. + +## Tests and acceptance evidence + +The owning branch must keep executable evidence for: + +- the reviewed created/admitted/running/waiting/checkpointed/reconciliation/terminal transition paths; +- fail-closed invalid transitions with no sequence advancement; +- terminal irreversibility; +- cancellation and expiry across allowed pre-dispatch and suspended states; +- explicit reconciliation resolution and governed dead-letter behavior; +- monotonic transition receipts and sequence-exhaustion failure; +- recovery acceptance for reachable snapshots and rejection for unreachable snapshots; and +- deterministic public Rust error contracts. + +Repository contracts must also require this ADR so the `originweave-bap` control-plane boundary cannot remain undocumented while the crate is present. Exact protected-main acceptance still depends on current-head CI, exact owned-production coverage, rustdoc, security evidence, review, live governance, and integration state; ADR presence does not substitute for those gates. + +## Migration and rollback + +No database migration is introduced. Existing callers on this branch construct the typed lifecycle directly. A future durable task repository should persist state and transition evidence in an authenticated form that can be validated by this kernel rather than introducing a second transition authority. + +Rollback before acceptance is removal of the active BAP lifecycle branch and its Proposed ADR. After acceptance, rollback or replacement must preserve fail-closed terminal/recovery semantics or explicitly supersede this ADR with a reviewed migration for any persisted lifecycle representation. + +## Open follow-ups + +- Bind durable idempotency receipts to exact accepted transitions without making retry metadata task authority. +- Define authenticated persistence, atomicity, and concurrency semantics for lifecycle plus command evidence. +- Define crash-recovery classification and reconciliation for ambiguous external side effects. +- Map authenticated BAP/MCP transport messages into typed lifecycle requests without ambient protocol authority. +- Propagate cancellation and expiry into real browser/process supervision only after the corresponding runtime authority exists. + +## Supersession / reversal conditions + +Supersede this ADR if OriginWeave replaces the BAP lifecycle model, introduces a materially different durable event-sourced task authority, or moves canonical task-state ownership to another reviewed component. A successor must preserve explicit state authority, terminal fail-closure, monotonic recovery evidence, and the rule that lifecycle state cannot mint unrelated browser/network/secret/model/approval/tenant authority. + +## References + +ContextualWisdomLab. (2026). *OriginWeave architecture* [Repository specification]. *OriginWeave*. [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) + +ContextualWisdomLab. (2026). *OriginWeave architecture decision records* [Repository specification]. *OriginWeave*. [`README.md`](README.md) + +ContextualWisdomLab. (2026). *Agent development contract* [Repository specification]. *OriginWeave*. [`../../AGENTS.md`](../../AGENTS.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index 416231b1c..5f9e2a878 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,6 +57,16 @@ Proposed ADR files are reviewable target architecture without becoming Accepted ADR 0013 and ADR 0014 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; both decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. +### Proposed decisions introduced by active feature work + +| ADR | Decision | Status | Governs | +|---|---|---|---| +| [0016](0016-bap-task-lifecycle-authority.md) | BAP task lifecycle and state authority | Proposed | BAP task states, transitions, recovery validation, transition sequencing, and authority separation | + +ADR 0016 belongs to the active BAP lifecycle feature branch. Indexing it makes the branch documentation graph complete while preserving its Proposed lifecycle and active-PR, non-protected-main maturity. + +After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016 from Proposed or assert implementation maturity. + Other active feature PRs may contain additional Proposed ADRs. Those files are not part of this canonical documentation line until integrated or deliberately reconciled here. Historical PR checks, stale branch state, or chat decisions never transfer ADR acceptance across a changed head. ## Index completeness rule diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 360e11143..057a0011b 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -20,6 +20,7 @@ def test_workspace_declares_all_independently_reusable_crates(self) -> None: set(data["workspace"]["members"]), { "crates/originweave-core", + "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-destination", "crates/originweave-network", @@ -59,6 +60,7 @@ def test_required_architecture_and_governance_documents_exist(self) -> None: "docs/adr/0005-direct-socket-binding.md", "docs/adr/0006-tls-server-identity.md", "docs/adr/0009-hourly-agent-credential-boundary.md", + "docs/adr/0016-bap-task-lifecycle-authority.md", "docs/superpowers/specs/2026-08-06-resolved-destination-policy-design.md", "docs/superpowers/specs/2026-08-06-direct-socket-binding-design.md", "docs/superpowers/specs/2026-08-06-tls-server-identity-design.md", @@ -185,4 +187,4 @@ def test_database_contract_requires_two_word_snake_case(self) -> None: if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main()