diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d6ac5750b..16158bbab 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -149,10 +149,17 @@ surfaces do not silently fall back to ambient host values. Owns the narrow WebDriver BiDi adapter contract that is expressible by one explicit specification revision. The active slice pins the W3C WebDriver BiDi Working Draft published on 3 September 2026 at `https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/` and delegates complete-profile admission back to `originweave-fingerprint`. Standard BiDi covers viewport, device-pixel-ratio, timezone, and reduced-motion surfaces. Its width/height screen command cannot prove the kernel's complete screen-and-color-depth surface, and its single locale cannot prove ordered language preferences; hardware concurrency and the complete Chromium platform/User-Agent Client Hints surface also remain outside the standard set. The adapter therefore fails first on `Screen` rather than inheriting ambient Chromium values. It can plan two typed reusable-context commands—viewport/DPR and timezone—for one bounded opaque browsing-context identifier. Reduced motion remains an expressible protocol capability, but the reusable plan does not install it because `features: null` removes the target's complete media-feature override configuration rather than restoring prior state. Generic cleanup therefore resets only viewport/DPR and timezone. No caller-mintable exclusive-reset type is exposed; a Browser Session owner must instead prove a disposable context lifecycle or restore the complete prior media configuration. Planning sends nothing and proves neither acknowledgement, cleanup, ownership, nor page-visible state. Transport, post-condition observation, and reusable-context media restoration require the pinned Chromium/BiDi path and, for Chromium-only surfaces, a separate versioned `originweave-cdp` adapter. +### `originweave-browser-session` (active PR) + +Owns the Browser Session aggregate boundary for disposable context lifecycle and presentation-mutation authority. Raw `BrowserSessionId` and `BrowsingContextId` values are transport addressability only. A context enters the owned set only after the narrow `DisposableContextPort` reports a fresh disposable isolation boundary together with its browsing-context address. The aggregate stores that exact handle and issues a non-caller-constructible `PresentationMutationAuthority` bound to browser-session identity, disposable-isolation identity, browsing context, and monotonic context epoch. + +The isolation identity prevents distinct aggregate incarnations from aliasing authority when external session/context identifiers and local epoch values are reused. Destruction validates the full authority before adapter I/O and passes the stored isolation handle back to the port; cleanup authority is never reconstructed from `(BrowserSessionId, BrowsingContextId)`. For a WebDriver BiDi adapter, the port contract requires a one-to-one mapping from the domain's `DisposableIsolationId` to the specification-defined unique user-context id created for that live boundary. The protocol identifier is lifecycle addressability, not OriginWeave policy authority. Stale, foreign-session, foreign-isolation, unknown, destroyed, or uncertain authority fails closed; failed destruction makes the context uncertain; browser transport loss invalidates active authority; and normal session end is rejected until every owned boundary has proven destruction. + +This active slice deliberately stops before browser transport. WebDriver BiDi/CDP remain adapters and do not mint policy authority. The current proposal does not yet bridge domain authority into `originweave-bidi`'s private presentation/screen-area witnesses, implement the real `browser.createUserContext`/`browsingContext.create`/`browser.removeUserContext` adapter, prove exact-boundary cleanup post-conditions in Chromium, or establish protected-main behavior. ADR 0114, the Browser Session traceability dossier, and the lifecycle UML record those remaining boundaries. + ## 6. Planned modules ```text -originweave-session isolated browser contexts and checkpoints originweave-proxy separately approved proxy and final-target routing originweave-http request, response, redirect, and elapsed-time budgets originweave-observation AX + DOM + layout + network semantic snapshots @@ -285,6 +292,7 @@ WARC stores source exchanges and resources; relational storage holds sessions, p - Proxy and PAC routing cannot be inherited ambiently by the direct-only or TLS kernels. - Redirects cannot inherit ambient origin or network authority. - TCP peer equality does not substitute for TLS server identity, and TLS identity does not substitute for HTTP safety. +- Disposable Browser Session mutation and destruction authority is bound to the exact owned isolation identity as well as session, context, and epoch; raw driver identifiers alone cannot cross that boundary. - Arbitrary script evaluation is absent from the standard action interface. - Crawler policy is not treated as access authorization. - High-risk actions fail closed when context, canonical intent, or approval evidence is incomplete. diff --git a/Cargo.lock b/Cargo.lock index d67729593..c268c0ccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -274,6 +274,13 @@ dependencies = [ "originweave-fingerprint", ] +[[package]] +name = "originweave-browser-session" +version = "0.1.0" +dependencies = [ + "originweave-core", +] + [[package]] name = "originweave-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index aef0b7ee7..aec209447 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/originweave-tls", "crates/originweave-fingerprint", "crates/originweave-bidi", + "crates/originweave-browser-session", ] resolver = "3" diff --git a/crates/originweave-browser-session/Cargo.toml b/crates/originweave-browser-session/Cargo.toml new file mode 100644 index 000000000..bd5a146ea --- /dev/null +++ b/crates/originweave-browser-session/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "originweave-browser-session" +description = "OriginWeave Browser Session lifecycle and mutation-authority contracts." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +publish = false + +[dependencies] +originweave-core = { path = "../originweave-core" } + +[lints] +workspace = true diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs new file mode 100644 index 000000000..66f5753c5 --- /dev/null +++ b/crates/originweave-browser-session/src/lib.rs @@ -0,0 +1,1013 @@ +//! Browser Session lifecycle authority for OriginWeave. +//! +//! This crate owns the domain transition that turns a newly created disposable +//! browser isolation boundary into presentation-mutation authority. Driver identifiers +//! remain adapter data: naming a session or browsing context is never sufficient to mint authority. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; + +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +static NEXT_BROWSER_SESSION_INCARNATION: AtomicU64 = AtomicU64::new(1); + +/// Current lifecycle state of one Browser Session aggregate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserSessionState { + /// The session may create and own disposable contexts. + Active, + /// Every owned context was destroyed and the session was ended normally. + Ended, + /// The browser transport was lost while no ownership-recovery condition preceded it. + TransportLost, + /// Browser lifecycle ownership became uncertain and requires external reconciliation. + RecoveryRequired, +} + +/// Domain failure while changing Browser Session ownership state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserSessionError { + /// The requested transition requires an active Browser Session. + SessionNotActive, + /// No unused session-incarnation identity remains in this process. + IncarnationExhausted, + /// No unused context epoch remains, so no new authority can be issued safely. + EpochExhausted, + /// The disposable-context port proved that context creation failed without creating a boundary. + ContextCreationFailed, + /// Context creation may have created browser state that the aggregate cannot safely own or destroy. + ContextCreationUncertain, + /// The port returned a browsing-context identity already known to this aggregate. + DuplicateBrowsingContext, + /// The port returned an isolation identity already known to this aggregate. + DuplicateDisposableIsolation, + /// The requested context is not currently owned and active in this session. + ContextNotOwned, + /// The supplied authority belongs to another incarnation, isolation boundary, session, context, or epoch. + AuthorityMismatch, + /// The disposable-context port could not prove destruction of the owned isolation boundary. + ContextDestructionFailed, + /// Normal session end was requested while an owned or uncertain context remains. + ActiveContextRemains, +} + +/// Bounded failure from disposable-context creation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DisposableContextCreateError { + /// Creation failed and the adapter proved that no disposable boundary was created. + CreateFailedClean, + /// Creation failed after ownership may have changed. The optional identity is the exact + /// browser-issued isolation identity already known at the failure boundary, when available. + CreateFailedUncertain(Option), +} + +/// Bounded failure from disposable-context destruction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisposableContextDestroyError { + /// Destruction of an owned disposable context failed or could not be proven. + DestroyFailed, +} + +/// Validation failure for a browser-issued disposable isolation identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisposableIsolationIdError { + /// The identity is empty. + Empty, + /// The identity exceeds the bounded adapter evidence size. + TooLong, + /// The identity contains surrounding whitespace or control characters. + InvalidCharacter, +} + +/// Browser-issued identity for one disposable isolation boundary. +/// +/// This value is addressability, not mutation authority. A conforming adapter must return a value +/// that is non-aliasing for the live lifetime of the created boundary. A WebDriver BiDi adapter +/// should map this one-to-one to the specification-defined unique user-context identifier. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DisposableIsolationId(String); + +impl DisposableIsolationId { + /// Parse one bounded browser-issued isolation identity. + pub fn parse(value: &str) -> Result { + if value.is_empty() { + return Err(DisposableIsolationIdError::Empty); + } + if value.len() > 4096 { + return Err(DisposableIsolationIdError::TooLong); + } + if value.trim() != value || value.chars().any(char::is_control) { + return Err(DisposableIsolationIdError::InvalidCharacter); + } + Ok(Self(value.to_owned())) + } + + /// Return the validated browser-issued isolation identity. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Process-local, non-reused identity for one Browser Session aggregate incarnation. +/// +/// Presentation authority is intentionally non-serializable. A process restart therefore destroys +/// every outstanding authority value. Within one process this monotonic identity prevents a later +/// aggregate from revalidating an authority retained from an earlier aggregate that reused the same +/// transport/session and browser-issued context identifiers. The identity is also passed through the +/// lifecycle port so an adapter must scope its remote ownership mapping to the same incarnation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowserSessionIncarnation(u64); + +impl BrowserSessionIncarnation { + /// Return the monotonic process-local incarnation value. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// Adapter result for one newly created disposable browser context. +/// +/// The isolation identity scopes the lifecycle boundary used for destruction; the browsing-context +/// identity addresses the independently navigable context inside that boundary. Neither field alone +/// is presentation-mutation authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisposableContextHandle { + isolation: DisposableIsolationId, + browsing_context: BrowsingContextId, +} + +impl DisposableContextHandle { + /// Bind one validated isolation identity to its created browsing context. + #[must_use] + pub fn new(isolation: DisposableIsolationId, browsing_context: BrowsingContextId) -> Self { + Self { + isolation, + browsing_context, + } + } + + /// Return the non-aliasing disposable isolation identity. + #[must_use] + pub fn isolation(&self) -> &DisposableIsolationId { + &self.isolation + } + + /// Return the browsing-context address inside the disposable boundary. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.browsing_context + } +} + +/// Lossless evidence retained when browser lifecycle ownership is no longer proven. +/// +/// These values authorize no browser command. They exist only so a separately reviewed recovery +/// path can later reconcile exact remote identities instead of guessing from raw session/context ids. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BrowserSessionRecoveryEvidence { + /// A partial creation exposed a browser-issued isolation identity before completion became uncertain. + PartialCreationIsolation(DisposableIsolationId), + /// A create call returned a complete handle that aliased an already-owned context or isolation. + DuplicateAdapterHandle(DisposableContextHandle), + /// Destruction of this exact owned handle failed or could not be proven. + UnprovenDestruction(DisposableContextHandle), +} + +/// Port implemented by a reviewed browser adapter for disposable context lifecycle operations. +/// +/// `incarnation` is domain-issued and must participate in the adapter's lifecycle mapping; ignoring it +/// would reintroduce sequential ABA aliasing. `create_disposable_context` must create a fresh isolation +/// boundary and context owned exclusively by the supplied Browser Session incarnation. For WebDriver +/// BiDi the isolation identity maps one-to-one to the user-context identifier returned by +/// `browser.createUserContext`. +/// +/// [`DisposableContextCreateError::CreateFailedClean`] is allowed only when the adapter proves that no +/// disposable state was created. If a user-context identity is already known when later creation or +/// verification becomes uncertain, the adapter must return it inside +/// [`DisposableContextCreateError::CreateFailedUncertain`]. +/// +/// `destroy_disposable_context` must destroy the exact boundary carried by the supplied handle and +/// return success only after destruction is proven. Reconstructing cleanup authority from raw driver +/// identifiers is forbidden, and a command acknowledgement alone is insufficient evidence. +pub trait DisposableContextPort { + /// Create one fresh disposable isolation boundary and browsing context for this incarnation. + fn create_disposable_context( + &mut self, + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + ) -> Result; + + /// Destroy the exact disposable isolation boundary represented by this handle and incarnation. + fn destroy_disposable_context( + &mut self, + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + context: &DisposableContextHandle, + ) -> Result<(), DisposableContextDestroyError>; +} + +/// Monotonic identity for one owned browsing-context authority epoch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowserContextEpoch(u64); + +impl BrowserContextEpoch { + /// Return the internal monotonic epoch value. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// Opaque proof that Browser Session currently owns presentation mutation for one context epoch. +/// +/// The fields are private and no public constructor exists. A caller obtains this value only after +/// Browser Session has created a disposable boundary through its lifecycle port. Session incarnation, +/// isolation identity, context identity, and epoch must all still match before adapter I/O is allowed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PresentationMutationAuthority { + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + isolation: DisposableIsolationId, + browsing_context: BrowsingContextId, + context_epoch: BrowserContextEpoch, +} + +impl PresentationMutationAuthority { + /// Return the Browser Session transport identity associated with this authority. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the Browser Session incarnation that minted this authority. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the owned disposable isolation identity. + #[must_use] + pub fn isolation(&self) -> &DisposableIsolationId { + &self.isolation + } + + /// Return the owned browsing-context identity. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.browsing_context + } + + /// Return the exact context epoch covered by this authority. + #[must_use] + pub const fn context_epoch(&self) -> BrowserContextEpoch { + self.context_epoch + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OwnedContextState { + Active, + Destroyed, + Uncertain, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OwnedContextRecord { + handle: DisposableContextHandle, + epoch: BrowserContextEpoch, + state: OwnedContextState, +} + +/// Aggregate root for disposable browser-context lifecycle and presentation mutation authority. +#[derive(Debug)] +pub struct BrowserSession { + id: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + state: BrowserSessionState, + transport_lost: bool, + next_epoch: u64, + contexts: BTreeMap, + recovery_evidence: Vec, +} + +impl BrowserSession { + /// Start an active Browser Session around an already validated transport session identity. + /// + /// A fresh process-local incarnation is allocated before any browser I/O. Exhaustion fails closed + /// rather than wrapping and making an older authority structurally valid again. + pub fn start(id: BrowserSessionId) -> Result { + Self::start_with_counter(id, &NEXT_BROWSER_SESSION_INCARNATION) + } + + fn start_with_counter( + id: BrowserSessionId, + counter: &AtomicU64, + ) -> Result { + let incarnation = allocate_incarnation(counter)?; + Ok(Self { + id, + incarnation, + state: BrowserSessionState::Active, + transport_lost: false, + next_epoch: 1, + contexts: BTreeMap::new(), + recovery_evidence: Vec::new(), + }) + } + + /// Return this aggregate's browser-session transport identity. + #[must_use] + pub const fn id(&self) -> BrowserSessionId { + self.id + } + + /// Return this aggregate's non-reused process-local incarnation. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the current aggregate lifecycle state. + #[must_use] + pub const fn state(&self) -> BrowserSessionState { + self.state + } + + /// Report whether browser transport loss has been observed for this aggregate. + #[must_use] + pub const fn transport_is_lost(&self) -> bool { + self.transport_lost + } + + /// Return immutable recovery evidence retained after uncertain browser lifecycle outcomes. + #[must_use] + pub fn recovery_evidence(&self) -> &[BrowserSessionRecoveryEvidence] { + &self.recovery_evidence + } + + /// Create and register one disposable context, then mint authority for its first epoch. + pub fn create_disposable_context( + &mut self, + port: &mut P, + ) -> Result { + self.require_active()?; + let epoch = reserve_epoch(&mut self.next_epoch)?; + let handle = match port.create_disposable_context(self.id, self.incarnation) { + Ok(handle) => handle, + Err(DisposableContextCreateError::CreateFailedClean) => { + return Err(BrowserSessionError::ContextCreationFailed); + } + Err(DisposableContextCreateError::CreateFailedUncertain(isolation)) => { + if let Some(isolation) = isolation { + self.recovery_evidence.push( + BrowserSessionRecoveryEvidence::PartialCreationIsolation(isolation), + ); + } + self.enter_recovery_required(); + return Err(BrowserSessionError::ContextCreationUncertain); + } + }; + + if self + .contexts + .values() + .any(|record| record.handle.isolation == handle.isolation) + { + self.recovery_evidence + .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( + handle, + )); + self.enter_recovery_required(); + return Err(BrowserSessionError::DuplicateDisposableIsolation); + } + if self.contexts.contains_key(&handle.browsing_context) { + self.recovery_evidence + .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( + handle, + )); + self.enter_recovery_required(); + return Err(BrowserSessionError::DuplicateBrowsingContext); + } + + let browsing_context = handle.browsing_context; + let authority = Self::authority_for(self.id, self.incarnation, &handle, epoch); + self.contexts.insert( + browsing_context, + OwnedContextRecord { + handle, + epoch, + state: OwnedContextState::Active, + }, + ); + Ok(authority) + } + + /// Return current presentation authority for an already-owned active context. + pub fn presentation_authority( + &self, + browsing_context: BrowsingContextId, + ) -> Result { + self.require_active()?; + let record = self + .contexts + .get(&browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + Ok(Self::authority_for( + self.id, + self.incarnation, + &record.handle, + record.epoch, + )) + } + + /// Advance one active owned context to a new authority epoch. + pub fn advance_context_epoch( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + self.require_active()?; + let browser_session = self.id; + let incarnation = self.incarnation; + let record = self + .contexts + .get_mut(&browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + let next = reserve_epoch(&mut self.next_epoch)?; + record.epoch = next; + Ok(Self::authority_for( + browser_session, + incarnation, + &record.handle, + next, + )) + } + + /// Destroy the disposable isolation boundary covered by the supplied exact-epoch authority. + pub fn destroy_disposable_context( + &mut self, + authority: &PresentationMutationAuthority, + port: &mut P, + ) -> Result<(), BrowserSessionError> { + let browser_session = self.id; + let incarnation = self.incarnation; + let record = self.context_for_authority_mut(authority)?; + let handle = record.handle.clone(); + match port.destroy_disposable_context(browser_session, incarnation, &handle) { + Ok(()) => { + record.state = OwnedContextState::Destroyed; + Ok(()) + } + Err(DisposableContextDestroyError::DestroyFailed) => { + record.state = OwnedContextState::Uncertain; + self.recovery_evidence + .push(BrowserSessionRecoveryEvidence::UnprovenDestruction(handle)); + self.enter_recovery_required(); + Err(BrowserSessionError::ContextDestructionFailed) + } + } + } + + /// Record browser transport loss independently from ownership-recovery state. + /// + /// Returns `true` only for the first observed transport loss. If ownership was already uncertain, + /// `RecoveryRequired` remains the lifecycle state while the transport-loss fact is retained. + pub fn record_transport_loss(&mut self) -> bool { + if self.transport_lost || self.state == BrowserSessionState::Ended { + return false; + } + self.transport_lost = true; + if self.state == BrowserSessionState::Active { + self.state = BrowserSessionState::TransportLost; + self.mark_active_contexts_uncertain(); + } + true + } + + /// End the Browser Session only after every owned context has proven destruction. + pub fn end(&mut self) -> Result<(), BrowserSessionError> { + self.require_active()?; + if self + .contexts + .values() + .any(|record| record.state != OwnedContextState::Destroyed) + { + return Err(BrowserSessionError::ActiveContextRemains); + } + self.state = BrowserSessionState::Ended; + Ok(()) + } + + fn require_active(&self) -> Result<(), BrowserSessionError> { + if self.state == BrowserSessionState::Active { + Ok(()) + } else { + Err(BrowserSessionError::SessionNotActive) + } + } + + fn authority_for( + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + handle: &DisposableContextHandle, + context_epoch: BrowserContextEpoch, + ) -> PresentationMutationAuthority { + PresentationMutationAuthority { + browser_session, + incarnation, + isolation: handle.isolation.clone(), + browsing_context: handle.browsing_context, + context_epoch, + } + } + + fn context_for_authority_mut( + &mut self, + authority: &PresentationMutationAuthority, + ) -> Result<&mut OwnedContextRecord, BrowserSessionError> { + self.require_active()?; + if authority.browser_session != self.id || authority.incarnation != self.incarnation { + return Err(BrowserSessionError::AuthorityMismatch); + } + let record = self + .contexts + .get_mut(&authority.browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + if record.epoch != authority.context_epoch || record.handle.isolation != authority.isolation + { + return Err(BrowserSessionError::AuthorityMismatch); + } + Ok(record) + } + + fn enter_recovery_required(&mut self) { + self.state = BrowserSessionState::RecoveryRequired; + self.mark_active_contexts_uncertain(); + } + + fn mark_active_contexts_uncertain(&mut self) { + for record in self.contexts.values_mut() { + if record.state == OwnedContextState::Active { + record.state = OwnedContextState::Uncertain; + } + } + } +} + +fn reserve_epoch(next_epoch: &mut u64) -> Result { + let epoch = BrowserContextEpoch(*next_epoch); + *next_epoch = next_epoch + .checked_add(1) + .ok_or(BrowserSessionError::EpochExhausted)?; + Ok(epoch) +} + +fn allocate_incarnation( + counter: &AtomicU64, +) -> Result { + let value = counter + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { + current.checked_add(1) + }) + .map_err(|_| BrowserSessionError::IncarnationExhausted)?; + Ok(BrowserSessionIncarnation(value)) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + + #[derive(Debug)] + struct TestPort { + next_handle: DisposableContextHandle, + create_error: Option, + fail_destroy: bool, + create_calls: usize, + destroy_calls: usize, + create_incarnations: Vec, + destroy_incarnations: Vec, + destroyed_isolations: Vec, + } + + impl TestPort { + fn new(context: u64, isolation: &str) -> Self { + Self { + next_handle: DisposableContextHandle::new( + isolation_id(isolation), + context_id(context), + ), + create_error: None, + fail_destroy: false, + create_calls: 0, + destroy_calls: 0, + create_incarnations: Vec::new(), + destroy_incarnations: Vec::new(), + destroyed_isolations: Vec::new(), + } + } + } + + impl DisposableContextPort for TestPort { + fn create_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + ) -> Result { + self.create_calls += 1; + self.create_incarnations.push(incarnation); + match self.create_error.clone() { + Some(error) => Err(error), + None => Ok(self.next_handle.clone()), + } + } + + fn destroy_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + context: &DisposableContextHandle, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls += 1; + self.destroy_incarnations.push(incarnation); + self.destroyed_isolations.push(context.isolation.clone()); + if self.fail_destroy { + Err(DisposableContextDestroyError::DestroyFailed) + } else { + Ok(()) + } + } + } + + fn session_id(value: u64) -> BrowserSessionId { + BrowserSessionId::new(value).expect("valid session id") + } + + fn context_id(value: u64) -> BrowsingContextId { + BrowsingContextId::new(value).expect("valid context id") + } + + fn isolation_id(value: &str) -> DisposableIsolationId { + DisposableIsolationId::parse(value).expect("valid isolation id") + } + + fn session(value: u64) -> BrowserSession { + BrowserSession::start(session_id(value)).expect("incarnation capacity") + } + + #[test] + fn isolation_identity_validation_is_bounded() { + assert_eq!( + DisposableIsolationId::parse(""), + Err(DisposableIsolationIdError::Empty) + ); + assert_eq!( + DisposableIsolationId::parse(&"x".repeat(4097)), + Err(DisposableIsolationIdError::TooLong) + ); + assert_eq!( + DisposableIsolationId::parse(" user-context "), + Err(DisposableIsolationIdError::InvalidCharacter) + ); + assert_eq!( + DisposableIsolationId::parse("user\ncontext"), + Err(DisposableIsolationIdError::InvalidCharacter) + ); + let valid = isolation_id("webdriver-user-context-10"); + assert_eq!(valid.as_str(), "webdriver-user-context-10"); + let handle = DisposableContextHandle::new(valid.clone(), context_id(10)); + assert_eq!(handle.isolation(), &valid); + assert_eq!(handle.browsing_context(), context_id(10)); + } + + #[test] + fn disposable_creation_is_the_only_raw_context_entry_to_authority() { + let mut session = session(1); + let mut port = TestPort::new(10, "isolation-10"); + assert_eq!(session.id(), session_id(1)); + assert_ne!(session.incarnation().value(), 0); + assert!(!session.transport_is_lost()); + assert!(session.recovery_evidence().is_empty()); + assert_eq!( + session.presentation_authority(context_id(10)), + Err(BrowserSessionError::ContextNotOwned) + ); + let authority = session + .create_disposable_context(&mut port) + .expect("owned disposable context"); + assert_eq!(port.create_incarnations, vec![session.incarnation()]); + assert_eq!(authority.browser_session(), session_id(1)); + assert_eq!(authority.incarnation(), session.incarnation()); + assert_eq!(authority.isolation().as_str(), "isolation-10"); + assert_eq!(authority.browsing_context(), context_id(10)); + assert_eq!(authority.context_epoch().value(), 1); + assert_eq!( + session.presentation_authority(context_id(10)), + Ok(authority) + ); + } + + #[test] + fn creation_failure_preserves_known_recovery_identity() { + let mut clean_session = session(2); + let mut clean_port = TestPort::new(20, "isolation-20"); + clean_port.create_error = Some(DisposableContextCreateError::CreateFailedClean); + assert_eq!( + clean_session.create_disposable_context(&mut clean_port), + Err(BrowserSessionError::ContextCreationFailed) + ); + assert_eq!(clean_session.state(), BrowserSessionState::Active); + clean_session.end().expect("clean failure can end"); + + let mut unknown_session = session(21); + let mut unknown_port = TestPort::new(210, "isolation-210"); + unknown_port.create_error = Some(DisposableContextCreateError::CreateFailedUncertain(None)); + assert_eq!( + unknown_session.create_disposable_context(&mut unknown_port), + Err(BrowserSessionError::ContextCreationUncertain) + ); + assert!(unknown_session.recovery_evidence().is_empty()); + + let known = isolation_id("partial-user-context-211"); + let mut known_session = session(22); + let mut known_port = TestPort::new(211, "unused"); + known_port.create_error = Some(DisposableContextCreateError::CreateFailedUncertain(Some( + known.clone(), + ))); + assert_eq!( + known_session.create_disposable_context(&mut known_port), + Err(BrowserSessionError::ContextCreationUncertain) + ); + assert_eq!( + known_session.recovery_evidence(), + &[BrowserSessionRecoveryEvidence::PartialCreationIsolation( + known + )] + ); + assert_eq!( + known_session.end(), + Err(BrowserSessionError::SessionNotActive) + ); + } + + #[test] + fn duplicate_adapter_output_preserves_offending_handle() { + let mut duplicate_context_session = session(3); + let mut first_context_port = TestPort::new(30, "isolation-30-a"); + duplicate_context_session + .create_disposable_context(&mut first_context_port) + .expect("first owned context"); + let duplicate_context_handle = + DisposableContextHandle::new(isolation_id("isolation-30-b"), context_id(30)); + let mut duplicate_context_port = TestPort::new(30, "isolation-30-b"); + assert_eq!( + duplicate_context_session.create_disposable_context(&mut duplicate_context_port), + Err(BrowserSessionError::DuplicateBrowsingContext) + ); + assert_eq!( + duplicate_context_session.recovery_evidence(), + &[BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( + duplicate_context_handle + )] + ); + + let mut duplicate_isolation_session = session(31); + let mut first_isolation_port = TestPort::new(310, "isolation-31"); + duplicate_isolation_session + .create_disposable_context(&mut first_isolation_port) + .expect("first owned isolation"); + let duplicate_isolation_handle = + DisposableContextHandle::new(isolation_id("isolation-31"), context_id(311)); + let mut duplicate_isolation_port = TestPort::new(311, "isolation-31"); + assert_eq!( + duplicate_isolation_session.create_disposable_context(&mut duplicate_isolation_port), + Err(BrowserSessionError::DuplicateDisposableIsolation) + ); + assert_eq!( + duplicate_isolation_session.recovery_evidence(), + &[BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( + duplicate_isolation_handle + )] + ); + } + + #[test] + fn epoch_exhaustion_prevents_creation_io() { + let mut exhausted_session = session(4); + exhausted_session.next_epoch = u64::MAX; + let mut unused_port = TestPort::new(40, "isolation-40"); + assert_eq!( + exhausted_session.create_disposable_context(&mut unused_port), + Err(BrowserSessionError::EpochExhausted) + ); + assert_eq!(unused_port.create_calls, 0); + } + + #[test] + fn epoch_exhaustion_prevents_advance_mutation() { + let mut exhausted_session = session(41); + let mut port = TestPort::new(410, "isolation-410"); + let authority = exhausted_session + .create_disposable_context(&mut port) + .expect("owned context"); + exhausted_session.next_epoch = u64::MAX; + assert_eq!( + exhausted_session.advance_context_epoch(context_id(410)), + Err(BrowserSessionError::EpochExhausted) + ); + assert_eq!( + exhausted_session.presentation_authority(context_id(410)), + Ok(authority) + ); + } + + #[test] + fn epoch_advance_invalidates_old_and_unknown_authority() { + let mut session = session(5); + let mut port = TestPort::new(50, "isolation-50"); + let old = session + .create_disposable_context(&mut port) + .expect("owned context"); + assert_eq!( + session.advance_context_epoch(context_id(51)), + Err(BrowserSessionError::ContextNotOwned) + ); + let new = session + .advance_context_epoch(context_id(50)) + .expect("advanced epoch"); + assert_eq!(new.context_epoch().value(), 2); + assert_eq!( + session.destroy_disposable_context(&old, &mut port), + Err(BrowserSessionError::AuthorityMismatch) + ); + session + .destroy_disposable_context(&new, &mut port) + .expect("destroy current epoch"); + assert_eq!(port.destroy_incarnations, vec![session.incarnation()]); + assert_eq!( + session.presentation_authority(context_id(50)), + Err(BrowserSessionError::ContextNotOwned) + ); + assert_eq!( + session.destroy_disposable_context(&new, &mut port), + Err(BrowserSessionError::ContextNotOwned) + ); + } + + #[test] + fn cross_session_and_foreign_isolation_authority_fail_before_io() { + let mut owner = session(6); + let mut owner_port = TestPort::new(60, "isolation-60"); + let authority = owner + .create_disposable_context(&mut owner_port) + .expect("owner context"); + + let mut foreign = session(7); + let mut foreign_port = TestPort::new(60, "isolation-60"); + foreign + .create_disposable_context(&mut foreign_port) + .expect("foreign context"); + assert_eq!( + foreign.destroy_disposable_context(&authority, &mut foreign_port), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!(foreign_port.destroy_calls, 0); + + let forged = PresentationMutationAuthority { + browser_session: owner.id(), + incarnation: owner.incarnation(), + isolation: isolation_id("foreign-isolation"), + browsing_context: authority.browsing_context(), + context_epoch: authority.context_epoch(), + }; + assert_eq!( + owner.destroy_disposable_context(&forged, &mut owner_port), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!(owner_port.destroy_calls, 0); + } + + #[test] + fn sequential_incarnation_reuse_rejects_stale_authority() { + let shared_id = session_id(8); + let mut session_a = BrowserSession::start(shared_id).expect("A incarnation"); + let mut port_a = TestPort::new(80, "reused-user-context"); + let authority_a = session_a + .create_disposable_context(&mut port_a) + .expect("A context"); + session_a + .destroy_disposable_context(&authority_a, &mut port_a) + .expect("A destroy"); + session_a.end().expect("A end"); + + let mut session_b = BrowserSession::start(shared_id).expect("B incarnation"); + let mut port_b = TestPort::new(80, "reused-user-context"); + let authority_b = session_b + .create_disposable_context(&mut port_b) + .expect("B context"); + assert_ne!(session_a.incarnation(), session_b.incarnation()); + assert_eq!( + session_b.destroy_disposable_context(&authority_a, &mut port_b), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!(port_b.destroy_calls, 0); + session_b + .destroy_disposable_context(&authority_b, &mut port_b) + .expect("B destroy"); + assert_eq!(port_b.destroy_calls, 1); + } + + #[test] + fn destroy_failure_retains_handle_and_transport_loss_orthogonally() { + let mut session = session(9); + let mut port = TestPort::new(90, "isolation-90"); + let authority = session + .create_disposable_context(&mut port) + .expect("owned context"); + let expected_handle = + DisposableContextHandle::new(isolation_id("isolation-90"), context_id(90)); + port.fail_destroy = true; + assert_eq!( + session.destroy_disposable_context(&authority, &mut port), + Err(BrowserSessionError::ContextDestructionFailed) + ); + assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); + assert_eq!( + session.recovery_evidence(), + &[BrowserSessionRecoveryEvidence::UnprovenDestruction( + expected_handle + )] + ); + assert!(!session.transport_is_lost()); + assert!(session.record_transport_loss()); + assert!(session.transport_is_lost()); + assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); + assert!(!session.record_transport_loss()); + assert_eq!( + session.create_disposable_context(&mut port), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!( + session.presentation_authority(context_id(90)), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!( + session.advance_context_epoch(context_id(90)), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); + } + + #[test] + fn transport_loss_invalidates_active_contexts_and_is_idempotent() { + let mut session = session(10); + let mut port = TestPort::new(100, "isolation-100"); + let authority = session + .create_disposable_context(&mut port) + .expect("owned context"); + assert!(session.record_transport_loss()); + assert_eq!(session.state(), BrowserSessionState::TransportLost); + assert!(session.transport_is_lost()); + assert!(!session.record_transport_loss()); + assert_eq!( + session.destroy_disposable_context(&authority, &mut port), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!(port.destroy_calls, 0); + } + + #[test] + fn normal_end_requires_proven_destruction_and_ignores_late_transport_report() { + let mut session = session(11); + let mut port = TestPort::new(110, "isolation-110"); + let authority = session + .create_disposable_context(&mut port) + .expect("owned context"); + assert_eq!( + session.end(), + Err(BrowserSessionError::ActiveContextRemains) + ); + session + .destroy_disposable_context(&authority, &mut port) + .expect("proven destruction"); + session.end().expect("normal end"); + assert_eq!(session.state(), BrowserSessionState::Ended); + assert!(!session.record_transport_loss()); + assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); + } + + #[test] + fn incarnation_allocator_fails_closed_before_wrap() { + let counter = AtomicU64::new(u64::MAX); + let error = BrowserSession::start_with_counter(session_id(12), &counter) + .expect_err("incarnation allocation must fail closed before wrapping"); + assert_eq!(error, BrowserSessionError::IncarnationExhausted); + } +} diff --git a/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs new file mode 100644 index 000000000..669f0723c --- /dev/null +++ b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs @@ -0,0 +1,102 @@ +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, BrowserSessionRecoveryEvidence, + BrowserSessionState, DisposableContextCreateError, DisposableContextDestroyError, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct FailingDestroyPort { + next_handle: DisposableContextHandle, + create_calls: usize, + destroy_calls: usize, +} + +impl FailingDestroyPort { + fn new(context: u64, isolation: &str) -> Result { + let isolation = DisposableIsolationId::parse(isolation) + .map_err(|_| "static fixture isolation id must be valid")?; + let browsing_context = BrowsingContextId::new(context) + .map_err(|_| "static fixture browsing context id must be valid")?; + Ok(Self { + next_handle: DisposableContextHandle::new(isolation, browsing_context), + create_calls: 0, + destroy_calls: 0, + }) + } +} + +impl DisposableContextPort for FailingDestroyPort { + fn create_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + _incarnation: BrowserSessionIncarnation, + ) -> Result { + self.create_calls += 1; + Ok(self.next_handle.clone()) + } + + fn destroy_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + _incarnation: BrowserSessionIncarnation, + _context: &DisposableContextHandle, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls += 1; + Err(DisposableContextDestroyError::DestroyFailed) + } +} + +/// An unproven destroy must retain exact recovery evidence and reject later normal authority. +#[test] +fn destroy_failure_requires_recovery_before_any_new_authority() -> Result<(), &'static str> { + let session_id = BrowserSessionId::new(501) + .map_err(|_| "static fixture browser session id must be valid")?; + let context_id = BrowsingContextId::new(5010) + .map_err(|_| "static fixture browsing context id must be valid")?; + let expected_isolation = DisposableIsolationId::parse("user-context-501") + .map_err(|_| "static fixture recovery isolation id must be valid")?; + let expected_handle = DisposableContextHandle::new(expected_isolation, context_id); + let mut session = BrowserSession::start(session_id) + .map_err(|_| "browser session incarnation must be available")?; + let mut failing_port = FailingDestroyPort::new(5010, "user-context-501")?; + + let authority = session + .create_disposable_context(&mut failing_port) + .map_err(|_| "fixture disposable context creation must succeed")?; + assert_eq!( + session.destroy_disposable_context(&authority, &mut failing_port), + Err(BrowserSessionError::ContextDestructionFailed) + ); + assert_eq!(failing_port.destroy_calls, 1); + assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); + assert_eq!( + session.recovery_evidence(), + &[BrowserSessionRecoveryEvidence::UnprovenDestruction( + expected_handle + )] + ); + assert!(!session.transport_is_lost()); + + assert!(session.record_transport_loss()); + assert!(session.transport_is_lost()); + assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); + assert!(!session.record_transport_loss()); + + let mut later_port = FailingDestroyPort::new(5011, "user-context-501-later")?; + assert_eq!( + session.create_disposable_context(&mut later_port), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!(later_port.create_calls, 0); + assert_eq!( + session.presentation_authority(context_id), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!( + session.advance_context_epoch(context_id), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); + Ok(()) +} diff --git a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs new file mode 100644 index 000000000..355201280 --- /dev/null +++ b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs @@ -0,0 +1,90 @@ +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, DisposableContextCreateError, + DisposableContextDestroyError, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct ReusingPort { + handle: DisposableContextHandle, + create_incarnations: Vec, + destroy_incarnations: Vec, +} + +impl ReusingPort { + fn new(context: u64, isolation: &str) -> Result { + let isolation = DisposableIsolationId::parse(isolation) + .map_err(|_| "static fixture isolation id must be valid")?; + let browsing_context = BrowsingContextId::new(context) + .map_err(|_| "static fixture browsing context id must be valid")?; + Ok(Self { + handle: DisposableContextHandle::new(isolation, browsing_context), + create_incarnations: Vec::new(), + destroy_incarnations: Vec::new(), + }) + } +} + +impl DisposableContextPort for ReusingPort { + fn create_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + ) -> Result { + self.create_incarnations.push(incarnation); + Ok(self.handle.clone()) + } + + fn destroy_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + _context: &DisposableContextHandle, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_incarnations.push(incarnation); + Ok(()) + } +} + +/// A retained authority from a completed aggregate must not become valid again after identifier reuse. +#[test] +fn stale_authority_cannot_cross_sequential_session_incarnations() -> Result<(), &'static str> { + let session_id = BrowserSessionId::new(701) + .map_err(|_| "static fixture browser session id must be valid")?; + + let mut port_a = ReusingPort::new(7010, "user-context-reused")?; + let mut session_a = BrowserSession::start(session_id) + .map_err(|_| "first browser session incarnation must be available")?; + let authority_a = session_a + .create_disposable_context(&mut port_a) + .map_err(|_| "first disposable context creation must succeed")?; + session_a + .destroy_disposable_context(&authority_a, &mut port_a) + .map_err(|_| "first disposable context destruction must succeed")?; + session_a + .end() + .map_err(|_| "first browser session must end normally")?; + + let mut port_b = ReusingPort::new(7010, "user-context-reused")?; + let mut session_b = BrowserSession::start(session_id) + .map_err(|_| "second browser session incarnation must be available")?; + let authority_b = session_b + .create_disposable_context(&mut port_b) + .map_err(|_| "second disposable context creation must succeed")?; + + assert_ne!(session_a.incarnation(), session_b.incarnation()); + assert_eq!(port_a.create_incarnations, vec![session_a.incarnation()]); + assert_eq!(port_b.create_incarnations, vec![session_b.incarnation()]); + assert_eq!( + session_b.destroy_disposable_context(&authority_a, &mut port_b), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert!(port_b.destroy_incarnations.is_empty()); + + session_b + .destroy_disposable_context(&authority_b, &mut port_b) + .map_err(|_| "current incarnation authority must remain valid")?; + assert_eq!(port_b.destroy_incarnations, vec![session_b.incarnation()]); + Ok(()) +} diff --git a/docs/README.md b/docs/README.md index fd2c19ec9..772ccead9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -94,9 +94,10 @@ The second group exists only on this documentation branch until the branch integ - [ADR 0016: BAP task lifecycle and state authority](adr/0016-bap-task-lifecycle-authority.md) - [ADR 0113: WebDriver BiDi screen-area ownership witness](adr/0113-webdriver-bidi-screen-area-ownership.md) +- [ADR 0114: Browser Session disposable-context authority](adr/0114-browser-session-disposable-context-authority.md) -ADR 0016 is owned by the active BAP lifecycle feature branch. ADR 0113 is owned by the active WebDriver BiDi screen-area ownership successor. Their presence here makes the branch documentation graph complete without presenting either decision or implementation as protected-main truth before integration. +ADR 0016 is owned by the active BAP lifecycle feature branch. ADR 0113 is owned by the active WebDriver BiDi screen-area ownership successor. ADR 0114 is owned by the active Browser Session lifecycle successor. Their presence here makes the branch documentation graph complete without presenting any 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 or ADR 0113 from Proposed or assert implementation 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, ADR 0113, or ADR 0114 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/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md new file mode 100644 index 000000000..345071fd6 --- /dev/null +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -0,0 +1,126 @@ +# ADR 0114: Browser Session disposable-context authority + +- Status: Proposed +- Date: 2026-09-10 + +## Context + +OriginWeave's WebDriver BiDi presentation adapter requires opaque ownership witnesses before viewport/device-pixel-ratio, timezone, or screen-area mutation can be planned. A caller that merely knows a browser-session or browsing-context identifier therefore cannot overwrite another owner's presentation state and later clear it to an implementation default. + +The Browser Session boundary must establish why a context is exclusively OriginWeave-owned before presentation authority exists. External browser-session, user-context/isolation, and browsing-context identifiers are protocol addressability. They may be reused after a prior lifecycle ends, so `(BrowserSessionId, DisposableIsolationId, BrowsingContextId, local epoch)` is not by itself a durable capability generation. + +Lifecycle failures also need lossless evidence. A BiDi adapter can successfully create a user context before later browsing-context creation or verification becomes uncertain. Duplicate adapter output can expose an offending handle that must not be silently discarded or automatically destroyed. Destruction can fail without proving that the exact isolation boundary is gone. These outcomes require recovery quarantine while retaining every exact browser-issued identity that is already known. + +Transport liveness is independent from ownership certainty. A session already in `RecoveryRequired` can subsequently lose its transport; that new fact must be recorded without erasing the recovery evidence. Conversely, merely entering recovery does not prove the transport is dead. + +The 9 September 2026 WebDriver BiDi Working Draft defines user-context identifiers and the `browser.createUserContext`, `browsingContext.create`, and `browser.removeUserContext` lifecycle. Those commands remain adapter capabilities rather than OriginWeave policy authority, and command ACK alone is not destruction proof. + +## Decision drivers + +- Raw WebDriver/BiDi identifiers are addressability, not mutation or cleanup authority. +- Sequential aggregate recreation must not make a retained stale authority valid again. +- The lifecycle adapter must receive the same non-reused session incarnation used by authority validation; an aggregate-only nonce is insufficient. +- Known remote identities from partial creation, duplicate output, or unproven destruction must be retained as recovery evidence without becoming command authority. +- Ownership recovery and transport liveness must remain orthogonal. +- Duplicate or uncertain outcomes fail closed and must not permit false normal completion. +- Destruction I/O must use the exact stored handle and session incarnation rather than reconstructing authority from raw identifiers. +- Browser Session remains the domain authority; WebDriver BiDi, CDP, MCP, and LLMs remain adapters or consumers. + +## Decision + +Introduce `originweave-browser-session` as an independent Rust bounded context and retain ADR status `Proposed` until protected-main and real-browser acceptance exist. + +1. `BrowserSession` is the aggregate root. `BrowserSession::start` allocates a process-local, monotonically non-reused `BrowserSessionIncarnation` before browser I/O. Allocation fails closed before `u64` wrap. +2. Presentation authority is intentionally non-serializable. A process restart destroys every outstanding in-memory authority. Within one process, `BrowserSessionIncarnation` prevents sequential ABA when a later aggregate reuses the same external session, isolation, context, and local epoch values. +3. The same `BrowserSessionIncarnation` is passed through `DisposableContextPort` create and destroy calls. Adapters must scope their remote ownership mapping to that incarnation. Ignoring it violates the port contract. +4. A context enters the owned set only after `DisposableContextPort::create_disposable_context` returns a `DisposableContextHandle`. Raw `BrowsingContextId` input never creates ownership. +5. `PresentationMutationAuthority` is opaque and binds browser session, Browser Session incarnation, disposable isolation, browsing context, and context epoch. All fields must match current aggregate ownership before adapter I/O. +6. `DisposableContextCreateError::CreateFailedClean` is valid only when no remote boundary exists. `DisposableContextCreateError::CreateFailedUncertain(Option)` enters `RecoveryRequired`; when the browser-issued isolation/user-context identity is known, it is preserved exactly. +7. Duplicate browsing-context or isolation output enters `RecoveryRequired` and stores the complete offending `DisposableContextHandle` as recovery evidence. OriginWeave does not auto-destroy it because the adapter may have returned foreign state. +8. `BrowserSessionRecoveryEvidence` records only reconciliation evidence: `PartialCreationIsolation`, `DuplicateAdapterHandle`, and `UnprovenDestruction`. It grants no browser command authority. +9. Destruction validates exact authority before I/O, passes the current incarnation and stored handle to the port, and succeeds only after the adapter proves the exact boundary is gone. `DisposableContextDestroyError` moves the record and aggregate into recovery and retains the exact failed handle. +10. Transport liveness is stored separately from ownership state. The first `record_transport_loss()` records the fact even after `RecoveryRequired`; later duplicate reports are idempotent. If transport is lost while the aggregate is `Active`, the lifecycle state becomes `TransportLost` and active contexts become uncertain. If ownership was already uncertain, `RecoveryRequired` remains the lifecycle state and the transport-loss fact is retained alongside it. +11. `RecoveryRequired`, `TransportLost`, and `Ended` reject active-only creation, authority issuance/advance, destruction, and normal end. Reconciliation is a later, separately authorized design. +12. Context epochs remain monotonic authority identities within one aggregate. They invalidate older authority after navigation or another lifecycle boundary but are not a substitute for session incarnation. + +## Alternatives considered + +### Treat any known context as owned + +Rejected. It restores the authority-confusion defect and allows one task to clear another task's state. + +### Depend only on browser-issued isolation identity + +Rejected. The WebDriver BiDi user-context identifier is suitable lifecycle addressability, but this ADR does not assume a historical non-reuse guarantee after removal. A later aggregate therefore needs a separate OriginWeave lifecycle generation. + +### Add an aggregate-only random or monotonic nonce + +Rejected if it does not reach the lifecycle adapter. It would stop one aggregate from accepting another aggregate's token while still allowing a valid current token to address a remote boundary through aliasable adapter keys. The selected `BrowserSessionIncarnation` participates in both authority validation and port calls. + +### Persist authority generations globally + +Deferred and unnecessary for the current in-process authority model. Presentation authority is not durable across process restart; recovery across restart belongs to evidence/reconciliation design, not silent authority resurrection. + +### Treat every uncertain lifecycle failure as transport loss + +Rejected. Ownership uncertainty and transport liveness answer different operational questions. Collapsing them loses information needed for safe reconciliation. + +### Automatically clean duplicate or partial state + +Rejected. When ownership is ambiguous, cleanup itself can become a cross-owner destructive action. Exact recovery evidence is retained while normal authority stays blocked. + +### Snapshot and restore every predecessor presentation override + +Deferred. OriginWeave does not yet have a complete queryable predecessor-state contract for every governed presentation surface. Disposable ownership remains the stronger first implementation. + +## Consequences + +The Browser Session aggregate now carries an explicit lifecycle generation through the anti-corruption boundary instead of treating protocol identifiers as durable capabilities. A retained token from aggregate A cannot validate against aggregate B solely because the browser or adapter later reused the same external identifiers and local epoch. + +Recovery is also diagnosable rather than merely terminal. Known partial user-context identities, duplicate returned handles, and exact handles whose destruction could not be proven remain available as `BrowserSessionRecoveryEvidence`. This evidence is purpose-bound to later reconciliation; it is not a cleanup credential. + +Transport failure can now be observed after ownership has already become uncertain without replacing or erasing that uncertainty. This supports later recovery planning that distinguishes “ownership uncertain but transport still live” from “ownership uncertain and transport lost.” + +The selected process-local incarnation has a deliberate scope. It prevents ABA only for outstanding in-memory authority within the running process. Durable restart reconciliation must use separately persisted evidence and browser observation; this ADR does not serialize or resurrect authority across restart. + +## Security and governance impact + +No page-controlled value, raw browser-session id, raw browsing-context id, user-context string, provider/model decision, or LLM output can mint presentation authority. The adapter receives domain-issued incarnation information only as a lifecycle-scoping input and cannot manufacture Browser Session policy authority. + +Unknown or duplicate remote state is quarantined rather than destroyed speculatively. This reduces the risk that recovery logic removes another owner's user context. It does not replace Chromium sandboxing, egress policy, Keyverse secret handling, Wardnet controls, or central workflow security. + +## Tests and exact evidence + +The test suite covers raw-context rejection, bounded isolation identity parsing, typed clean/uncertain creation, retained partial identity, duplicate-handle evidence, epoch exhaustion, stale epoch rejection, foreign-session/isolation rejection, destruction failure, transport loss, normal end, and incarnation-allocation exhaustion. + +A dedicated hostile test, `stale_authority_cannot_cross_sequential_session_incarnations`, creates aggregate A, destroys and ends it, creates aggregate B with the same external session/user-context/browsing-context values and local epoch, and requires A's retained authority to fail before B adapter I/O while B's current authority succeeds. The port records incarnation values so the test also proves that the lifecycle mapping receives the new generation. + +`destroy_failure_requires_recovery_before_any_new_authority` requires an unproven destruction to retain the exact failed handle, enter `RecoveryRequired`, then record a later real transport loss without erasing ownership evidence; repeated loss reports are idempotent. + +The RED for the sequential ABA defect was captured on exact `ec145963ad8fe19c9416f2b3856b94660082dbf7` in CI `34469580144`: repository contracts and formatting passed, and Rust `Run tests` failed at the new hostile test before Clippy/rustdoc. The production fix and subsequent documentation/test updates must earn a new exact-head GREEN; predecessor evidence does not transfer. + +Repository contracts, canonical formatting, locked Rust tests, strict Clippy, rustdoc/API docs, exact function/line/region/branch coverage, independent review, and applicable central checks remain required before ordinary adoption into #313. + +## Buyer acceptance still open + +This slice does not yet prove real WebDriver BiDi `browser.createUserContext`/`browsingContext.create`/`browser.removeUserContext` integration, browser-observed destruction, recovery reconciliation, Browser Session→BiDi private-witness conversion, pinned Chromium presentation post-conditions, crash/restart cleanup, #299 3/3 Agent Task replay, or protected-main release/SBOM/provenance/reproducibility/rollback. + +## Migration and rollback + +The change remains additive on the active stacked branch. Consumers must adopt the new `BrowserSession::start` result and incarnation-aware `DisposableContextPort` contract. Until a reviewed adapter bridge exists, presentation mutation remains fail closed behind private ownership witnesses. Rollback removes this active-PR bounded-context slice without weakening protected Chromium or central security policy. + +## Open follow-ups + +- Implement the WebDriver BiDi disposable-user-context adapter with incarnation-scoped mapping and observed destruction post-condition. +- Define the Browser Session→BiDi ACL without exposing public ownership constructors. +- Design separately authorized reconciliation for `BrowserSessionRecoveryEvidence`, including browser/process restart. +- Replay #299 historical pinned Chromium evidence after the canonical sandbox/runtime repair, then run a separate current-Stable qualification. +- Revisit predecessor capture/restore only if reusable attached contexts become a buyer requirement. + +## Supersession / reversal conditions + +Supersede this ADR if the browser platform provides a complete, queryable, generation-safe ownership primitive with exact destruction evidence, or if OriginWeave adopts another isolation primitive with equivalent guarantees. Do not regress to raw context identity as authority. + +## References + +Browser Testing and Tools Working Group. (2026, September 9). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/ diff --git a/docs/adr/README.md b/docs/adr/README.md index 25aa31c0c..2c492ba95 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -66,10 +66,11 @@ ADR 0013, ADR 0014, ADR 0110, ADR 0111, and ADR 0112 exist only on this document |---|---|---|---| | [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 | | [0113](0113-webdriver-bidi-screen-area-ownership.md) | WebDriver BiDi screen-area ownership witness | Proposed | Browser Session-owned screen-settings mutation, destructive reset boundary, and fail-closed adapter authority | +| [0114](0114-browser-session-disposable-context-authority.md) | Browser Session disposable-context authority | Proposed | owned disposable context lifecycle, exact context epochs, presentation mutation authority, cleanup uncertainty and transport-loss invalidation | -ADR 0016 belongs to the active BAP lifecycle feature branch. ADR 0113 belongs to the active WebDriver BiDi screen-area ownership successor. Indexing them makes the branch documentation graph complete while preserving Proposed lifecycle and active-PR, non-protected-main maturity. +ADR 0016 belongs to the active BAP lifecycle feature branch. ADR 0113 belongs to the active WebDriver BiDi screen-area ownership successor. ADR 0114 belongs to the Browser Session lifecycle successor for issue #312. Indexing them makes the branch documentation graph complete while preserving 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 or ADR 0113 from Proposed or assert implementation 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, ADR 0113, or ADR 0114 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. diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md new file mode 100644 index 000000000..66336ac88 --- /dev/null +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -0,0 +1,95 @@ +# Browser Session lifecycle authority trace + +- Status: IMPLEMENTED_ON_ACTIVE_PR +- Owning bounded context: `originweave-browser-session` +- Governing proposal: ADR 0114 +- Requirement owner: issue #312 +- Integration prerequisites: #229 presentation-ownership witnesses; canonical browser/sandbox owner path under #212/#148 + +## Problem and invariant + +Browser-session, user-context/isolation, and browsing-context identifiers are addresses. They are not evidence that the current Browser Session aggregate exclusively owns presentation mutation or cleanup. A retained authority must not regain meaning if a later aggregate reuses the same remote identifiers and local epoch. + +The active implementation now establishes this chain: + +```text +validated BrowserSessionId +→ BrowserSession::start allocates non-reused BrowserSessionIncarnation +→ DisposableContextPort receives session id + incarnation +→ adapter creates fresh task-owned isolation boundary + browsing context +→ adapter returns DisposableIsolationId + BrowsingContextId +→ aggregate records exact handle + monotonic context epoch +→ opaque PresentationMutationAuthority(session, incarnation, isolation, context, epoch) +→ exact authority validation before adapter I/O +→ destruction receives the same incarnation + stored handle +→ adapter proves exact disposable boundary destruction +→ context Destroyed +→ normal BrowserSession::end admitted +``` + +`BrowserSessionIncarnation` is process-local and monotonic. Presentation authority is not persisted across process restart, so restart invalidates outstanding authority rather than requiring a durable counter. Within one running process, the incarnation is checked by the aggregate and passed through the lifecycle port; an adapter that ignores it does not satisfy the ACL contract. + +## Lossless recovery evidence + +`DisposableContextCreateError::CreateFailedClean` is valid only when no disposable browser state exists. `CreateFailedUncertain(Some(isolation))` retains the exact known user-context/isolation identity as `BrowserSessionRecoveryEvidence::PartialCreationIsolation`; `None` remains representable when no identity was obtained. Both uncertain cases enter `RecoveryRequired` and mint no authority. + +Duplicate browsing-context or isolation output stores the complete offending `DisposableContextHandle` as `DuplicateAdapterHandle` before recovery quarantine. OriginWeave deliberately does not auto-destroy duplicate output because ownership may be foreign. Failed or unproven destruction records `UnprovenDestruction` with the exact owned handle. Recovery evidence authorizes no browser command; it exists only for a later reviewed reconciliation path. + +## Orthogonal transport liveness + +Transport liveness is tracked independently from ownership recovery. If transport loss occurs after `RecoveryRequired`, the aggregate keeps `RecoveryRequired`, preserves all recovery evidence, and separately records `transport_lost = true`. The first loss report is observable; repeated reports are idempotent. If loss occurs while `Active`, the lifecycle state becomes `TransportLost` and active context records become uncertain. + +This avoids conflating “ownership uncertain while transport may still be usable for separately authorized reconciliation” with “ownership uncertain and the transport is gone.” + +## Sequential ABA safety + +The sequential ABA hostile case is explicit: aggregate A creates `(S,U,C,epoch=1)`, proves destruction, and ends. Aggregate B later starts with the same external `S`; the adapter may return the same `U/C`, and B also begins at local epoch 1. A's retained authority must still fail before any B adapter I/O. B receives a different `BrowserSessionIncarnation`, and only B's newly minted authority is accepted. + +The port also receives the incarnation on create/destroy. This closes the prior gap where an aggregate-only nonce could protect token comparison while the browser adapter still keyed destruction by aliasable raw identifiers. + +## Standards trace + +The design dossier references the 9 September 2026 WebDriver BiDi Working Draft. A user context has a user-context id set on creation. `browser.createUserContext` creates it, `browsingContext.create` can create a browsing context inside it, and `browser.removeUserContext` removes the selected user context after closing its navigables. + +OriginWeave does not turn that protocol identifier into policy authority or assume historical non-reuse after removal. `DisposableIsolationId` remains lifecycle addressability. A successful command ACK is insufficient evidence that the disposable boundary is actually gone. + +The active `originweave-bidi` adapter remains separately runtime-qualified against its documented 3 September 2026 revision. Tracking the 9 September publication here does not silently repin that runtime contract. + +## Source and executable evidence + +| Invariant | Source / test | +|---|---| +| independent Browser Session bounded context | `crates/originweave-browser-session/`; `tests/test_browser_session_lifecycle_contract.py` | +| raw context cannot mint authority | `BrowserSession::presentation_authority`; `disposable_creation_is_the_only_raw_context_entry_to_authority` | +| authority includes non-reused BrowserSessionIncarnation | `PresentationMutationAuthority`; `sequential_incarnation_reuse_rejects_stale_authority` | +| lifecycle port receives the same incarnation | `DisposableContextPort`; `stale_authority_cannot_cross_sequential_session_incarnations` | +| lossless recovery evidence for known partial identity | `BrowserSessionRecoveryEvidence`; `creation_failure_preserves_known_recovery_identity` | +| duplicate adapter handle retained without speculative cleanup | `BrowserSession::create_disposable_context`; `duplicate_adapter_output_preserves_offending_handle` | +| unproven destruction retains exact handle | `BrowserSession::destroy_disposable_context`; `destroy_failure_requires_recovery_before_any_new_authority` | +| transport liveness remains orthogonal to recovery | `BrowserSession::record_transport_loss`; `destroy_failure_retains_handle_and_transport_loss_orthogonally` | +| sequential ABA authority is rejected before I/O | `BrowserSession::context_for_authority_mut`; `stale_authority_cannot_cross_sequential_session_incarnations` | +| normal end requires proved destruction | `BrowserSession::end`; `normal_end_requires_proven_destruction_and_ignores_late_transport_report` | +| incarnation exhaustion fails closed | `allocate_incarnation`; `incarnation_allocator_fails_closed_before_wrap` | + +Earlier exact-head evidence remains historical only. Exact `ab04f9522e97e1ecd6d914c48cb6f77f087eac3b` was repository GREEN in CI `34463908909` after repairing repository-contract drift, but it still contained the three Browser Session defects above. + +The sequential ABA RED was then captured on exact `ec145963ad8fe19c9416f2b3856b94660082dbf7` in CI `34469580144`: Python repository contracts and canonical formatting passed; the Rust `Run tests` step failed at the newly added hostile sequential-incarnation test. That RED is the causal predecessor for the incarnation-aware domain/port repair. No earlier GREEN transfers to the repaired successor. + +Protected-main integration is required before capability maturity can be promoted beyond `IMPLEMENTED_ON_ACTIVE_PR`. + +## Buyer acceptance still open + +This slice does not yet prove: + +- actual WebDriver BiDi `browser.createUserContext`/`browsingContext.create` integration and incarnation-scoped mapping; +- observed `browser.removeUserContext` post-condition for the exact owned boundary; +- a separately authorized reconciliation service consuming `BrowserSessionRecoveryEvidence`; +- Browser Session authority conversion into BiDi presentation/screen-area private witnesses; +- pinned Chromium post-condition observation after presentation mutation; +- crash/process-restart reconciliation of uncertain disposable contexts; +- 3/3 complete #299 Agent Task browser trials; +- protected-main release, SBOM, provenance, reproducibility, or rollback evidence. + +## Reference + +Browser Testing and Tools Working Group. (2026, September 9). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/ diff --git a/docs/uml/browser-session-lifecycle-authority.md b/docs/uml/browser-session-lifecycle-authority.md new file mode 100644 index 000000000..5b171cfbf --- /dev/null +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -0,0 +1,101 @@ +# Browser Session lifecycle authority + +This diagram describes the active-PR domain contract for issue #312. It is not evidence that a WebDriver BiDi or Chromium adapter already implements the port. + +```mermaid +sequenceDiagram + autonumber + participant C as Application service + participant S as BrowserSession aggregate + participant P as DisposableContextPort + participant B as Browser adapter (planned) + + C->>S: start(valid BrowserSessionId) + S->>S: allocate BrowserSessionIncarnation + C->>S: create_disposable_context(port) + S->>S: reserve monotonic context epoch + S->>P: create_disposable_context(session_id, incarnation) + P->>B: create fresh isolation boundary + browsing context + B-->>P: unique isolation id + BrowsingContextId or typed create error + P-->>S: DisposableContextHandle + S->>S: register exact handle + Active epoch + S-->>C: PresentationMutationAuthority(session, incarnation, isolation, context, epoch) + + Note over C,S: Raw BrowserSessionId/BrowsingContextId/user-context id cannot mint authority. + + C->>S: advance_context_epoch(context_id) + S->>S: replace epoch; old authority becomes stale + S-->>C: new opaque authority carrying same incarnation + isolation + + C->>S: destroy_disposable_context(authority, port) + S->>S: validate exact session/incarnation/isolation/context/epoch before I/O + S->>P: destroy_disposable_context(session_id, incarnation, stored handle) + P->>B: remove exact owned isolation boundary + B-->>P: observed destruction post-condition or DisposableContextDestroyError + P-->>S: success + S->>S: context = Destroyed + C->>S: end() + S->>S: require every owned context Destroyed + S-->>C: Ended +``` + +`BrowserSessionIncarnation` separates two sequential aggregate lifecycles even when the browser or adapter later reuses the same external session, user-context/isolation, browsing-context, and local epoch values. The incarnation is checked by authority validation and reaches the lifecycle port. It is therefore not merely an aggregate-local nonce that the adapter can ignore. + +For a WebDriver BiDi adapter, `DisposableIsolationId` maps to the user-context id created by `browser.createUserContext`. That protocol id remains lifecycle addressability rather than OriginWeave policy authority. Creation and destruction expose distinct typed errors. + +## Recovery and transport state + +```mermaid +stateDiagram-v2 + [*] --> Active + Active --> Active: fresh isolation + context / authority minted + Active --> Active: context epoch advanced / prior authority stale + Active --> Active: exact owned isolation destruction proved + Active --> Active: DisposableContextCreateError::CreateFailedClean + Active --> RecoveryRequired: CreateFailedUncertain / retain known partial isolation + Active --> RecoveryRequired: duplicate output / retain offending handle + Active --> RecoveryRequired: DisposableContextDestroyError / cleanup unproven + Active --> Ended: all owned contexts Destroyed + end + Active --> TransportLost: browser transport lost + RecoveryRequired --> RecoveryRequired: transport_lost = true / preserve recovery evidence + Ended --> [*] + RecoveryRequired --> [*] + TransportLost --> [*] + + note right of RecoveryRequired + BrowserSessionRecoveryEvidence retains known + partial identity, duplicate handle, or exact + unproven-destruction handle. It grants no I/O. + end note + + note right of TransportLost + Transport liveness is orthogonal to ownership + recovery. Duplicate loss reports are idempotent. + end note +``` + +## Sequential ABA hostile case + +```mermaid +sequenceDiagram + autonumber + participant A as BrowserSession A + participant B as BrowserSession B + participant P as Lifecycle port + + A->>A: start(S) => incarnation A + A->>P: create(S, incarnation A) + P-->>A: U, C + A->>P: destroy(S, incarnation A, U/C) + A->>A: end() + + B->>B: start(S) => incarnation B + B->>P: create(S, incarnation B) + P-->>B: same U, same C + Note over A,B: both local context epochs may equal 1 + B->>B: validate retained authority A + B-->>A: AuthorityMismatch before adapter I/O + B->>P: destroy with authority B + incarnation B +``` + +`RecoveryRequired` and `TransportLost` remain terminal for normal authority in this slice. A later reconciliation design may inspect `BrowserSessionRecoveryEvidence`, but it must not reconstruct cleanup authority from raw identifiers or treat command ACK as proof of destruction. diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py new file mode 100644 index 000000000..6e4487988 --- /dev/null +++ b/tests/test_browser_session_lifecycle_contract.py @@ -0,0 +1,128 @@ +"""Repository contracts for Browser Session presentation authority.""" + +from __future__ import annotations + +import pathlib +import tomllib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +CRATE = ROOT / "crates/originweave-browser-session" + + +class BrowserSessionLifecycleContractTests(unittest.TestCase): + """Keep presentation mutation authority in an explicit Browser Session domain.""" + + def test_browser_session_is_an_independent_workspace_boundary(self) -> None: + """Browser Session authority must not be hidden in a driver adapter.""" + + workspace = tomllib.loads((ROOT / "Cargo.toml").read_text(encoding="utf-8")) + self.assertIn( + "crates/originweave-browser-session", + workspace["workspace"]["members"], + ) + package = tomllib.loads((CRATE / "Cargo.toml").read_text(encoding="utf-8")) + self.assertEqual( + package["dependencies"], + {"originweave-core": {"path": "../originweave-core"}}, + ) + + def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: + """Raw driver identifiers must never become caller-mintable authority tokens.""" + + source = (CRATE / "src/lib.rs").read_text(encoding="utf-8") + self.assertIn("pub struct BrowserSession", source) + self.assertIn("pub trait DisposableContextPort", source) + self.assertIn("pub struct DisposableIsolationId", source) + self.assertIn("pub struct DisposableContextHandle", source) + self.assertIn("pub struct BrowserSessionIncarnation", source) + self.assertIn("pub struct PresentationMutationAuthority", source) + self.assertIn("pub enum BrowserSessionRecoveryEvidence", source) + self.assertIn("BrowserSessionState::RecoveryRequired", source) + self.assertIn("pub enum DisposableContextCreateError", source) + self.assertIn("pub enum DisposableContextDestroyError", source) + self.assertNotIn("pub enum DisposableContextPortError", source) + self.assertIn("CreateFailedClean", source) + self.assertIn("CreateFailedUncertain", source) + self.assertIn("PartialCreationIsolation", source) + self.assertIn("DuplicateAdapterHandle", source) + self.assertIn("UnprovenDestruction", source) + self.assertIn("create_disposable_context", source) + self.assertIn("advance_context_epoch", source) + self.assertIn("record_transport_loss", source) + self.assertIn("transport_is_lost", source) + self.assertIn("recovery_evidence", source) + self.assertIn("user-context identifier", source) + self.assertIn("Reconstructing cleanup authority", source) + self.assertIn("sequential_incarnation_reuse_rejects_stale_authority", source) + + authority_impl = source.split("impl PresentationMutationAuthority", 1)[1].split( + "enum OwnedContextState", 1 + )[0] + self.assertNotIn("pub fn new", authority_impl) + self.assertNotIn("pub const fn new", authority_impl) + + def test_hostile_recovery_and_reincarnation_fixtures_remain_external(self) -> None: + """Recovery and sequential reuse invariants must be executable outside crate internals.""" + + destroy_hostile = ( + CRATE / "tests/destroy_failure_requires_recovery.rs" + ).read_text(encoding="utf-8") + reincarnation_hostile = ( + CRATE / "tests/sequential_incarnation_reuse.rs" + ).read_text(encoding="utf-8") + self.assertIn( + "destroy_failure_requires_recovery_before_any_new_authority", + destroy_hostile, + ) + self.assertIn("BrowserSessionRecoveryEvidence::UnprovenDestruction", destroy_hostile) + self.assertIn("assert!(session.record_transport_loss());", destroy_hostile) + self.assertIn("assert!(!session.record_transport_loss());", destroy_hostile) + self.assertIn( + "stale_authority_cannot_cross_sequential_session_incarnations", + reincarnation_hostile, + ) + self.assertIn("assert_ne!(session_a.incarnation(), session_b.incarnation());", reincarnation_hostile) + self.assertIn("assert!(port_b.destroy_incarnations.is_empty());", reincarnation_hostile) + + def test_architecture_decision_and_traceability_are_explicit(self) -> None: + """Disposable ownership must remain a Proposed, standards-traced active-PR claim.""" + + adr = (ROOT / "docs/adr/0114-browser-session-disposable-context-authority.md").read_text( + encoding="utf-8" + ) + trace = (ROOT / "docs/traceability/browser-session-lifecycle-authority.md").read_text( + encoding="utf-8" + ) + uml = (ROOT / "docs/uml/browser-session-lifecycle-authority.md").read_text( + encoding="utf-8" + ) + self.assertIn("Status: Proposed", adr) + self.assertIn("WD-webdriver-bidi-20260909", adr) + self.assertIn("RecoveryRequired", adr) + self.assertIn("BrowserSessionIncarnation", adr) + self.assertIn("BrowserSessionRecoveryEvidence", adr) + self.assertIn("DisposableContextCreateError", adr) + self.assertIn("DisposableContextDestroyError", adr) + self.assertIn("CreateFailedClean", adr) + self.assertIn("CreateFailedUncertain", adr) + self.assertIn("transport liveness", adr) + self.assertIn("sequential", adr) + self.assertIn("unproven destruction", adr) + self.assertIn("IMPLEMENTED_ON_ACTIVE_PR", trace) + self.assertIn("RecoveryRequired", trace) + self.assertIn("BrowserSessionIncarnation", trace) + self.assertIn("lossless recovery evidence", trace) + self.assertIn("transport liveness", trace) + self.assertIn("sequential ABA", trace) + self.assertIn("command ACK", trace) + self.assertIn("PresentationMutationAuthority", uml) + self.assertIn("BrowserSessionIncarnation", uml) + self.assertIn("RecoveryRequired", uml) + self.assertIn("transport_lost", uml) + self.assertIn("DisposableContextDestroyError / cleanup unproven", uml) + self.assertNotIn("IMPLEMENTED_ON_PROTECTED_MAIN", trace) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 44f1ffe41..818c14339 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -29,6 +29,7 @@ def test_workspace_declares_all_independently_reusable_crates(self) -> None: "crates/originweave-evidence", "crates/originweave-fingerprint", "crates/originweave-bidi", + "crates/originweave-browser-session", }, )