diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 7e7ef86bc..66f5753c5 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -8,9 +8,12 @@ #![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 { @@ -18,8 +21,10 @@ pub enum BrowserSessionState { Active, /// Every owned context was destroyed and the session was ended normally. Ended, - /// The browser transport was lost; remaining contexts have uncertain cleanup state. + /// 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. @@ -27,17 +32,21 @@ pub enum BrowserSessionState { 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 could not create the requested isolated context. + /// 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 isolation boundary, session, context, or epoch. + /// 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, @@ -45,11 +54,19 @@ pub enum BrowserSessionError { ActiveContextRemains, } -/// Bounded failure reported by the adapter port used for disposable context lifecycle I/O. +/// 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 DisposableContextPortError { - /// Creation of a fresh disposable context failed. - CreateFailed, +pub enum DisposableContextDestroyError { /// Destruction of an owned disposable context failed or could not be proven. DestroyFailed, } @@ -95,6 +112,24 @@ impl DisposableIsolationId { } } +/// 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 @@ -129,31 +164,51 @@ impl DisposableContextHandle { } } +/// 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. /// -/// `create_disposable_context` must create a fresh isolation boundary and context owned exclusively -/// by the supplied Browser Session. The returned [`DisposableIsolationId`] must be non-aliasing for -/// the lifetime of that boundary; for WebDriver BiDi this means a one-to-one mapping to the unique -/// user-context identifier returned by `browser.createUserContext`. An implementation that merely -/// returns an existing/shared context violates this port contract. +/// `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 isolation boundary carried by the supplied -/// handle and return success only after the adapter has proved that the task-owned boundary is gone. -/// Reconstructing cleanup authority from `(BrowserSessionId, BrowsingContextId)` is forbidden, and a -/// command acknowledgement alone is insufficient destruction evidence. +/// `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 the Browser Session. + /// Create one fresh disposable isolation boundary and browsing context for this incarnation. fn create_disposable_context( &mut self, browser_session: BrowserSessionId, - ) -> Result; + incarnation: BrowserSessionIncarnation, + ) -> Result; - /// Destroy the exact disposable isolation boundary represented by this handle. + /// 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<(), DisposableContextPortError>; + ) -> Result<(), DisposableContextDestroyError>; } /// Monotonic identity for one owned browsing-context authority epoch. @@ -170,14 +225,13 @@ impl BrowserContextEpoch { /// Opaque proof that Browser Session currently owns presentation mutation for one context epoch. /// -/// The fields are private and no public constructor exists. A caller can obtain this value only after -/// the Browser Session aggregate has successfully created a disposable isolation boundary through its -/// lifecycle port, or after that already-owned context advances to a new epoch. The isolation identity -/// prevents two aggregate incarnations that reuse external session/context identifiers from aliasing -/// each other's mutation or destruction authority when their disposable boundaries are distinct. +/// 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, @@ -190,6 +244,12 @@ impl PresentationMutationAuthority { 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 { @@ -224,32 +284,40 @@ struct OwnedContextRecord { } /// Aggregate root for disposable browser-context lifecycle and presentation mutation authority. -/// -/// The aggregate never accepts a remote/WebDriver context string as authority. A context enters the -/// owned set only through [`BrowserSession::create_disposable_context`], which invokes the lifecycle -/// port before minting an opaque [`PresentationMutationAuthority`]. #[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. /// - /// The transport identity may be reused by a later aggregate incarnation; it therefore does not - /// participate alone in disposable ownership. Per-context authority additionally carries the - /// adapter-proved non-aliasing isolation identity. - #[must_use] - pub fn start(id: BrowserSessionId) -> Self { - Self { + /// 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. @@ -258,41 +326,76 @@ impl BrowserSession { 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. - /// - /// Epoch capacity is reserved before external creation so an exhausted aggregate never creates an - /// untrackable context. Epoch identifiers may therefore have gaps after failed creation or rejected - /// duplicate adapter output. Duplicate browser or isolation identities are rejected without cleanup - /// because a port that violates the fresh-boundary contract may have returned another owner's state. pub fn create_disposable_context( &mut self, port: &mut P, ) -> Result { self.require_active()?; - let epoch = self.reserve_epoch()?; - let handle = port - .create_disposable_context(self.id) - .map_err(|_error| BrowserSessionError::ContextCreationFailed)?; + 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, &handle, epoch); + let authority = Self::authority_for(self.id, self.incarnation, &handle, epoch); self.contexts.insert( browsing_context, OwnedContextRecord { @@ -305,9 +408,6 @@ impl BrowserSession { } /// Return current presentation authority for an already-owned active context. - /// - /// A raw context identity that was not created through this aggregate cannot enter the authority - /// path and fails closed with [`BrowserSessionError::ContextNotOwned`]. pub fn presentation_authority( &self, browsing_context: BrowsingContextId, @@ -318,69 +418,74 @@ impl BrowserSession { .get(&browsing_context) .filter(|record| record.state == OwnedContextState::Active) .ok_or(BrowserSessionError::ContextNotOwned)?; - Ok(Self::authority_for(self.id, &record.handle, record.epoch)) + Ok(Self::authority_for( + self.id, + self.incarnation, + &record.handle, + record.epoch, + )) } /// Advance one active owned context to a new authority epoch. - /// - /// Navigation, renderer replacement, or another lifecycle boundary can call this transition to - /// invalidate every previously issued token while preserving disposable-context ownership. Epoch - /// identifiers are monotonic authority identities rather than gap-free business counters. pub fn advance_context_epoch( &mut self, browsing_context: BrowsingContextId, ) -> Result { self.require_active()?; - let next = self.reserve_epoch()?; + 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(self.id, &record.handle, next)) + Ok(Self::authority_for( + browser_session, + incarnation, + &record.handle, + next, + )) } /// Destroy the disposable isolation boundary covered by the supplied exact-epoch authority. - /// - /// Authority is validated before any adapter I/O. Failed or unproven destruction moves the - /// context to an uncertain terminal state so its old authority cannot be reused. OriginWeave does - /// not interpret an adapter ACK as destruction proof. pub fn destroy_disposable_context( &mut self, authority: &PresentationMutationAuthority, port: &mut P, ) -> Result<(), BrowserSessionError> { - let handle = self.context_for_authority(authority)?.handle.clone(); - let result = port.destroy_disposable_context(self.id, &handle); - let record = self - .contexts - .get_mut(&authority.browsing_context) - .ok_or(BrowserSessionError::ContextNotOwned)?; - match result { + 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(_error) => { + 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 and invalidate all still-active context authority. + /// Record browser transport loss independently from ownership-recovery state. /// - /// Returns `true` only for the first transition to `TransportLost`; repeated reports are idempotent. + /// 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.state != BrowserSessionState::Active { + if self.transport_lost || self.state == BrowserSessionState::Ended { return false; } - self.state = BrowserSessionState::TransportLost; - for record in self.contexts.values_mut() { - if record.state == OwnedContextState::Active { - record.state = OwnedContextState::Uncertain; - } + self.transport_lost = true; + if self.state == BrowserSessionState::Active { + self.state = BrowserSessionState::TransportLost; + self.mark_active_contexts_uncertain(); } true } @@ -407,46 +512,72 @@ impl BrowserSession { } } - fn reserve_epoch(&mut self) -> Result { - let epoch = BrowserContextEpoch(self.next_epoch); - self.next_epoch = self - .next_epoch - .checked_add(1) - .ok_or(BrowserSessionError::EpochExhausted)?; - Ok(epoch) - } - 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( - &self, + fn context_for_authority_mut( + &mut self, authority: &PresentationMutationAuthority, - ) -> Result<&OwnedContextRecord, BrowserSessionError> { + ) -> Result<&mut OwnedContextRecord, BrowserSessionError> { self.require_active()?; - if authority.browser_session != self.id { + if authority.browser_session != self.id || authority.incarnation != self.incarnation { return Err(BrowserSessionError::AuthorityMismatch); } let record = self .contexts - .get(&authority.browsing_context) + .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 { + 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)] @@ -457,10 +588,12 @@ mod tests { #[derive(Debug)] struct TestPort { next_handle: DisposableContextHandle, - fail_create: bool, + create_error: Option, fail_destroy: bool, create_calls: usize, destroy_calls: usize, + create_incarnations: Vec, + destroy_incarnations: Vec, destroyed_isolations: Vec, } @@ -471,10 +604,12 @@ mod tests { isolation_id(isolation), context_id(context), ), - fail_create: false, + create_error: None, fail_destroy: false, create_calls: 0, destroy_calls: 0, + create_incarnations: Vec::new(), + destroy_incarnations: Vec::new(), destroyed_isolations: Vec::new(), } } @@ -484,24 +619,27 @@ mod tests { fn create_disposable_context( &mut self, _browser_session: BrowserSessionId, - ) -> Result { + incarnation: BrowserSessionIncarnation, + ) -> Result { self.create_calls += 1; - if self.fail_create { - Err(DisposableContextPortError::CreateFailed) - } else { - Ok(self.next_handle.clone()) + 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<(), DisposableContextPortError> { + ) -> Result<(), DisposableContextDestroyError> { self.destroy_calls += 1; + self.destroy_incarnations.push(incarnation); self.destroyed_isolations.push(context.isolation.clone()); if self.fail_destroy { - Err(DisposableContextPortError::DestroyFailed) + Err(DisposableContextDestroyError::DestroyFailed) } else { Ok(()) } @@ -520,6 +658,10 @@ mod tests { 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!( @@ -540,25 +682,29 @@ mod tests { ); 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 = BrowserSession::start(session_id(1)); + let mut session = session(1); let mut port = TestPort::new(10, "isolation-10"); - assert_eq!(session.id(), session_id(1)); - assert_eq!(session.state(), BrowserSessionState::Active); + 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_calls, 1); + 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); @@ -569,32 +715,92 @@ mod tests { } #[test] - fn creation_failure_duplicate_ids_and_epoch_exhaustion_fail_closed() { - let mut failed_session = BrowserSession::start(session_id(2)); - let mut failed_port = TestPort::new(20, "isolation-20"); - failed_port.fail_create = true; + 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!( - failed_session.create_disposable_context(&mut failed_port), + 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) + ); + } - let mut duplicate_session = BrowserSession::start(session_id(3)); - let mut first_port = TestPort::new(30, "isolation-30-a"); - duplicate_session - .create_disposable_context(&mut first_port) + #[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 mut duplicate_context = TestPort::new(30, "isolation-30-b"); + 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_session.create_disposable_context(&mut duplicate_context), + duplicate_context_session.create_disposable_context(&mut duplicate_context_port), Err(BrowserSessionError::DuplicateBrowsingContext) ); - let mut duplicate_isolation = TestPort::new(31, "isolation-30-a"); assert_eq!( - duplicate_session.create_disposable_context(&mut duplicate_isolation), + 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 + )] + ); + } - let mut exhausted_session = BrowserSession::start(session_id(4)); + #[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!( @@ -605,12 +811,34 @@ mod tests { } #[test] - fn epoch_advance_invalidates_old_and_cross_session_authority() { - let mut session = BrowserSession::start(session_id(5)); + 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"); @@ -619,187 +847,167 @@ mod tests { session.destroy_disposable_context(&old, &mut port), Err(BrowserSessionError::AuthorityMismatch) ); - - let mut foreign = BrowserSession::start(session_id(6)); - 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(&new, &mut foreign_port), - Err(BrowserSessionError::AuthorityMismatch) - ); - session .destroy_disposable_context(&new, &mut port) .expect("destroy current epoch"); - assert_eq!(port.destroy_calls, 1); + assert_eq!(port.destroy_incarnations, vec![session.incarnation()]); assert_eq!( session.presentation_authority(context_id(50)), Err(BrowserSessionError::ContextNotOwned) ); - assert_eq!( - session.advance_context_epoch(context_id(50)), - Err(BrowserSessionError::ContextNotOwned) - ); assert_eq!( session.destroy_disposable_context(&new, &mut port), Err(BrowserSessionError::ContextNotOwned) ); - assert_eq!(port.destroy_calls, 1); } #[test] - fn two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary() { - let shared_session = session_id(12); - let shared_context = context_id(120); - let mut session_a = BrowserSession::start(shared_session); - let mut session_b = BrowserSession::start(shared_session); - let mut port_a = TestPort::new(120, "user-context-a"); - let mut port_b = TestPort::new(120, "user-context-b"); + 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("owner A context"); + .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("owner B context"); - assert_eq!(authority_a.browsing_context(), shared_context); - assert_eq!(authority_b.browsing_context(), shared_context); - assert_ne!(authority_a.isolation(), authority_b.isolation()); - + .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 destroys only its isolation boundary"); + .expect("B destroy"); assert_eq!(port_b.destroy_calls, 1); - assert_eq!( - port_b.destroyed_isolations, - vec![isolation_id("user-context-b")] - ); - assert_ne!(&port_b.destroyed_isolations[0], authority_a.isolation()); - } - - #[test] - fn unknown_internal_authority_cannot_trigger_destroy_io() { - let mut session = BrowserSession::start(session_id(11)); - let mut port = TestPort::new(110, "isolation-110"); - let unknown = PresentationMutationAuthority { - browser_session: session_id(11), - isolation: isolation_id("isolation-111"), - browsing_context: context_id(111), - context_epoch: BrowserContextEpoch(1), - }; - - assert_eq!( - session.destroy_disposable_context(&unknown, &mut port), - Err(BrowserSessionError::ContextNotOwned) - ); - assert_eq!(port.destroy_calls, 0); } #[test] - fn destroy_failure_quarantines_authority_and_transport_loss_is_idempotent() { - let mut session = BrowserSession::start(session_id(7)); - let mut port = TestPort::new(70, "isolation-70"); + 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!(port.destroy_calls, 1); + assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); assert_eq!( - session.presentation_authority(context_id(70)), - Err(BrowserSessionError::ContextNotOwned) - ); - assert_eq!( - session.end(), - Err(BrowserSessionError::ActiveContextRemains) + 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.state(), BrowserSessionState::TransportLost); assert_eq!( session.create_disposable_context(&mut port), Err(BrowserSessionError::SessionNotActive) ); assert_eq!( - session.presentation_authority(context_id(70)), + session.presentation_authority(context_id(90)), Err(BrowserSessionError::SessionNotActive) ); assert_eq!( - session.advance_context_epoch(context_id(70)), + session.advance_context_epoch(context_id(90)), Err(BrowserSessionError::SessionNotActive) ); assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); } #[test] - fn successful_destruction_is_required_before_normal_end() { - let mut session = BrowserSession::start(session_id(8)); - let mut port = TestPort::new(80, "isolation-80"); + 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.end(), - Err(BrowserSessionError::ActiveContextRemains) - ); - session - .destroy_disposable_context(&authority, &mut port) - .expect("proven destruction"); - session.end().expect("all owned contexts destroyed"); - assert_eq!(session.state(), BrowserSessionState::Ended); - assert_eq!( - session.presentation_authority(context_id(80)), - Err(BrowserSessionError::SessionNotActive) - ); - assert_eq!( - session.advance_context_epoch(context_id(80)), + session.destroy_disposable_context(&authority, &mut port), Err(BrowserSessionError::SessionNotActive) ); - assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); + assert_eq!(port.destroy_calls, 0); } #[test] - fn transport_loss_invalidates_still_active_contexts() { - let mut session = BrowserSession::start(session_id(9)); - let mut port = TestPort::new(90, "isolation-90"); + 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!(session.record_transport_loss()); assert_eq!( - session.destroy_disposable_context(&authority, &mut port), - Err(BrowserSessionError::SessionNotActive) + session.end(), + Err(BrowserSessionError::ActiveContextRemains) ); - assert_eq!(port.destroy_calls, 0); + 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 advance_context_epoch_rejects_unknown_and_exhausted_contexts() { - let mut session = BrowserSession::start(session_id(10)); - assert_eq!( - session.advance_context_epoch(context_id(100)), - Err(BrowserSessionError::ContextNotOwned) - ); - - let mut port = TestPort::new(101, "isolation-101"); - session - .create_disposable_context(&mut port) - .expect("owned context"); - session.next_epoch = u64::MAX; - assert_eq!( - session.advance_context_epoch(context_id(101)), - Err(BrowserSessionError::EpochExhausted) - ); + 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/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index 31c964b4f..345071fd6 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -5,105 +5,121 @@ ## Context -OriginWeave's WebDriver BiDi presentation adapter requires opaque ownership witnesses before it can plan viewport/device-pixel-ratio, timezone, or screen-area mutation. That closes an adapter-level gap: a caller that merely knows a browsing-context identifier cannot overwrite another owner's presentation state and later clear it to an implementation default. +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 remaining gap is upstream of the adapter. A production Browser Session must establish why a context is exclusively OriginWeave-owned before any presentation-mutation authority can be issued. The first Browser Session implementation bound authority to `(BrowserSessionId, BrowsingContextId, local epoch)`, but those values can be reused by separate aggregate incarnations. Two aggregates that receive the same external session/context identifiers and both start at epoch 1 can therefore alias unless the disposable lifecycle carries a separate non-aliasing isolation identity through mutation validation and destruction I/O. +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. -The 9 September 2026 WebDriver BiDi Working Draft provides a standards-aligned isolation identity. A user context has a user-context id defined as a unique string set when the user context is created. `browser.createUserContext` creates a new user context, `browsingContext.create` can create a browsing context inside it, and `browser.removeUserContext` removes that user context after closing its navigables. These protocol operations are adapter capabilities; they do not themselves define OriginWeave policy authority, and a command acknowledgement alone is not cleanup proof. +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 -- Remote-issued browser-session and browsing-context identifiers are addressability, not mutation authority. -- Reuse of external session/context identifiers across aggregate incarnations must not create authority aliasing. -- Shared or attached human contexts must never acquire disposable-owner semantics by implication. -- Presentation reset must not destroy a predecessor override owned by another task/session. -- Destruction I/O must be scoped by the exact disposable isolation boundary, not reconstructed from aliasable session/context identifiers. -- Navigation, renderer replacement, crash, cleanup failure, and transport loss must invalidate stale authority. -- The Browser Session domain must remain independent of WebDriver BiDi, CDP, MCP, and LLM policy decisions. -- An adapter acknowledgement is not a successful cleanup post-condition. +- 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. -## Assumptions and authority boundaries +## Decision -`originweave-browser-session` owns Browser Session lifecycle state, owned-context membership, monotonic context epochs, validated disposable-isolation identity, and opaque presentation-mutation authority. It consumes validated `BrowserSessionId` and `BrowsingContextId` values from `originweave-core`. +Introduce `originweave-browser-session` as an independent Rust bounded context and retain ADR status `Proposed` until protected-main and real-browser acceptance exist. -A narrow `DisposableContextPort` is the anti-corruption boundary to a future browser adapter. The port must return a `DisposableContextHandle` containing the browsing-context address and a live-lifetime non-aliasing `DisposableIsolationId`. For WebDriver BiDi, the adapter proof obligation is a one-to-one mapping from that isolation id to the specification-defined unique user-context id returned by fresh user-context creation. The same handle must scope destruction; reconstructing cleanup authority from `(BrowserSessionId, BrowsingContextId)` is forbidden. +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. -`DisposableIsolationId` is addressability and lifecycle identity, not policy or presentation authority. Callers can validate an identifier value, but they cannot mint `PresentationMutationAuthority`; only the Browser Session aggregate can bind a port-created isolation boundary to a context epoch and issue the opaque authority token. +## Alternatives considered -The implementation deliberately does not convert `PresentationMutationAuthority` into the WebDriver BiDi crate's private presentation/screen-area witnesses. That bridge belongs to a later integration slice after both sides' contracts are reviewed. It also does not claim real-Chromium cleanup evidence. +### Treat any known context as owned -## Options considered +Rejected. It restores the authority-confusion defect and allows one task to clear another task's state. -### A. Treat any known browsing context as owned +### Depend only on browser-issued isolation identity -Rejected. It recreates the original authority-confusion defect and allows one task to erase another task's predecessor state. +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. -### B. Add only an aggregate-local incarnation or epoch +### Add an aggregate-only random or monotonic nonce -Rejected as insufficient. An incarnation field can prevent one aggregate from accepting another aggregate's token, but if adapter destruction is still addressed only by reused session/context identifiers, a valid token from aggregate B can still cause the adapter to destroy aggregate A's boundary. The non-aliasing identity therefore has to reach the port boundary itself. +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. -### C. Snapshot every predecessor presentation override and restore it exactly +### Persist authority generations globally -Deferred. Exact predecessor capture can support reusable/attached contexts later, but today OriginWeave does not have a complete standard protocol snapshot for every governed presentation surface. Partial restoration would be a false safety claim. +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. -### D. Own a disposable isolation lifecycle and issue opaque authority only after creation +### Treat every uncertain lifecycle failure as transport loss -Selected. The Browser Session records a port-proved non-aliasing isolation identity together with its browsing context and epoch. A WebDriver BiDi adapter should map that identity one-to-one to a fresh user context and remove that exact user context during cleanup. This keeps raw driver identifiers as addresses while carrying lifecycle ownership to the destruction boundary. +Rejected. Ownership uncertainty and transport liveness answer different operational questions. Collapsing them loses information needed for safe reconciliation. -## Decision +### 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. -Introduce `originweave-browser-session` as an independent Rust bounded context with these invariants: +### Snapshot and restore every predecessor presentation override -1. `BrowserSession` is the aggregate root. It begins `Active` and may end normally only after every owned disposable context has proven destruction. -2. A context enters the aggregate's owned set only after `DisposableContextPort::create_disposable_context` succeeds with a `DisposableContextHandle`. Supplying a raw `BrowsingContextId` never creates ownership. -3. The handle contains both the browsing-context address and a `DisposableIsolationId` that the adapter contract requires to be non-aliasing for the live lifetime of the isolation boundary. A WebDriver BiDi adapter maps it one-to-one to the unique user-context id. -4. Successful owned-context creation mints a non-caller-constructible `PresentationMutationAuthority` bound to the exact browser session transport identity, disposable isolation identity, browsing context, and context epoch. -5. Two aggregates may reuse the same external `BrowserSessionId`, `BrowsingContextId`, and local epoch without sharing authority when their disposable isolation identities differ. Foreign isolation authority is rejected before adapter I/O. -6. Advancing the context epoch invalidates previously issued authority. Adapter integration must use this transition at navigation/renderer lifecycle boundaries that invalidate the prior authority scope. -7. Destruction requires exact current authority and passes the stored `DisposableContextHandle` back to the port. A stale, foreign-session, foreign-isolation, unknown, already-destroyed, or uncertain context fails closed before destruction I/O. -8. If destruction cannot be proved, the context becomes `Uncertain` and its authority is invalidated. Normal session end is prohibited. -9. Transport loss moves the Browser Session to `TransportLost`, marks still-active owned contexts uncertain, and prevents further authority issuance. -10. Epoch sequence numbers are monotonic authority identities, not business counters; gaps are allowed after failed creation or rejected duplicate adapter output. +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 -Browser Session ownership becomes a domain fact carried through the adapter lifecycle instead of a convention reconstructed from transport identifiers. This gives the future BiDi/Chromium bridge a legitimate place to mint presentation witnesses without making raw driver identifiers authoritative. +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 -The Browser Session domain relies on an explicit adapter proof obligation for global live-lifetime non-aliasing of `DisposableIsolationId`. For WebDriver BiDi that proof is the standard's unique user-context identifier plus adapter conformance tests that preserve the mapping and remove the exact user context. A generic random adapter token without a verified one-to-one browser lifecycle mapping is not sufficient. +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. -The slice remains incomplete for buyer acceptance. No real Chromium user-context adapter, presentation-witness bridge, observed cleanup receipt, crash-recovery reconciliation, or #299 full browser replay is claimed here. +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. -## Failure and degraded behavior +## Tests and exact evidence -Creation failure produces no authority. Duplicate browsing-context or disposable-isolation identities returned inside one aggregate are rejected and are not automatically destroyed, because a port that violates the fresh-boundary contract may have returned another owner's state. Cross-aggregate aliasing is prevented by requiring authority and destruction to carry the distinct isolation identity. Destruction failure and transport loss quarantine the affected lifecycle rather than assuming cleanup. Once a Browser Session is `Ended` or `TransportLost`, creation, authority lookup, destruction, and normal end transitions that require an active session fail closed. +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. -## Security / privacy / governance impact +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. -Disposable context ownership reduces cross-task presentation-state interference and is compatible with isolated Agent Task profiles. It is not a substitute for Chromium sandboxing, egress policy, origin capability policy, Keyverse secret handling, or evidence retention controls. Those remain with their canonical owners. +`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. -No page-controlled value, secret, provider/model choice, LLM result, raw browser-session id, or raw browsing-context id can mint Browser Session presentation authority. +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. -## Tests and acceptance evidence +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. -The owning crate tests hostile raw-context lookup, creation failure, duplicate adapter output, epoch exhaustion, stale authority, cross-session authority, cleanup failure, transport loss, unknown context, epoch advancement, successful destroy-before-end behavior, and a two-aggregate alias case. In the hostile alias case, both aggregates deliberately reuse the same external session and browsing-context identifiers at the same local epoch but receive distinct disposable isolation identities; aggregate B must reject aggregate A's authority before adapter I/O, while B's own authority destroys only B's isolation handle. +## Buyer acceptance still open -Repository contracts require the bounded context to be a workspace member, keep ADR 0114 indexed, and preserve the non-aliasing port contract. Exact-head CI, Clippy, rustdoc, function/line/region/branch coverage and independent review remain required before integration. Real-browser acceptance is deferred to a later adapter slice and must prove unique user-context creation, page-observed mutation, exact-boundary cleanup/destruction and post-cleanup isolation in pinned Chromium; command ACK alone is not success. +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 -This is additive. Until a reviewed adapter bridge consumes the new authority, existing presentation code remains fail closed behind its private ownership witnesses. Rollback removes the new crate, workspace/lockfile entries, tests and Proposed ADR without changing protected Chromium or central workflow policy. +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 using the runtime-qualified protocol contract and prove the one-to-one `DisposableIsolationId` mapping. -- Define the narrow conversion/ACL from `PresentationMutationAuthority` to BiDi presentation/screen-area ownership witnesses without exposing public constructors. -- Specify observed user-context destruction/reconciliation after browser crash or transport loss. -- Replay #299 with three complete real-Chromium trials after the canonical sandbox/runtime owner path is usable. -- Evaluate exact predecessor capture/restore only if attached/reusable contexts become a buyer requirement. +- 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 WebDriver/Chromium gains a complete, queryable and exactly restorable predecessor-state contract for all governed presentation surfaces, or if OriginWeave adopts another isolation primitive with equivalent non-aliasing ownership and destruction evidence. Do not replace disposable ownership with raw context identity. +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 diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index 03a4b342e..66336ac88 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -8,33 +8,52 @@ ## Problem and invariant -Browser-session and browsing-context identifiers are addresses. They are not evidence that the current Browser Session aggregate exclusively owns presentation mutation or cleanup. They may also be reused across separate aggregate incarnations, so an aggregate-local epoch does not by itself prevent cross-aggregate authority aliasing. +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 establishes this fail-closed chain: +The active implementation now establishes this chain: ```text validated BrowserSessionId -→ BrowserSession::start -→ DisposableContextPort creates a fresh task-owned isolation boundary + browsing context +→ 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 isolation handle + monotonic context epoch -→ opaque PresentationMutationAuthority(session, isolation, context, epoch) -→ exact-authority validation before adapter I/O -→ destruction receives the stored isolation handle, not reconstructed session/context authority +→ 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 state Destroyed -→ normal BrowserSession::end is admitted +→ context Destroyed +→ normal BrowserSession::end admitted ``` -A raw `BrowsingContextId`, stale epoch, foreign session, foreign isolation, unknown context, destruction failure, or lost transport cannot enter the successful chain. Destruction failure and transport loss invalidate active authority rather than treating a remote acknowledgement as cleanup evidence. +`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 latest published WebDriver BiDi Working Draft at the time of this decision is 9 September 2026. A user context has a user-context id defined as a unique string set on creation. The browser module defines `browser.createUserContext`; `browsingContext.create` accepts a `userContext`; and `browser.removeUserContext` removes the selected user context after closing its navigables. +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 make the protocol identifier itself a policy authority. `DisposableIsolationId` is lifecycle addressability carried through the domain so cleanup cannot be reconstructed from aliasable session/context identifiers. A WebDriver BiDi implementation of `DisposableContextPort` must map the isolation id one-to-one to the specification-defined unique user-context id and must prove removal of that exact boundary. An unchecked random adapter token without that browser-lifecycle mapping does not satisfy the port contract. A successful command ACK is insufficient evidence that the disposable boundary is actually gone. +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 runtime-qualified against its separately documented 3 September 2026 revision. Tracking the 9 September publication here does not silently repin that runtime contract. +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 @@ -42,25 +61,32 @@ The active `originweave-bidi` adapter remains runtime-qualified against its sepa |---|---| | 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 is session/isolation/context/epoch bound | `PresentationMutationAuthority`; `epoch_advance_invalidates_old_and_cross_session_authority` | -| same external session/context/epoch cannot cross aggregate isolation | `BrowserSession::context_for_authority`; `two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary` | -| destruction is scoped by stored isolation handle | `DisposableContextPort::destroy_disposable_context`; `two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary` | -| adapter duplicate fails closed | `BrowserSession::create_disposable_context`; `creation_failure_duplicate_ids_and_epoch_exhaustion_fail_closed` | -| cleanup failure invalidates authority | `BrowserSession::destroy_disposable_context`; `destroy_failure_quarantines_authority_and_transport_loss_is_idempotent` | -| transport loss invalidates active contexts | `BrowserSession::record_transport_loss`; `transport_loss_invalidates_still_active_contexts` | -| normal end requires proved destruction | `BrowserSession::end`; `successful_destruction_is_required_before_normal_end` | +| 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. -Exact-head CI/coverage is required before this dossier can be cited as verified active-PR implementation. Protected-main integration is required before any capability maturity is promoted beyond `IMPLEMENTED_ON_ACTIVE_PR`. +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 one-to-one `DisposableIsolationId` mapping; -- observed `browser.removeUserContext` post-condition for the exact owned isolation boundary; -- conversion of domain authority into the BiDi presentation/screen-area private witnesses; +- 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; -- browser crash/restart reconciliation of uncertain disposable contexts; +- 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. diff --git a/docs/uml/browser-session-lifecycle-authority.md b/docs/uml/browser-session-lifecycle-authority.md index 279f08270..5b171cfbf 100644 --- a/docs/uml/browser-session-lifecycle-authority.md +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -1,6 +1,6 @@ # Browser Session lifecycle authority -This diagram describes the active-PR domain contract introduced for issue #312. It is not evidence that a WebDriver BiDi or Chromium adapter already implements the port. +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 @@ -11,26 +11,27 @@ sequenceDiagram 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) + S->>P: create_disposable_context(session_id, incarnation) P->>B: create fresh isolation boundary + browsing context - B-->>P: unique isolation id + BrowsingContextId + B-->>P: unique isolation id + BrowsingContextId or typed create error P-->>S: DisposableContextHandle - S->>S: register exact isolation handle + Active epoch - S-->>C: PresentationMutationAuthority(session, isolation, context, epoch) + S->>S: register exact handle + Active epoch + S-->>C: PresentationMutationAuthority(session, incarnation, isolation, context, epoch) - Note over C,S: Raw BrowserSessionId/BrowsingContextId cannot mint authority. + 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 isolation + S-->>C: new opaque authority carrying same incarnation + isolation C->>S: destroy_disposable_context(authority, port) - S->>S: validate exact session/isolation/context/epoch before I/O - S->>P: destroy_disposable_context(session_id, stored handle) + 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 + B-->>P: observed destruction post-condition or DisposableContextDestroyError P-->>S: success S->>S: context = Destroyed C->>S: end() @@ -38,29 +39,63 @@ sequenceDiagram S-->>C: Ended ``` -Two aggregates may receive the same external `BrowserSessionId`, the same `BrowsingContextId`, and the same local epoch. Their authority must still differ because the adapter-created disposable isolation identity is non-aliasing for its live lifetime. Passing aggregate A's authority into aggregate B therefore fails before adapter I/O; aggregate B's own destroy call carries B's stored isolation handle instead of reconstructing cleanup authority from the shared transport identifiers. +`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, the isolation identity is expected to map one-to-one to the specification-defined unique user-context id created by `browser.createUserContext`, and cleanup targets that exact user context. The protocol id remains lifecycle addressability, not OriginWeave policy authority. +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. -## Failure state machine +## Recovery and transport state ```mermaid stateDiagram-v2 [*] --> Active - Active --> Active: fresh isolation + context created / authority minted + Active --> Active: fresh isolation + context / authority minted Active --> Active: context epoch advanced / prior authority stale Active --> Active: exact owned isolation destruction proved - Active --> Active: create rejected / no authority - Active --> Active: destroy fails / context becomes Uncertain + 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 Active - Normal end is rejected while any - Active or Uncertain context remains. + 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 ``` -`TransportLost` is terminal for this aggregate. Recovery of an uncertain remote browser boundary requires a separate reconciliation design; reopening the same aggregate would allow stale authority to regain meaning and is therefore not part of this slice. +`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 index 46a633e5d..6e4487988 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -35,16 +35,26 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: 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( - "two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary", - source, - ) + self.assertIn("sequential_incarnation_reuse_rejects_stale_authority", source) authority_impl = source.split("impl PresentationMutationAuthority", 1)[1].split( "enum OwnedContextState", 1 @@ -52,6 +62,29 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: 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.""" @@ -66,9 +99,28 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: ) 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)