diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 66f5753c5..fa2982909 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -8,11 +8,23 @@ #![deny(missing_docs)] use std::collections::BTreeMap; +use std::fmt; use std::sync::atomic::{AtomicU64, Ordering}; use originweave_core::{BrowserSessionId, BrowsingContextId}; static NEXT_BROWSER_SESSION_INCARNATION: AtomicU64 = AtomicU64::new(1); +static ABANDONED_BOUND_SESSIONS: AtomicU64 = AtomicU64::new(0); + +/// Return the number of bound Browser Sessions abandoned with unresolved remote ownership. +/// +/// This is a process-local, non-I/O operability signal. It deliberately does not claim that remote +/// browser cleanup happened and is not a substitute for persisting exact recovery evidence before a +/// process exits. +#[must_use] +pub fn abandoned_bound_session_count() -> u64 { + ABANDONED_BOUND_SESSIONS.load(Ordering::Relaxed) +} /// Current lifecycle state of one Browser Session aggregate. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -174,43 +186,244 @@ pub enum BrowserSessionRecoveryEvidence { PartialCreationIsolation(DisposableIsolationId), /// A create call returned a complete handle that aliased an already-owned context or isolation. DuplicateAdapterHandle(DisposableContextHandle), + /// A complete create result could not be settled with the bound adapter after domain validation. + UnsettledAdapterHandle(DisposableContextHandle), /// Destruction of this exact owned handle failed or could not be proven. UnprovenDestruction(DisposableContextHandle), + /// A recovery condition elsewhere in the session made this active owned handle uncertain. + RecoveryRequiredOwnedHandle(DisposableContextHandle), + /// Transport loss made this previously active owned handle uncertain. + TransportLossOwnedHandle(DisposableContextHandle), +} + +/// Opaque Browser Session-issued request for one disposable-context creation attempt. +/// +/// There is deliberately no public constructor. A request is created only inside a +/// [`BoundBrowserSession`], after Browser Session has validated that the aggregate is active. Raw +/// session, incarnation, context, isolation, or adapter-selected identifiers cannot recreate it. +#[derive(Debug)] +pub struct DisposableContextCreateRequest { + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + attempt_epoch: BrowserContextEpoch, +} + +impl DisposableContextCreateRequest { + /// Return the Browser Session transport identity for adapter addressability. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the non-reused Browser Session incarnation for adapter lifecycle mapping. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the unique context epoch reserved for this create attempt. + #[must_use] + pub const fn attempt_epoch(&self) -> BrowserContextEpoch { + self.attempt_epoch + } +} + +/// Domain disposition for one completed disposable-context create attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisposableContextCreateDisposition { + /// The returned handle passed Browser Session ownership validation and may become authorizing. + Accepted, + /// The returned handle failed Browser Session ownership validation and must remain non-authorizing. + Rejected, +} + +/// Opaque Browser Session-issued completion for one exact create attempt. +/// +/// The adapter may stage remote protocol state while executing a create request, but it must not +/// promote that state into an authorizing binding until it receives an `Accepted` completion for the +/// same session incarnation and attempt epoch. `Rejected` candidates are recovery/quarantine evidence +/// only. There is deliberately no public constructor. +#[derive(Debug)] +pub struct DisposableContextCreateCompletion { + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + attempt_epoch: BrowserContextEpoch, + disposition: DisposableContextCreateDisposition, +} + +impl DisposableContextCreateCompletion { + /// Return the Browser Session transport identity for adapter correlation. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the Browser Session incarnation for adapter correlation. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the create-attempt epoch that this completion settles. + #[must_use] + pub const fn attempt_epoch(&self) -> BrowserContextEpoch { + self.attempt_epoch + } + + /// Return whether Browser Session accepted or rejected the created candidate. + #[must_use] + pub const fn disposition(&self) -> DisposableContextCreateDisposition { + self.disposition + } +} + +/// Failure while settling one exact create attempt with the bound lifecycle adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisposableContextCreateCompletionError { + /// The adapter could not prove that the exact pending create attempt reached the requested state. + CompletionFailed, +} + +/// Opaque Browser Session-issued request for destruction of one exact owned disposable context. +/// +/// There is deliberately no public constructor. The bound aggregate creates this request only after +/// validating the supplied presentation authority against current ownership. A caller cannot rebuild +/// cleanup authority from raw browser identifiers. +#[derive(Debug)] +pub struct DisposableContextDestroyRequest { + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + context: DisposableContextHandle, +} + +impl DisposableContextDestroyRequest { + /// Return the Browser Session transport identity for adapter addressability. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the non-reused Browser Session incarnation for adapter lifecycle mapping. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the exact domain handle whose remote isolation boundary must be destroyed. + #[must_use] + pub const fn context(&self) -> &DisposableContextHandle { + &self.context + } } /// Port implemented by a reviewed browser adapter for disposable context lifecycle operations. /// -/// `incarnation` is domain-issued and must participate in the adapter's lifecycle mapping; ignoring it -/// would reintroduce sequential ABA aliasing. `create_disposable_context` must create a fresh isolation -/// boundary and context owned exclusively by the supplied Browser Session incarnation. For WebDriver -/// BiDi the isolation identity maps one-to-one to the user-context identifier returned by -/// `browser.createUserContext`. +/// The port never self-asserts an instance identifier. Instead, Browser Session consumes one concrete +/// port value into [`BoundBrowserSession`]. Public lifecycle methods then use only that owned port, so a +/// caller cannot swap a second adapter instance into create or destroy after binding. The port receives +/// only aggregate-issued request values with private construction paths. /// -/// [`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`]. +/// For WebDriver BiDi, creation should map the isolation identity one-to-one to the user-context +/// identifier returned by `browser.createUserContext`. [`DisposableContextCreateError::CreateFailedClean`] +/// is allowed only when the adapter proves that no disposable state was created. If a user-context +/// identity is already known when later creation or verification becomes uncertain, the adapter must +/// return it inside [`DisposableContextCreateError::CreateFailedUncertain`]. /// -/// `destroy_disposable_context` must destroy the exact boundary carried by the supplied handle and +/// `destroy_disposable_context` must destroy the exact boundary carried by the supplied request and /// return success only after destruction is proven. Reconstructing cleanup authority from raw driver /// identifiers is forbidden, and a command acknowledgement alone is insufficient evidence. pub trait DisposableContextPort { - /// Create one fresh disposable isolation boundary and browsing context for this incarnation. + /// Create one fresh disposable isolation boundary and browsing context for this authorized request. fn create_disposable_context( &mut self, - browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, + request: &DisposableContextCreateRequest, ) -> Result; - /// Destroy the exact disposable isolation boundary represented by this handle and incarnation. + /// Settle the exact create attempt after Browser Session validates the returned domain handle. + /// + /// An adapter must keep a successful remote create result non-authorizing until this completion + /// accepts the matching attempt. A rejected attempt must remain non-authorizing and be retained + /// only for recovery/quarantine processing. + fn complete_disposable_context_creation( + &mut self, + completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError>; + + /// Destroy the exact disposable isolation boundary represented by this authorized request. fn destroy_disposable_context( &mut self, - browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, - context: &DisposableContextHandle, + request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError>; } +/// Opaque aggregate-authorized request for one purpose-bounded adapter operation. +/// +/// The caller supplies only the adapter-defined operation value. Browser Session validates the +/// accompanying presentation authority first and privately binds the operation to the exact owned +/// context before the consumed adapter can observe it. There is deliberately no public constructor. +pub struct AuthorizedContextOperationRequest { + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + context: DisposableContextHandle, + operation: O, +} + +impl AuthorizedContextOperationRequest { + /// Return the Browser Session transport identity for adapter addressability. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the non-reused Browser Session incarnation for adapter lifecycle correlation. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the exact currently owned context validated before adapter I/O. + #[must_use] + pub const fn context(&self) -> &DisposableContextHandle { + &self.context + } + + /// Return the adapter-defined purpose-bounded operation payload. + #[must_use] + pub const fn operation(&self) -> &O { + &self.operation + } +} + +/// Failure from executing an aggregate-authorized operation through the consumed adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthorizedContextOperationError { + /// Browser Session rejected the authority before adapter I/O. + BrowserSession(BrowserSessionError), + /// The bound adapter attempted the authorized operation and returned its bounded failure. + Adapter(E), +} + +/// Adapter extension for purpose-bounded operations that must use the exact consumed adapter. +/// +/// Browser Session remains protocol-agnostic: the adapter owns the operation, output, and error +/// types. The wrapper only proves current ownership and routes the opaque request to the same concrete +/// adapter instance used for lifecycle creation and destruction. Implementations must not treat the +/// request as permission to mutate any other context. +pub trait AuthorizedContextOperationPort: DisposableContextPort { + /// Adapter-defined operation vocabulary, such as a reviewed BiDi presentation command. + type Operation; + /// Adapter-defined successful result. + type Output; + /// Adapter-defined bounded operation failure. + type Error; + + /// Execute one aggregate-authorized operation against the exact context carried by the request. + fn execute_authorized_context_operation( + &mut self, + request: &AuthorizedContextOperationRequest, + ) -> Result; +} + /// Monotonic identity for one owned browsing-context authority epoch. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct BrowserContextEpoch(u64); @@ -226,8 +439,9 @@ 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 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. +/// Browser Session has created a disposable boundary through its bound 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, @@ -295,6 +509,47 @@ pub struct BrowserSession { recovery_evidence: Vec, } +/// Browser Session composed with the one lifecycle-port instance allowed to mutate its remote state. +/// +/// Construction consumes both the aggregate and the concrete port. The port is not exposed mutably and +/// no public Browser Session lifecycle method accepts an arbitrary port parameter. This makes adapter +/// ownership structural rather than dependent on a caller-selected scalar or an adapter callback. +#[must_use = "destroy owned browser state and finish the session, or hand unresolved ownership to recovery"] +pub struct BoundBrowserSession

{ + session: BrowserSession, + port: P, +} + +impl

fmt::Debug for BoundBrowserSession

{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BoundBrowserSession") + .field("browser_session", &self.session.id) + .field("incarnation", &self.session.incarnation) + .field("state", &self.session.state) + .field("transport_lost", &self.session.transport_lost) + .field("owned_context_count", &self.session.contexts.len()) + .field( + "recovery_evidence_count", + &self.session.recovery_evidence.len(), + ) + .field("port", &"") + .finish() + } +} + +impl

Drop for BoundBrowserSession

{ + fn drop(&mut self) { + if self.session.has_unresolved_remote_ownership() { + let _ = ABANDONED_BOUND_SESSIONS.fetch_update( + Ordering::Relaxed, + Ordering::Relaxed, + |value| Some(value.saturating_add(1)), + ); + } + } +} + impl BrowserSession { /// Start an active Browser Session around an already validated transport session identity. /// @@ -320,6 +575,18 @@ impl BrowserSession { }) } + /// Consume this aggregate and one concrete lifecycle port into a linear bound session. + /// + /// Binding invokes no adapter method. All subsequent create/destroy I/O is reachable only through + /// the owned port inside the returned wrapper. + #[must_use] + pub fn bind_lifecycle_port(self, port: P) -> BoundBrowserSession

{ + BoundBrowserSession { + session: self, + port, + } + } + /// Return this aggregate's browser-session transport identity. #[must_use] pub const fn id(&self) -> BrowserSessionId { @@ -350,63 +617,6 @@ impl BrowserSession { &self.recovery_evidence } - /// Create and register one disposable context, then mint authority for its first epoch. - pub fn create_disposable_context( - &mut self, - port: &mut P, - ) -> Result { - self.require_active()?; - let epoch = reserve_epoch(&mut self.next_epoch)?; - let handle = match port.create_disposable_context(self.id, self.incarnation) { - Ok(handle) => handle, - Err(DisposableContextCreateError::CreateFailedClean) => { - return Err(BrowserSessionError::ContextCreationFailed); - } - Err(DisposableContextCreateError::CreateFailedUncertain(isolation)) => { - if let Some(isolation) = isolation { - self.recovery_evidence.push( - BrowserSessionRecoveryEvidence::PartialCreationIsolation(isolation), - ); - } - self.enter_recovery_required(); - return Err(BrowserSessionError::ContextCreationUncertain); - } - }; - - if self - .contexts - .values() - .any(|record| record.handle.isolation == handle.isolation) - { - self.recovery_evidence - .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( - handle, - )); - self.enter_recovery_required(); - return Err(BrowserSessionError::DuplicateDisposableIsolation); - } - if self.contexts.contains_key(&handle.browsing_context) { - self.recovery_evidence - .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( - handle, - )); - self.enter_recovery_required(); - return Err(BrowserSessionError::DuplicateBrowsingContext); - } - - let browsing_context = handle.browsing_context; - let authority = Self::authority_for(self.id, self.incarnation, &handle, epoch); - self.contexts.insert( - browsing_context, - OwnedContextRecord { - handle, - epoch, - state: OwnedContextState::Active, - }, - ); - Ok(authority) - } - /// Return current presentation authority for an already-owned active context. pub fn presentation_authority( &self, @@ -449,31 +659,6 @@ impl BrowserSession { )) } - /// Destroy the disposable isolation boundary covered by the supplied exact-epoch authority. - pub fn destroy_disposable_context( - &mut self, - authority: &PresentationMutationAuthority, - port: &mut P, - ) -> Result<(), BrowserSessionError> { - let browser_session = self.id; - let incarnation = self.incarnation; - let record = self.context_for_authority_mut(authority)?; - let handle = record.handle.clone(); - match port.destroy_disposable_context(browser_session, incarnation, &handle) { - Ok(()) => { - record.state = OwnedContextState::Destroyed; - Ok(()) - } - Err(DisposableContextDestroyError::DestroyFailed) => { - record.state = OwnedContextState::Uncertain; - self.recovery_evidence - .push(BrowserSessionRecoveryEvidence::UnprovenDestruction(handle)); - self.enter_recovery_required(); - Err(BrowserSessionError::ContextDestructionFailed) - } - } - } - /// Record browser transport loss independently from ownership-recovery state. /// /// Returns `true` only for the first observed transport loss. If ownership was already uncertain, @@ -484,6 +669,16 @@ impl BrowserSession { } self.transport_lost = true; if self.state == BrowserSessionState::Active { + self.recovery_evidence.extend( + self.contexts + .values() + .filter(|record| record.state == OwnedContextState::Active) + .map(|record| { + BrowserSessionRecoveryEvidence::TransportLossOwnedHandle( + record.handle.clone(), + ) + }), + ); self.state = BrowserSessionState::TransportLost; self.mark_active_contexts_uncertain(); } @@ -504,6 +699,131 @@ impl BrowserSession { Ok(()) } + fn create_disposable_context_with_port( + &mut self, + port: &mut P, + ) -> Result { + self.require_active()?; + let epoch = reserve_epoch(&mut self.next_epoch)?; + let request = DisposableContextCreateRequest { + browser_session: self.id, + incarnation: self.incarnation, + attempt_epoch: epoch, + }; + let handle = match port.create_disposable_context(&request) { + 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); + } + }; + + let duplicate_error = if self + .contexts + .values() + .any(|record| record.handle.isolation == handle.isolation) + { + Some(BrowserSessionError::DuplicateDisposableIsolation) + } else if self.contexts.contains_key(&handle.browsing_context) { + Some(BrowserSessionError::DuplicateBrowsingContext) + } else { + None + }; + + if let Some(error) = duplicate_error { + let completion = DisposableContextCreateCompletion { + browser_session: self.id, + incarnation: self.incarnation, + attempt_epoch: epoch, + disposition: DisposableContextCreateDisposition::Rejected, + }; + self.recovery_evidence + .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( + handle.clone(), + )); + if port + .complete_disposable_context_creation(&completion) + .is_err() + { + self.recovery_evidence.push( + BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(handle), + ); + self.enter_recovery_required(); + return Err(BrowserSessionError::ContextCreationUncertain); + } + self.enter_recovery_required(); + return Err(error); + } + + let completion = DisposableContextCreateCompletion { + browser_session: self.id, + incarnation: self.incarnation, + attempt_epoch: epoch, + disposition: DisposableContextCreateDisposition::Accepted, + }; + if port + .complete_disposable_context_creation(&completion) + .is_err() + { + self.recovery_evidence + .push(BrowserSessionRecoveryEvidence::UnsettledAdapterHandle( + handle, + )); + self.enter_recovery_required(); + return Err(BrowserSessionError::ContextCreationUncertain); + } + + let browsing_context = handle.browsing_context; + let authority = Self::authority_for(self.id, self.incarnation, &handle, epoch); + self.contexts.insert( + browsing_context, + OwnedContextRecord { + handle, + epoch, + state: OwnedContextState::Active, + }, + ); + Ok(authority) + } + + fn destroy_disposable_context_with_port( + &mut self, + authority: &PresentationMutationAuthority, + port: &mut P, + ) -> Result<(), BrowserSessionError> { + let browser_session = self.id; + let incarnation = self.incarnation; + let record = self.context_for_authority_mut(authority)?; + let request = DisposableContextDestroyRequest { + browser_session, + incarnation, + context: record.handle.clone(), + }; + match port.destroy_disposable_context(&request) { + Ok(()) => { + record.state = OwnedContextState::Destroyed; + Ok(()) + } + Err(DisposableContextDestroyError::DestroyFailed) => { + record.state = OwnedContextState::Uncertain; + self.recovery_evidence + .push(BrowserSessionRecoveryEvidence::UnprovenDestruction( + request.context, + )); + self.enter_recovery_required(); + Err(BrowserSessionError::ContextDestructionFailed) + } + } + } + fn require_active(&self) -> Result<(), BrowserSessionError> { if self.state == BrowserSessionState::Active { Ok(()) @@ -548,6 +868,29 @@ impl BrowserSession { } fn enter_recovery_required(&mut self) { + let sibling_handles = + self.contexts + .values() + .filter(|record| record.state == OwnedContextState::Active) + .map(|record| record.handle.clone()) + .filter(|handle| { + !self.recovery_evidence.iter().any(|evidence| match evidence { + BrowserSessionRecoveryEvidence::PartialCreationIsolation(_) => false, + BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(existing) + | BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(existing) + | BrowserSessionRecoveryEvidence::UnprovenDestruction(existing) + | BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(existing) + | BrowserSessionRecoveryEvidence::TransportLossOwnedHandle(existing) => { + existing == handle + } + }) + }) + .collect::>(); + self.recovery_evidence.extend( + sibling_handles + .into_iter() + .map(BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle), + ); self.state = BrowserSessionState::RecoveryRequired; self.mark_active_contexts_uncertain(); } @@ -559,6 +902,108 @@ impl BrowserSession { } } } + + fn has_unresolved_remote_ownership(&self) -> bool { + matches!( + self.state, + BrowserSessionState::TransportLost | BrowserSessionState::RecoveryRequired + ) || self.contexts.values().any(|record| { + matches!( + record.state, + OwnedContextState::Active | OwnedContextState::Uncertain + ) + }) + } +} + +impl BoundBrowserSession

{ + /// Return the bound Browser Session for read-only policy and ACL validation. + #[must_use] + pub const fn browser_session(&self) -> &BrowserSession { + &self.session + } + + /// Create one disposable context through the exact port consumed when this session was bound. + pub fn create_disposable_context( + &mut self, + ) -> Result { + self.session + .create_disposable_context_with_port(&mut self.port) + } + + /// Return current presentation authority for an already-owned active context. + pub fn presentation_authority( + &self, + browsing_context: BrowsingContextId, + ) -> Result { + self.session.presentation_authority(browsing_context) + } + + /// Advance one active owned context to a new authority epoch. + pub fn advance_context_epoch( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + self.session.advance_context_epoch(browsing_context) + } + + /// Destroy the exact owned disposable boundary through the bound lifecycle port. + pub fn destroy_disposable_context( + &mut self, + authority: &PresentationMutationAuthority, + ) -> Result<(), BrowserSessionError> { + self.session + .destroy_disposable_context_with_port(authority, &mut self.port) + } + + /// Record browser transport loss without exposing mutable lifecycle-port access. + pub fn record_transport_loss(&mut self) -> bool { + self.session.record_transport_loss() + } + + /// End the Browser Session only after every owned context has proven destruction. + pub fn end(&mut self) -> Result<(), BrowserSessionError> { + self.session.end() + } + + /// Verify normal completion without relinquishing the exact bound lifecycle owner on failure. + /// + /// A rejected finish leaves the wrapper intact so the caller can destroy or reconcile outstanding + /// contexts and retry. After success the aggregate is `Ended`; dropping the wrapper is then inert. + pub fn finish(&mut self) -> Result<(), BrowserSessionError> { + self.session.end() + } +} + +impl BoundBrowserSession

{ + /// Execute one adapter-defined operation through the exact consumed adapter after authority validation. + /// + /// Browser Session validates session incarnation, isolation identity, browsing-context identity, + /// and epoch before the adapter receives the operation. Stale or foreign authority therefore fails + /// before adapter I/O, while the adapter-specific operation vocabulary remains outside this domain. + pub fn execute_authorized_context_operation( + &mut self, + authority: &PresentationMutationAuthority, + operation: P::Operation, + ) -> Result> { + let browser_session = self.session.id; + let incarnation = self.session.incarnation; + let context = self + .session + .context_for_authority_mut(authority) + .map_err(AuthorizedContextOperationError::BrowserSession)? + .handle + .clone(); + let request = AuthorizedContextOperationRequest { + browser_session, + incarnation, + context, + operation, + }; + self.port + .execute_authorized_context_operation(&request) + .map_err(AuthorizedContextOperationError::Adapter) + } } fn reserve_epoch(next_epoch: &mut u64) -> Result { @@ -584,31 +1029,51 @@ fn allocate_incarnation( #[allow(clippy::expect_used)] mod tests { use super::*; + use std::collections::VecDeque; #[derive(Debug)] struct TestPort { - next_handle: DisposableContextHandle, + handles: VecDeque, create_error: Option, fail_destroy: bool, + fail_completion: bool, create_calls: usize, destroy_calls: usize, + create_sessions: Vec, create_incarnations: Vec, + create_attempts: Vec, + create_completions: Vec<( + BrowserSessionId, + BrowserSessionIncarnation, + BrowserContextEpoch, + DisposableContextCreateDisposition, + )>, + destroy_sessions: Vec, destroy_incarnations: Vec, destroyed_isolations: Vec, } impl TestPort { fn new(context: u64, isolation: &str) -> Self { + Self::with_handles(vec![DisposableContextHandle::new( + isolation_id(isolation), + context_id(context), + )]) + } + + fn with_handles(handles: Vec) -> Self { Self { - next_handle: DisposableContextHandle::new( - isolation_id(isolation), - context_id(context), - ), + handles: handles.into(), create_error: None, fail_destroy: false, + fail_completion: false, create_calls: 0, destroy_calls: 0, + create_sessions: Vec::new(), create_incarnations: Vec::new(), + create_attempts: Vec::new(), + create_completions: Vec::new(), + destroy_sessions: Vec::new(), destroy_incarnations: Vec::new(), destroyed_isolations: Vec::new(), } @@ -618,26 +1083,47 @@ mod tests { impl DisposableContextPort for TestPort { fn create_disposable_context( &mut self, - _browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, + request: &DisposableContextCreateRequest, ) -> Result { self.create_calls += 1; - self.create_incarnations.push(incarnation); + self.create_sessions.push(request.browser_session()); + self.create_incarnations.push(request.incarnation()); + self.create_attempts.push(request.attempt_epoch()); match self.create_error.clone() { Some(error) => Err(error), - None => Ok(self.next_handle.clone()), + None => Ok(self + .handles + .pop_front() + .expect("test must provide one handle per successful creation")), + } + } + + fn complete_disposable_context_creation( + &mut self, + completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + self.create_completions.push(( + completion.browser_session(), + completion.incarnation(), + completion.attempt_epoch(), + completion.disposition(), + )); + if self.fail_completion { + Err(DisposableContextCreateCompletionError::CompletionFailed) + } else { + Ok(()) } } fn destroy_disposable_context( &mut self, - _browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, - context: &DisposableContextHandle, + request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { self.destroy_calls += 1; - self.destroy_incarnations.push(incarnation); - self.destroyed_isolations.push(context.isolation.clone()); + self.destroy_sessions.push(request.browser_session()); + self.destroy_incarnations.push(request.incarnation()); + self.destroyed_isolations + .push(request.context().isolation.clone()); if self.fail_destroy { Err(DisposableContextDestroyError::DestroyFailed) } else { @@ -688,65 +1174,79 @@ mod tests { } #[test] - fn disposable_creation_is_the_only_raw_context_entry_to_authority() { - let mut session = session(1); - let mut port = TestPort::new(10, "isolation-10"); - assert_eq!(session.id(), session_id(1)); - assert_ne!(session.incarnation().value(), 0); - assert!(!session.transport_is_lost()); - assert!(session.recovery_evidence().is_empty()); - assert_eq!( - session.presentation_authority(context_id(10)), + fn bound_creation_is_the_only_raw_context_entry_to_authority() { + let raw_session = session(1); + assert_eq!(raw_session.id(), session_id(1)); + assert_ne!(raw_session.incarnation().value(), 0); + assert!(!raw_session.transport_is_lost()); + assert!(raw_session.recovery_evidence().is_empty()); + assert_eq!( + raw_session.presentation_authority(context_id(10)), Err(BrowserSessionError::ContextNotOwned) ); - let authority = session - .create_disposable_context(&mut port) + let mut bound = raw_session.bind_lifecycle_port(TestPort::new(10, "isolation-10")); + let authority = bound + .create_disposable_context() .expect("owned disposable context"); - assert_eq!(port.create_incarnations, vec![session.incarnation()]); + assert_eq!(bound.port.create_sessions, vec![session_id(1)]); + assert_eq!( + bound.port.create_incarnations, + vec![bound.browser_session().incarnation()] + ); + assert_eq!(bound.port.create_attempts, vec![BrowserContextEpoch(1)]); + assert_eq!( + bound.port.create_completions, + vec![( + session_id(1), + bound.browser_session().incarnation(), + BrowserContextEpoch(1), + DisposableContextCreateDisposition::Accepted, + )] + ); assert_eq!(authority.browser_session(), session_id(1)); - assert_eq!(authority.incarnation(), session.incarnation()); + assert_eq!( + authority.incarnation(), + bound.browser_session().incarnation() + ); assert_eq!(authority.isolation().as_str(), "isolation-10"); assert_eq!(authority.browsing_context(), context_id(10)); assert_eq!(authority.context_epoch().value(), 1); - assert_eq!( - session.presentation_authority(context_id(10)), - Ok(authority) - ); + assert_eq!(bound.presentation_authority(context_id(10)), Ok(authority)); } #[test] fn creation_failure_preserves_known_recovery_identity() { - let mut clean_session = session(2); let mut clean_port = TestPort::new(20, "isolation-20"); clean_port.create_error = Some(DisposableContextCreateError::CreateFailedClean); + let mut clean = session(2).bind_lifecycle_port(clean_port); assert_eq!( - clean_session.create_disposable_context(&mut clean_port), + clean.create_disposable_context(), Err(BrowserSessionError::ContextCreationFailed) ); - assert_eq!(clean_session.state(), BrowserSessionState::Active); - clean_session.end().expect("clean failure can end"); + assert_eq!(clean.browser_session().state(), BrowserSessionState::Active); + clean.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)); + let mut unknown = session(21).bind_lifecycle_port(unknown_port); assert_eq!( - unknown_session.create_disposable_context(&mut unknown_port), + unknown.create_disposable_context(), Err(BrowserSessionError::ContextCreationUncertain) ); - assert!(unknown_session.recovery_evidence().is_empty()); + assert!(unknown.browser_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(), ))); + let mut known_session = session(22).bind_lifecycle_port(known_port); assert_eq!( - known_session.create_disposable_context(&mut known_port), + known_session.create_disposable_context(), Err(BrowserSessionError::ContextCreationUncertain) ); assert_eq!( - known_session.recovery_evidence(), + known_session.browser_session().recovery_evidence(), &[BrowserSessionRecoveryEvidence::PartialCreationIsolation( known )] @@ -759,248 +1259,329 @@ mod tests { #[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 first_context_handle = + DisposableContextHandle::new(isolation_id("isolation-30-a"), context_id(30)); 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"); + let context_port = TestPort::with_handles(vec![ + first_context_handle.clone(), + duplicate_context_handle.clone(), + ]); + let mut duplicate_context = session(3).bind_lifecycle_port(context_port); + duplicate_context + .create_disposable_context() + .expect("first owned context"); assert_eq!( - duplicate_context_session.create_disposable_context(&mut duplicate_context_port), + duplicate_context.create_disposable_context(), Err(BrowserSessionError::DuplicateBrowsingContext) ); assert_eq!( - duplicate_context_session.recovery_evidence(), - &[BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( - duplicate_context_handle - )] + duplicate_context.browser_session().recovery_evidence(), + &[ + BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(duplicate_context_handle), + BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(first_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 first_isolation_handle = + DisposableContextHandle::new(isolation_id("isolation-31"), context_id(310)); let duplicate_isolation_handle = DisposableContextHandle::new(isolation_id("isolation-31"), context_id(311)); - let mut duplicate_isolation_port = TestPort::new(311, "isolation-31"); + let isolation_port = TestPort::with_handles(vec![ + first_isolation_handle.clone(), + duplicate_isolation_handle.clone(), + ]); + let mut duplicate_isolation = session(31).bind_lifecycle_port(isolation_port); + duplicate_isolation + .create_disposable_context() + .expect("first owned isolation"); assert_eq!( - duplicate_isolation_session.create_disposable_context(&mut duplicate_isolation_port), + duplicate_isolation.create_disposable_context(), Err(BrowserSessionError::DuplicateDisposableIsolation) ); assert_eq!( - duplicate_isolation_session.recovery_evidence(), - &[BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( - duplicate_isolation_handle + duplicate_isolation.browser_session().recovery_evidence(), + &[ + BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(duplicate_isolation_handle), + BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(first_isolation_handle), + ] + ); + } + + #[test] + fn create_completion_failure_preserves_non_authorizing_recovery_evidence() { + let expected = DisposableContextHandle::new(isolation_id("isolation-315"), context_id(315)); + let mut port = TestPort::with_handles(vec![expected.clone()]); + port.fail_completion = true; + let mut bound = session(315).bind_lifecycle_port(port); + + assert_eq!( + bound.create_disposable_context(), + Err(BrowserSessionError::ContextCreationUncertain) + ); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + assert_eq!( + bound.browser_session().recovery_evidence(), + &[BrowserSessionRecoveryEvidence::UnsettledAdapterHandle( + expected )] ); + assert_eq!( + bound.port.create_completions[0].3, + DisposableContextCreateDisposition::Accepted + ); + assert_eq!( + bound.presentation_authority(context_id(315)), + Err(BrowserSessionError::SessionNotActive) + ); + } + + #[test] + fn rejected_create_completion_failure_preserves_duplicate_and_unsettled_evidence() { + let first = DisposableContextHandle::new(isolation_id("isolation-316-a"), context_id(316)); + let duplicate = + DisposableContextHandle::new(isolation_id("isolation-316-b"), context_id(316)); + let mut port = TestPort::with_handles(vec![first.clone(), duplicate.clone()]); + let mut bound = session(316).bind_lifecycle_port(port); + + bound + .create_disposable_context() + .expect("first candidate accepted"); + bound.port.fail_completion = true; + assert_eq!( + bound.create_disposable_context(), + Err(BrowserSessionError::ContextCreationUncertain) + ); + assert_eq!( + bound.browser_session().recovery_evidence(), + &[ + BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(duplicate.clone()), + BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(duplicate), + BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(first), + ] + ); + assert_eq!( + bound.port.create_completions[1].3, + DisposableContextCreateDisposition::Rejected + ); + } + + #[test] + fn bound_port_is_structural_and_not_swappable() { + let approved = TestPort::new(320, "isolation-320"); + let other = TestPort::new(321, "isolation-321"); + let mut bound = session(32).bind_lifecycle_port(approved); + let authority = bound + .create_disposable_context() + .expect("owned context uses consumed port"); + assert_eq!(other.create_calls, 0); + assert_eq!(other.destroy_calls, 0); + bound + .destroy_disposable_context(&authority) + .expect("same structurally bound port destroys context"); + assert_eq!(bound.port.create_calls, 1); + assert_eq!(bound.port.destroy_calls, 1); } #[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"); + let mut bound = session(4).bind_lifecycle_port(TestPort::new(40, "isolation-40")); + bound.session.next_epoch = u64::MAX; assert_eq!( - exhausted_session.create_disposable_context(&mut unused_port), + bound.create_disposable_context(), Err(BrowserSessionError::EpochExhausted) ); - assert_eq!(unused_port.create_calls, 0); + assert_eq!(bound.port.create_calls, 0); } #[test] fn epoch_exhaustion_prevents_advance_mutation() { - let mut exhausted_session = session(41); - let mut port = TestPort::new(410, "isolation-410"); - let authority = exhausted_session - .create_disposable_context(&mut port) - .expect("owned context"); - exhausted_session.next_epoch = u64::MAX; - assert_eq!( - exhausted_session.advance_context_epoch(context_id(410)), - Err(BrowserSessionError::EpochExhausted) - ); + let mut bound = session(41).bind_lifecycle_port(TestPort::new(410, "isolation-410")); + let authority = bound.create_disposable_context().expect("owned context"); + bound.session.next_epoch = u64::MAX; assert_eq!( - exhausted_session.presentation_authority(context_id(410)), - Ok(authority) + bound.advance_context_epoch(context_id(410)), + Err(BrowserSessionError::EpochExhausted) ); + assert_eq!(bound.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"); + let mut bound = session(5).bind_lifecycle_port(TestPort::new(50, "isolation-50")); + let old = bound.create_disposable_context().expect("owned context"); assert_eq!( - session.advance_context_epoch(context_id(51)), + bound.advance_context_epoch(context_id(51)), Err(BrowserSessionError::ContextNotOwned) ); - let new = session + let new = bound .advance_context_epoch(context_id(50)) .expect("advanced epoch"); assert_eq!(new.context_epoch().value(), 2); assert_eq!( - session.destroy_disposable_context(&old, &mut port), + bound.destroy_disposable_context(&old), Err(BrowserSessionError::AuthorityMismatch) ); - session - .destroy_disposable_context(&new, &mut port) + bound + .destroy_disposable_context(&new) .expect("destroy current epoch"); - assert_eq!(port.destroy_incarnations, vec![session.incarnation()]); assert_eq!( - session.presentation_authority(context_id(50)), + bound.port.destroy_incarnations, + vec![bound.browser_session().incarnation()] + ); + assert_eq!( + bound.presentation_authority(context_id(50)), Err(BrowserSessionError::ContextNotOwned) ); assert_eq!( - session.destroy_disposable_context(&new, &mut port), + bound.destroy_disposable_context(&new), Err(BrowserSessionError::ContextNotOwned) ); } #[test] fn cross_session_and_foreign_isolation_authority_fail_before_io() { - let mut owner = session(6); - let mut owner_port = TestPort::new(60, "isolation-60"); - let authority = owner - .create_disposable_context(&mut owner_port) - .expect("owner context"); - - let mut foreign = session(7); - let mut foreign_port = TestPort::new(60, "isolation-60"); + let mut owner = session(6).bind_lifecycle_port(TestPort::new(60, "isolation-60")); + let authority = owner.create_disposable_context().expect("owner context"); + + let mut foreign = session(7).bind_lifecycle_port(TestPort::new(60, "isolation-60")); foreign - .create_disposable_context(&mut foreign_port) + .create_disposable_context() .expect("foreign context"); assert_eq!( - foreign.destroy_disposable_context(&authority, &mut foreign_port), + foreign.destroy_disposable_context(&authority), Err(BrowserSessionError::AuthorityMismatch) ); - assert_eq!(foreign_port.destroy_calls, 0); + assert_eq!(foreign.port.destroy_calls, 0); let forged = PresentationMutationAuthority { - browser_session: owner.id(), - incarnation: owner.incarnation(), + browser_session: owner.browser_session().id(), + incarnation: owner.browser_session().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), + owner.destroy_disposable_context(&forged), Err(BrowserSessionError::AuthorityMismatch) ); - assert_eq!(owner_port.destroy_calls, 0); + assert_eq!(owner.port.destroy_calls, 0); } #[test] fn sequential_incarnation_reuse_rejects_stale_authority() { let shared_id = session_id(8); - let mut session_a = BrowserSession::start(shared_id).expect("A incarnation"); - let mut port_a = TestPort::new(80, "reused-user-context"); - let authority_a = session_a - .create_disposable_context(&mut port_a) - .expect("A context"); + let mut session_a = BrowserSession::start(shared_id) + .expect("A incarnation") + .bind_lifecycle_port(TestPort::new(80, "reused-user-context")); + let authority_a = session_a.create_disposable_context().expect("A context"); session_a - .destroy_disposable_context(&authority_a, &mut port_a) + .destroy_disposable_context(&authority_a) .expect("A destroy"); session_a.end().expect("A end"); - let mut session_b = BrowserSession::start(shared_id).expect("B incarnation"); - let mut port_b = TestPort::new(80, "reused-user-context"); - let authority_b = session_b - .create_disposable_context(&mut port_b) - .expect("B context"); - assert_ne!(session_a.incarnation(), session_b.incarnation()); + let mut session_b = BrowserSession::start(shared_id) + .expect("B incarnation") + .bind_lifecycle_port(TestPort::new(80, "reused-user-context")); + let authority_b = session_b.create_disposable_context().expect("B context"); + assert_ne!( + session_a.browser_session().incarnation(), + session_b.browser_session().incarnation() + ); assert_eq!( - session_b.destroy_disposable_context(&authority_a, &mut port_b), + session_b.destroy_disposable_context(&authority_a), Err(BrowserSessionError::AuthorityMismatch) ); - assert_eq!(port_b.destroy_calls, 0); + assert_eq!(session_b.port.destroy_calls, 0); session_b - .destroy_disposable_context(&authority_b, &mut port_b) + .destroy_disposable_context(&authority_b) .expect("B destroy"); - assert_eq!(port_b.destroy_calls, 1); + assert_eq!(session_b.port.destroy_calls, 1); } #[test] fn destroy_failure_retains_handle_and_transport_loss_orthogonally() { - let mut session = session(9); - let mut port = TestPort::new(90, "isolation-90"); - let authority = session - .create_disposable_context(&mut port) - .expect("owned context"); let expected_handle = DisposableContextHandle::new(isolation_id("isolation-90"), context_id(90)); + let mut port = TestPort::new(90, "isolation-90"); port.fail_destroy = true; + let mut bound = session(9).bind_lifecycle_port(port); + let authority = bound.create_disposable_context().expect("owned context"); assert_eq!( - session.destroy_disposable_context(&authority, &mut port), + bound.destroy_disposable_context(&authority), Err(BrowserSessionError::ContextDestructionFailed) ); - assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); assert_eq!( - session.recovery_evidence(), + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + assert_eq!( + bound.browser_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!(!bound.browser_session().transport_is_lost()); + assert!(bound.record_transport_loss()); + assert!(bound.browser_session().transport_is_lost()); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + assert!(!bound.record_transport_loss()); assert_eq!( - session.create_disposable_context(&mut port), + bound.create_disposable_context(), Err(BrowserSessionError::SessionNotActive) ); assert_eq!( - session.presentation_authority(context_id(90)), + bound.presentation_authority(context_id(90)), Err(BrowserSessionError::SessionNotActive) ); assert_eq!( - session.advance_context_epoch(context_id(90)), + bound.advance_context_epoch(context_id(90)), Err(BrowserSessionError::SessionNotActive) ); - assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); + assert_eq!(bound.end(), Err(BrowserSessionError::SessionNotActive)); } #[test] fn transport_loss_invalidates_active_contexts_and_is_idempotent() { - let mut session = session(10); - let mut port = TestPort::new(100, "isolation-100"); - let authority = session - .create_disposable_context(&mut port) - .expect("owned context"); - assert!(session.record_transport_loss()); - assert_eq!(session.state(), BrowserSessionState::TransportLost); - assert!(session.transport_is_lost()); - assert!(!session.record_transport_loss()); - assert_eq!( - session.destroy_disposable_context(&authority, &mut port), + let mut bound = session(10).bind_lifecycle_port(TestPort::new(100, "isolation-100")); + let authority = bound.create_disposable_context().expect("owned context"); + assert!(bound.record_transport_loss()); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::TransportLost + ); + assert!(bound.browser_session().transport_is_lost()); + assert!(!bound.record_transport_loss()); + assert_eq!( + bound.destroy_disposable_context(&authority), Err(BrowserSessionError::SessionNotActive) ); - assert_eq!(port.destroy_calls, 0); + assert_eq!(bound.port.destroy_calls, 0); } #[test] fn normal_end_requires_proven_destruction_and_ignores_late_transport_report() { - let mut session = session(11); - let mut port = TestPort::new(110, "isolation-110"); - let authority = session - .create_disposable_context(&mut port) - .expect("owned context"); + let mut bound = session(11).bind_lifecycle_port(TestPort::new(110, "isolation-110")); + let authority = bound.create_disposable_context().expect("owned context"); + assert_eq!(bound.end(), Err(BrowserSessionError::ActiveContextRemains)); + bound + .destroy_disposable_context(&authority) + .expect("proven destruction"); + assert_eq!(bound.port.destroy_sessions, vec![session_id(11)]); assert_eq!( - session.end(), - Err(BrowserSessionError::ActiveContextRemains) + bound.port.destroyed_isolations, + vec![isolation_id("isolation-110")] ); - 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)); + bound.end().expect("normal end"); + assert_eq!(bound.browser_session().state(), BrowserSessionState::Ended); + assert!(!bound.record_transport_loss()); + assert_eq!(bound.end(), Err(BrowserSessionError::SessionNotActive)); } #[test] diff --git a/crates/originweave-browser-session/tests/authorized_context_operation.rs b/crates/originweave-browser-session/tests/authorized_context_operation.rs new file mode 100644 index 000000000..926871c1f --- /dev/null +++ b/crates/originweave-browser-session/tests/authorized_context_operation.rs @@ -0,0 +1,153 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use originweave_browser_session::{ + AuthorizedContextOperationError, AuthorizedContextOperationPort, + AuthorizedContextOperationRequest, BrowserSession, BrowserSessionError, + BrowserSessionIncarnation, DisposableContextCreateCompletion, + DisposableContextCreateCompletionError, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +struct OperationPort { + handle: Option, + operation_calls: Rc>, + observed_operations: Rc>>, + observed_sessions: Rc>>, + observed_incarnations: Rc>>, + fail_operation: Rc>, +} + +impl DisposableContextPort for OperationPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.handle + .take() + .ok_or(DisposableContextCreateError::CreateFailedClean) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Ok(()) + } +} + +impl AuthorizedContextOperationPort for OperationPort { + type Operation = &'static str; + type Output = BrowsingContextId; + type Error = (); + + fn execute_authorized_context_operation( + &mut self, + request: &AuthorizedContextOperationRequest, + ) -> Result { + self.operation_calls.set(self.operation_calls.get() + 1); + self.observed_operations + .borrow_mut() + .push(*request.operation()); + self.observed_sessions + .borrow_mut() + .push(request.browser_session()); + self.observed_incarnations + .borrow_mut() + .push(request.incarnation()); + if self.fail_operation.get() { + Err(()) + } else { + Ok(request.context().browsing_context()) + } + } +} + +#[test] +fn authorized_operation_uses_exact_bound_port_and_rejects_stale_authority_before_io() { + let operation_calls = Rc::new(Cell::new(0)); + let observed_operations = Rc::new(RefCell::new(Vec::new())); + let observed_sessions = Rc::new(RefCell::new(Vec::new())); + let observed_incarnations = Rc::new(RefCell::new(Vec::new())); + let fail_operation = Rc::new(Cell::new(false)); + let context = BrowsingContextId::new(503).expect("valid browsing context"); + let port = OperationPort { + handle: Some(DisposableContextHandle::new( + DisposableIsolationId::parse("operation-user-context-503").expect("valid isolation id"), + context, + )), + operation_calls: Rc::clone(&operation_calls), + observed_operations: Rc::clone(&observed_operations), + observed_sessions: Rc::clone(&observed_sessions), + observed_incarnations: Rc::clone(&observed_incarnations), + fail_operation: Rc::clone(&fail_operation), + }; + let session_id = BrowserSessionId::new(503).expect("valid session id"); + let session = BrowserSession::start(session_id).expect("incarnation capacity"); + let incarnation = session.incarnation(); + let mut bound = session.bind_lifecycle_port(port); + + let authority = bound + .create_disposable_context() + .expect("accepted disposable context"); + assert_eq!( + bound.execute_authorized_context_operation(&authority, "set-viewport"), + Ok(context) + ); + assert_eq!(operation_calls.get(), 1); + assert_eq!(observed_operations.borrow().as_slice(), &["set-viewport"]); + assert_eq!(observed_sessions.borrow().as_slice(), &[session_id]); + assert_eq!(observed_incarnations.borrow().as_slice(), &[incarnation]); + + fail_operation.set(true); + assert_eq!( + bound.execute_authorized_context_operation(&authority, "remote-failure"), + Err(AuthorizedContextOperationError::Adapter(())) + ); + assert_eq!(operation_calls.get(), 2); + fail_operation.set(false); + + let current = bound + .advance_context_epoch(context) + .expect("advance authority epoch"); + assert_eq!( + bound.execute_authorized_context_operation(&authority, "stale-operation"), + Err(AuthorizedContextOperationError::BrowserSession( + BrowserSessionError::AuthorityMismatch + )) + ); + assert_eq!( + operation_calls.get(), + 2, + "stale authority must fail before the bound adapter observes an operation" + ); + + assert_eq!( + bound.execute_authorized_context_operation(¤t, "reconcile-liveness"), + Ok(context) + ); + assert_eq!(operation_calls.get(), 3); + assert_eq!( + observed_operations.borrow().as_slice(), + &["set-viewport", "remote-failure", "reconcile-liveness"] + ); + assert_eq!( + observed_sessions.borrow().as_slice(), + &[session_id, session_id, session_id], + "the purpose-bounded adapter must observe only the bound Browser Session identity" + ); + assert_eq!( + observed_incarnations.borrow().as_slice(), + &[incarnation, incarnation, incarnation], + "the purpose-bounded adapter must observe only the bound Browser Session incarnation" + ); +} diff --git a/crates/originweave-browser-session/tests/bound_session_abandonment.rs b/crates/originweave-browser-session/tests/bound_session_abandonment.rs new file mode 100644 index 000000000..6106b5f24 --- /dev/null +++ b/crates/originweave-browser-session/tests/bound_session_abandonment.rs @@ -0,0 +1,148 @@ +use std::cell::Cell; +use std::rc::Rc; +use std::sync::Mutex; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, DisposableContextCreateCompletion, + DisposableContextCreateCompletionError, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, + abandoned_bound_session_count, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +static ABANDONMENT_COUNTER_LOCK: Mutex<()> = Mutex::new(()); + +struct AbandonmentPort { + handle: Option, + destroy_calls: Rc>, +} + +impl DisposableContextPort for AbandonmentPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.handle + .take() + .ok_or(DisposableContextCreateError::CreateFailedClean) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls.set(self.destroy_calls.get() + 1); + Ok(()) + } +} + +fn port_for(context: u64, destroy_calls: &Rc>) -> AbandonmentPort { + AbandonmentPort { + handle: Some(DisposableContextHandle::new( + DisposableIsolationId::parse(&format!("abandoned-user-context-{context}")) + .expect("valid isolation id"), + BrowsingContextId::new(context).expect("valid browsing context"), + )), + destroy_calls: Rc::clone(destroy_calls), + } +} + +#[test] +fn dropping_unresolved_bound_session_is_observable_without_implicit_browser_io() { + let _guard = ABANDONMENT_COUNTER_LOCK + .lock() + .expect("abandonment counter test lock"); + let destroy_calls = Rc::new(Cell::new(0)); + let before = abandoned_bound_session_count(); + let session = BrowserSession::start(BrowserSessionId::new(504).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(port_for(504, &destroy_calls)); + let _authority = bound + .create_disposable_context() + .expect("accepted disposable context"); + + drop(bound); + + assert_eq!( + destroy_calls.get(), + 0, + "Drop must never pretend synchronous browser cleanup succeeded" + ); + assert!( + abandoned_bound_session_count() > before, + "unresolved bound-session abandonment must be observable to recovery/operability code" + ); +} + +#[test] +fn failed_finish_retains_same_bound_owner_for_cleanup_and_retry() { + let _guard = ABANDONMENT_COUNTER_LOCK + .lock() + .expect("abandonment counter test lock"); + let destroy_calls = Rc::new(Cell::new(0)); + let before = abandoned_bound_session_count(); + let session = BrowserSession::start(BrowserSessionId::new(506).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(port_for(506, &destroy_calls)); + let authority = bound + .create_disposable_context() + .expect("accepted disposable context"); + + assert_eq!( + bound.finish(), + Err(BrowserSessionError::ActiveContextRemains) + ); + assert_eq!( + abandoned_bound_session_count(), + before, + "a failed deliberate finish must retain the bound lifecycle owner instead of dropping it as abandonment" + ); + assert_eq!( + destroy_calls.get(), + 0, + "failed finish validation must not perform implicit browser cleanup" + ); + + bound + .destroy_disposable_context(&authority) + .expect("the same bound lifecycle owner must remain available for cleanup"); + assert_eq!(destroy_calls.get(), 1); + bound + .finish() + .expect("retry succeeds after proven destruction"); + drop(bound); + assert_eq!( + abandoned_bound_session_count(), + before, + "successful retry must leave no abandonment signal" + ); +} + +#[test] +fn proven_destruction_can_finish_without_abandonment_path() { + let _guard = ABANDONMENT_COUNTER_LOCK + .lock() + .expect("abandonment counter test lock"); + let destroy_calls = Rc::new(Cell::new(0)); + let session = BrowserSession::start(BrowserSessionId::new(505).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(port_for(505, &destroy_calls)); + let authority = bound + .create_disposable_context() + .expect("accepted disposable context"); + bound + .destroy_disposable_context(&authority) + .expect("proven destruction"); + bound + .finish() + .expect("end normally after proven destruction"); + assert_eq!(destroy_calls.get(), 1); +} diff --git a/crates/originweave-browser-session/tests/bound_session_debug_redaction.rs b/crates/originweave-browser-session/tests/bound_session_debug_redaction.rs new file mode 100644 index 000000000..edbd99a1c --- /dev/null +++ b/crates/originweave-browser-session/tests/bound_session_debug_redaction.rs @@ -0,0 +1,68 @@ +use std::cell::Cell; +use std::fmt; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, +}; +use originweave_core::BrowserSessionId; + +struct SideEffectingDebugPort { + debug_callbacks: Rc>, +} + +impl fmt::Debug for SideEffectingDebugPort { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.debug_callbacks + .set(self.debug_callbacks.get().saturating_add(1)); + formatter.write_str("adapter-secret-sentinel") + } +} + +impl DisposableContextPort for SideEffectingDebugPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + Err(DisposableContextCreateError::CreateFailedClean) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Ok(()) + } +} + +#[test] +fn bound_session_debug_never_executes_or_exposes_adapter_debug() { + let debug_callbacks = Rc::new(Cell::new(0)); + let port = SideEffectingDebugPort { + debug_callbacks: Rc::clone(&debug_callbacks), + }; + let session = BrowserSession::start(BrowserSessionId::new(502).expect("valid session id")) + .expect("incarnation capacity"); + let bound = session.bind_lifecycle_port(port); + + let rendered = format!("{bound:?}"); + + assert_eq!( + debug_callbacks.get(), + 0, + "formatting a bound session must not execute adapter-owned Debug code" + ); + assert!( + !rendered.contains("adapter-secret-sentinel"), + "bound-session diagnostics must not expose adapter-internal state" + ); +} diff --git a/crates/originweave-browser-session/tests/creation_transaction_completion.rs b/crates/originweave-browser-session/tests/creation_transaction_completion.rs new file mode 100644 index 000000000..e0cb9c49c --- /dev/null +++ b/crates/originweave-browser-session/tests/creation_transaction_completion.rs @@ -0,0 +1,120 @@ +use std::cell::RefCell; +use std::collections::{BTreeMap, VecDeque}; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, BrowserSessionState, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateDisposition, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug, Default)] +struct CreationLedger { + session: Option, + incarnation: Option, + pending: BTreeMap, + accepted: Vec, + rejected: Vec, +} + +#[derive(Debug)] +struct TransactionalPort { + handles: VecDeque, + ledger: Rc>, +} + +impl TransactionalPort { + fn new(handles: Vec, ledger: Rc>) -> Self { + Self { + handles: handles.into(), + ledger, + } + } +} + +impl DisposableContextPort for TransactionalPort { + fn create_disposable_context( + &mut self, + request: &DisposableContextCreateRequest, + ) -> Result { + let handle = self + .handles + .pop_front() + .expect("fixture supplies one handle per create"); + let mut ledger = self.ledger.borrow_mut(); + ledger.session.get_or_insert(request.browser_session()); + ledger.incarnation.get_or_insert(request.incarnation()); + let prior = ledger + .pending + .insert(request.attempt_epoch().value(), handle.clone()); + assert!(prior.is_none(), "create attempts must not collide"); + Ok(handle) + } + + fn complete_disposable_context_creation( + &mut self, + completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + let mut ledger = self.ledger.borrow_mut(); + if ledger.session != Some(completion.browser_session()) + || ledger.incarnation != Some(completion.incarnation()) + { + return Err(DisposableContextCreateCompletionError::CompletionFailed); + } + let attempt = completion.attempt_epoch().value(); + if ledger.pending.remove(&attempt).is_none() { + return Err(DisposableContextCreateCompletionError::CompletionFailed); + } + match completion.disposition() { + DisposableContextCreateDisposition::Accepted => ledger.accepted.push(attempt), + DisposableContextCreateDisposition::Rejected => ledger.rejected.push(attempt), + } + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Ok(()) + } +} + +#[test] +fn accepted_and_rejected_create_candidates_are_correlated_by_exact_attempt() { + let context = BrowsingContextId::new(8010).expect("valid browsing context"); + let first = DisposableContextHandle::new( + DisposableIsolationId::parse("transaction-user-context-a").expect("valid isolation"), + context, + ); + let duplicate_context = DisposableContextHandle::new( + DisposableIsolationId::parse("transaction-user-context-b").expect("valid isolation"), + context, + ); + let ledger = Rc::new(RefCell::new(CreationLedger::default())); + let port = TransactionalPort::new(vec![first, duplicate_context], Rc::clone(&ledger)); + let session = BrowserSession::start(BrowserSessionId::new(801).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(port); + + let accepted = bound + .create_disposable_context() + .expect("first candidate accepted"); + assert_eq!(accepted.context_epoch().value(), 1); + assert_eq!( + bound.create_disposable_context(), + Err(BrowserSessionError::DuplicateBrowsingContext) + ); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + + let ledger = ledger.borrow(); + assert!(ledger.pending.is_empty()); + assert_eq!(ledger.accepted, vec![1]); + assert_eq!(ledger.rejected, vec![2]); +} diff --git a/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs index 669f0723c..e88a1b964 100644 --- a/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs +++ b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs @@ -1,27 +1,37 @@ +use std::cell::Cell; +use std::rc::Rc; + use originweave_browser_session::{ - BrowserSession, BrowserSessionError, BrowserSessionIncarnation, BrowserSessionRecoveryEvidence, - BrowserSessionState, DisposableContextCreateError, DisposableContextDestroyError, - DisposableContextHandle, DisposableContextPort, DisposableIsolationId, + BrowserSession, BrowserSessionError, BrowserSessionRecoveryEvidence, BrowserSessionState, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct FailingDestroyPort { next_handle: DisposableContextHandle, - create_calls: usize, - destroy_calls: usize, + create_calls: Rc>, + destroy_calls: Rc>, } impl FailingDestroyPort { - fn new(context: u64, isolation: &str) -> Result { + fn new( + context: u64, + isolation: &str, + create_calls: Rc>, + destroy_calls: Rc>, + ) -> 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, + create_calls, + destroy_calls, }) } } @@ -29,20 +39,24 @@ impl FailingDestroyPort { impl DisposableContextPort for FailingDestroyPort { fn create_disposable_context( &mut self, - _browser_session: BrowserSessionId, - _incarnation: BrowserSessionIncarnation, + _request: &DisposableContextCreateRequest, ) -> Result { - self.create_calls += 1; + self.create_calls.set(self.create_calls.get() + 1); Ok(self.next_handle.clone()) } + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + fn destroy_disposable_context( &mut self, - _browser_session: BrowserSessionId, - _incarnation: BrowserSessionIncarnation, - _context: &DisposableContextHandle, + _request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { - self.destroy_calls += 1; + self.destroy_calls.set(self.destroy_calls.get() + 1); Err(DisposableContextDestroyError::DestroyFailed) } } @@ -57,46 +71,58 @@ fn destroy_failure_requires_recovery_before_any_new_authority() -> Result<(), &' 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) + let session = BrowserSession::start(session_id) .map_err(|_| "browser session incarnation must be available")?; - let mut failing_port = FailingDestroyPort::new(5010, "user-context-501")?; + let create_calls = Rc::new(Cell::new(0)); + let destroy_calls = Rc::new(Cell::new(0)); + let failing_port = FailingDestroyPort::new( + 5010, + "user-context-501", + Rc::clone(&create_calls), + Rc::clone(&destroy_calls), + )?; + let mut bound = session.bind_lifecycle_port(failing_port); - let authority = session - .create_disposable_context(&mut failing_port) + let authority = bound + .create_disposable_context() .map_err(|_| "fixture disposable context creation must succeed")?; assert_eq!( - session.destroy_disposable_context(&authority, &mut failing_port), + bound.destroy_disposable_context(&authority), Err(BrowserSessionError::ContextDestructionFailed) ); - assert_eq!(failing_port.destroy_calls, 1); - assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); + assert_eq!(destroy_calls.get(), 1); assert_eq!( - session.recovery_evidence(), + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + assert_eq!( + bound.browser_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!(!bound.browser_session().transport_is_lost()); - let mut later_port = FailingDestroyPort::new(5011, "user-context-501-later")?; + assert!(bound.record_transport_loss()); + assert!(bound.browser_session().transport_is_lost()); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + assert!(!bound.record_transport_loss()); assert_eq!( - session.create_disposable_context(&mut later_port), + bound.create_disposable_context(), Err(BrowserSessionError::SessionNotActive) ); - assert_eq!(later_port.create_calls, 0); + assert_eq!(create_calls.get(), 1); assert_eq!( - session.presentation_authority(context_id), + bound.presentation_authority(context_id), Err(BrowserSessionError::SessionNotActive) ); assert_eq!( - session.advance_context_epoch(context_id), + bound.advance_context_epoch(context_id), Err(BrowserSessionError::SessionNotActive) ); - assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); + assert_eq!(bound.end(), Err(BrowserSessionError::SessionNotActive)); Ok(()) } diff --git a/crates/originweave-browser-session/tests/lifecycle_port_authority.rs b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs new file mode 100644 index 000000000..ea5432fcc --- /dev/null +++ b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs @@ -0,0 +1,89 @@ +use std::cell::Cell; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct RecordingPort { + create_calls: Rc>, + destroy_calls: Rc>, +} + +impl RecordingPort { + fn new(create_calls: Rc>, destroy_calls: Rc>) -> Self { + Self { + create_calls, + destroy_calls, + } + } +} + +impl DisposableContextPort for RecordingPort { + fn create_disposable_context( + &mut self, + request: &DisposableContextCreateRequest, + ) -> Result { + assert_eq!(request.browser_session(), BrowserSessionId::new(7).unwrap()); + self.create_calls.set(self.create_calls.get() + 1); + Ok(DisposableContextHandle::new( + DisposableIsolationId::parse("aggregate-issued-request").expect("valid isolation id"), + BrowsingContextId::new(41).expect("valid browsing context"), + )) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + assert_eq!(request.browser_session(), BrowserSessionId::new(7).unwrap()); + assert_eq!( + request.context().browsing_context(), + BrowsingContextId::new(41).unwrap() + ); + self.destroy_calls.set(self.destroy_calls.get() + 1); + Ok(()) + } +} + +#[test] +fn aggregate_issued_request_is_reachable_only_through_owned_port_binding() { + let session = BrowserSession::start(BrowserSessionId::new(7).expect("valid session id")) + .expect("incarnation capacity"); + let approved_create_calls = Rc::new(Cell::new(0)); + let approved_destroy_calls = Rc::new(Cell::new(0)); + let other_create_calls = Rc::new(Cell::new(0)); + let other_destroy_calls = Rc::new(Cell::new(0)); + let _unbound_other_port = RecordingPort::new( + Rc::clone(&other_create_calls), + Rc::clone(&other_destroy_calls), + ); + let mut bound = session.bind_lifecycle_port(RecordingPort::new( + Rc::clone(&approved_create_calls), + Rc::clone(&approved_destroy_calls), + )); + + let authority = bound + .create_disposable_context() + .expect("Browser Session-issued create request"); + assert_eq!(approved_create_calls.get(), 1); + assert_eq!(other_create_calls.get(), 0); + + bound + .destroy_disposable_context(&authority) + .expect("Browser Session-issued destroy request"); + assert_eq!(approved_destroy_calls.get(), 1); + assert_eq!(other_destroy_calls.get(), 0); +} diff --git a/crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs b/crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs new file mode 100644 index 000000000..cee8c46ed --- /dev/null +++ b/crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs @@ -0,0 +1,85 @@ +use std::cell::Cell; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct SideEffectingIdentityPort { + identity_callbacks: Rc>, + create_calls: Rc>, +} + +impl SideEffectingIdentityPort { + fn new(identity_callbacks: Rc>, create_calls: Rc>) -> Self { + Self { + identity_callbacks, + create_calls, + } + } + + fn identity_probe(&self) { + self.identity_callbacks + .set(self.identity_callbacks.get().saturating_add(1)); + } +} + +impl DisposableContextPort for SideEffectingIdentityPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.create_calls + .set(self.create_calls.get().saturating_add(1)); + Ok(DisposableContextHandle::new( + DisposableIsolationId::parse("preflight-user-context").expect("valid isolation id"), + BrowsingContextId::new(401).expect("valid browsing context"), + )) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Ok(()) + } +} + +#[test] +fn lifecycle_binding_invokes_no_adapter_callback_before_authorized_create() { + let session = BrowserSession::start(BrowserSessionId::new(401).expect("valid session id")) + .expect("incarnation capacity"); + let identity_callbacks = Rc::new(Cell::new(0)); + let create_calls = Rc::new(Cell::new(0)); + let port = + SideEffectingIdentityPort::new(Rc::clone(&identity_callbacks), Rc::clone(&create_calls)); + + // Prove the fixture observes a shared-reference callback without retaining adapter access after bind. + port.identity_probe(); + assert_eq!(identity_callbacks.get(), 1); + identity_callbacks.set(0); + + let mut bound = session.bind_lifecycle_port(port); + assert_eq!( + identity_callbacks.get(), + 0, + "binding invoked adapter code before aggregate-issued lifecycle authority existed" + ); + bound + .create_disposable_context() + .expect("authorized create"); + assert_eq!(identity_callbacks.get(), 0); + assert_eq!(create_calls.get(), 1); +} diff --git a/crates/originweave-browser-session/tests/lifecycle_port_same_id_spoof.rs b/crates/originweave-browser-session/tests/lifecycle_port_same_id_spoof.rs new file mode 100644 index 000000000..bc692f31d --- /dev/null +++ b/crates/originweave-browser-session/tests/lifecycle_port_same_id_spoof.rs @@ -0,0 +1,123 @@ +use std::cell::Cell; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct RecordingPort { + context: BrowsingContextId, + isolation: &'static str, + create_calls: Rc>, + destroy_calls: Rc>, +} + +impl RecordingPort { + fn new( + context: u64, + isolation: &'static str, + create_calls: Rc>, + destroy_calls: Rc>, + ) -> Self { + Self { + context: BrowsingContextId::new(context).expect("valid browsing context"), + isolation, + create_calls, + destroy_calls, + } + } +} + +impl DisposableContextPort for RecordingPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.create_calls.set(self.create_calls.get() + 1); + Ok(DisposableContextHandle::new( + DisposableIsolationId::parse(self.isolation).expect("valid isolation id"), + self.context, + )) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls.set(self.destroy_calls.get() + 1); + Ok(()) + } +} + +#[test] +fn distinct_adapter_cannot_be_substituted_for_create_after_binding() { + let session = BrowserSession::start(BrowserSessionId::new(17).expect("valid session id")) + .expect("incarnation capacity"); + let approved_create_calls = Rc::new(Cell::new(0)); + let approved_destroy_calls = Rc::new(Cell::new(0)); + let spoof_create_calls = Rc::new(Cell::new(0)); + let spoof_destroy_calls = Rc::new(Cell::new(0)); + let approved_port = RecordingPort::new( + 41, + "approved-isolation", + Rc::clone(&approved_create_calls), + Rc::clone(&approved_destroy_calls), + ); + let _spoofing_port = RecordingPort::new( + 42, + "spoofed-isolation", + Rc::clone(&spoof_create_calls), + Rc::clone(&spoof_destroy_calls), + ); + let mut bound = session.bind_lifecycle_port(approved_port); + + bound + .create_disposable_context() + .expect("bound adapter creates context"); + assert_eq!(approved_create_calls.get(), 1); + assert_eq!(spoof_create_calls.get(), 0); +} + +#[test] +fn distinct_adapter_cannot_be_substituted_for_destroy_after_binding() { + let session = BrowserSession::start(BrowserSessionId::new(18).expect("valid session id")) + .expect("incarnation capacity"); + let approved_create_calls = Rc::new(Cell::new(0)); + let approved_destroy_calls = Rc::new(Cell::new(0)); + let spoof_create_calls = Rc::new(Cell::new(0)); + let spoof_destroy_calls = Rc::new(Cell::new(0)); + let approved_port = RecordingPort::new( + 51, + "approved-isolation", + Rc::clone(&approved_create_calls), + Rc::clone(&approved_destroy_calls), + ); + let _spoofing_port = RecordingPort::new( + 52, + "spoofed-isolation", + Rc::clone(&spoof_create_calls), + Rc::clone(&spoof_destroy_calls), + ); + let mut bound = session.bind_lifecycle_port(approved_port); + let authority = bound + .create_disposable_context() + .expect("bound adapter creates context"); + + bound + .destroy_disposable_context(&authority) + .expect("bound adapter destroys context"); + assert_eq!(approved_destroy_calls.get(), 1); + assert_eq!(spoof_destroy_calls.get(), 0); +} diff --git a/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs b/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs new file mode 100644 index 000000000..164e217d7 --- /dev/null +++ b/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs @@ -0,0 +1,119 @@ +use std::collections::VecDeque; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionRecoveryEvidence, BrowserSessionState, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +struct FailingDestroyPort { + handles: VecDeque, +} + +impl DisposableContextPort for FailingDestroyPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.handles + .pop_front() + .ok_or(DisposableContextCreateError::CreateFailedClean) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Err(DisposableContextDestroyError::DestroyFailed) + } +} + +fn handle(context: u64, isolation: &str) -> DisposableContextHandle { + DisposableContextHandle::new( + DisposableIsolationId::parse(isolation).expect("valid isolation id"), + BrowsingContextId::new(context).expect("valid browsing context"), + ) +} + +fn existing_exact_handle( + evidence: &BrowserSessionRecoveryEvidence, +) -> Option<&DisposableContextHandle> { + match evidence { + BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(handle) + | BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(handle) + | BrowserSessionRecoveryEvidence::UnprovenDestruction(handle) + | BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(handle) + | BrowserSessionRecoveryEvidence::TransportLossOwnedHandle(handle) => Some(handle), + BrowserSessionRecoveryEvidence::PartialCreationIsolation(_) => None, + } +} + +#[test] +fn recovery_required_projects_exact_handles_for_indirectly_uncertain_siblings() { + let first = handle(5070, "recovery-user-context-a"); + let sibling = handle(5071, "recovery-user-context-b"); + let port = FailingDestroyPort { + handles: VecDeque::from([first.clone(), sibling.clone()]), + }; + let session = BrowserSession::start(BrowserSessionId::new(507).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(port); + + let first_authority = bound + .create_disposable_context() + .expect("first accepted context"); + let _sibling_authority = bound + .create_disposable_context() + .expect("second accepted context"); + + assert_eq!( + bound.destroy_disposable_context(&first_authority), + Err(BrowserSessionError::ContextDestructionFailed) + ); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + + let evidence = bound.browser_session().recovery_evidence(); + assert!( + evidence.contains(&BrowserSessionRecoveryEvidence::UnprovenDestruction( + first.clone() + )), + "the directly failed destruction must keep its cause-specific evidence" + ); + assert!( + evidence.contains( + &BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(sibling.clone()) + ), + "the indirectly invalidated sibling must be projected as non-authorizing exact recovery evidence" + ); + assert_eq!( + evidence + .iter() + .filter_map(existing_exact_handle) + .filter(|candidate| *candidate == &first) + .count(), + 1, + "the directly failed context must not be duplicated as generic recovery evidence" + ); + assert_eq!( + evidence + .iter() + .filter_map(existing_exact_handle) + .filter(|candidate| *candidate == &sibling) + .count(), + 1, + "an indirectly invalidated sibling must be retained exactly once" + ); +} diff --git a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs index 355201280..81eac3a9e 100644 --- a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs +++ b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs @@ -1,6 +1,11 @@ +use std::cell::RefCell; +use std::rc::Rc; + use originweave_browser_session::{ - BrowserSession, BrowserSessionError, BrowserSessionIncarnation, DisposableContextCreateError, - DisposableContextDestroyError, DisposableContextHandle, DisposableContextPort, + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; @@ -8,20 +13,25 @@ use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct ReusingPort { handle: DisposableContextHandle, - create_incarnations: Vec, - destroy_incarnations: Vec, + create_incarnations: Rc>>, + destroy_incarnations: Rc>>, } impl ReusingPort { - fn new(context: u64, isolation: &str) -> Result { + fn new( + context: u64, + isolation: &str, + create_incarnations: Rc>>, + destroy_incarnations: Rc>>, + ) -> 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(), + create_incarnations, + destroy_incarnations, }) } } @@ -29,20 +39,28 @@ impl ReusingPort { impl DisposableContextPort for ReusingPort { fn create_disposable_context( &mut self, - _browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, + request: &DisposableContextCreateRequest, ) -> Result { - self.create_incarnations.push(incarnation); + self.create_incarnations + .borrow_mut() + .push(request.incarnation()); Ok(self.handle.clone()) } + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + fn destroy_disposable_context( &mut self, - _browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, - _context: &DisposableContextHandle, + request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { - self.destroy_incarnations.push(incarnation); + self.destroy_incarnations + .borrow_mut() + .push(request.incarnation()); Ok(()) } } @@ -53,38 +71,64 @@ fn stale_authority_cannot_cross_sequential_session_incarnations() -> Result<(), 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) + let create_a = Rc::new(RefCell::new(Vec::new())); + let destroy_a = Rc::new(RefCell::new(Vec::new())); + let 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) + let mut bound_a = session_a.bind_lifecycle_port(ReusingPort::new( + 7010, + "user-context-reused", + Rc::clone(&create_a), + Rc::clone(&destroy_a), + )?); + let authority_a = bound_a + .create_disposable_context() .map_err(|_| "first disposable context creation must succeed")?; - session_a - .destroy_disposable_context(&authority_a, &mut port_a) + bound_a + .destroy_disposable_context(&authority_a) .map_err(|_| "first disposable context destruction must succeed")?; - session_a + bound_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) + let create_b = Rc::new(RefCell::new(Vec::new())); + let destroy_b = Rc::new(RefCell::new(Vec::new())); + let 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) + let mut bound_b = session_b.bind_lifecycle_port(ReusingPort::new( + 7010, + "user-context-reused", + Rc::clone(&create_b), + Rc::clone(&destroy_b), + )?); + let authority_b = bound_b + .create_disposable_context() .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_ne!( + bound_a.browser_session().incarnation(), + bound_b.browser_session().incarnation() + ); assert_eq!( - session_b.destroy_disposable_context(&authority_a, &mut port_b), + create_a.borrow().as_slice(), + &[bound_a.browser_session().incarnation()] + ); + assert_eq!( + create_b.borrow().as_slice(), + &[bound_b.browser_session().incarnation()] + ); + assert_eq!( + bound_b.destroy_disposable_context(&authority_a), Err(BrowserSessionError::AuthorityMismatch) ); - assert!(port_b.destroy_incarnations.is_empty()); + assert!(destroy_b.borrow().is_empty()); - session_b - .destroy_disposable_context(&authority_b, &mut port_b) + bound_b + .destroy_disposable_context(&authority_b) .map_err(|_| "current incarnation authority must remain valid")?; - assert_eq!(port_b.destroy_incarnations, vec![session_b.incarnation()]); + assert_eq!( + destroy_b.borrow().as_slice(), + &[bound_b.browser_session().incarnation()] + ); Ok(()) } diff --git a/crates/originweave-browser-session/tests/transport_loss_recovery_evidence.rs b/crates/originweave-browser-session/tests/transport_loss_recovery_evidence.rs new file mode 100644 index 000000000..90c6b8b69 --- /dev/null +++ b/crates/originweave-browser-session/tests/transport_loss_recovery_evidence.rs @@ -0,0 +1,118 @@ +use std::cell::Cell; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionState, DisposableContextCreateCompletion, + DisposableContextCreateCompletionError, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +struct ObservedPort { + create_calls: Rc>, + completion_calls: Rc>, + destroy_calls: Rc>, +} + +impl DisposableContextPort for ObservedPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.create_calls.set(self.create_calls.get() + 1); + Ok(DisposableContextHandle::new( + DisposableIsolationId::parse("transport-user-context-501").expect("valid isolation id"), + BrowsingContextId::new(501).expect("valid browsing context"), + )) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + self.completion_calls.set(self.completion_calls.get() + 1); + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls.set(self.destroy_calls.get() + 1); + Ok(()) + } +} + +#[test] +fn transport_loss_preserves_exact_owned_handle_as_non_authorizing_recovery_evidence() { + let create_calls = Rc::new(Cell::new(0)); + let completion_calls = Rc::new(Cell::new(0)); + let destroy_calls = Rc::new(Cell::new(0)); + let port = ObservedPort { + create_calls: Rc::clone(&create_calls), + completion_calls: Rc::clone(&completion_calls), + destroy_calls: Rc::clone(&destroy_calls), + }; + let session = BrowserSession::start(BrowserSessionId::new(501).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(port); + + let authority = bound + .create_disposable_context() + .expect("accepted disposable context"); + assert_eq!(authority.browsing_context().value(), 501); + assert_eq!(create_calls.get(), 1); + assert_eq!(completion_calls.get(), 1); + assert_eq!(destroy_calls.get(), 0); + + assert!(bound.record_transport_loss()); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::TransportLost + ); + assert_eq!( + create_calls.get(), + 1, + "transport loss must not create browser state" + ); + assert_eq!( + completion_calls.get(), + 1, + "transport loss must not settle another create attempt" + ); + assert_eq!( + destroy_calls.get(), + 0, + "transport loss is not destruction proof" + ); + + let evidence = bound.browser_session().recovery_evidence(); + assert_eq!( + evidence.len(), + 1, + "the exact previously owned handle must remain externally recoverable after transport loss" + ); + let rendered = format!("{:?}", evidence[0]); + assert!( + rendered.contains("transport-user-context-501"), + "recovery evidence lost the exact disposable isolation identity: {rendered}" + ); + assert!( + rendered.contains("501"), + "recovery evidence lost the exact browsing-context identity: {rendered}" + ); + + assert!(!bound.record_transport_loss()); + assert_eq!( + bound.browser_session().recovery_evidence().len(), + 1, + "repeated transport-loss reports must not duplicate recovery evidence" + ); + assert_eq!( + bound.presentation_authority(authority.browsing_context()), + Err(originweave_browser_session::BrowserSessionError::SessionNotActive), + "transport-loss recovery evidence must never resurrect presentation authority" + ); + assert_eq!(destroy_calls.get(), 0); +} diff --git a/crates/originweave-browser-session/tests/user_context_identity_length.rs b/crates/originweave-browser-session/tests/user_context_identity_length.rs new file mode 100644 index 000000000..d8c23c79f --- /dev/null +++ b/crates/originweave-browser-session/tests/user_context_identity_length.rs @@ -0,0 +1,11 @@ +use originweave_browser_session::DisposableIsolationId; + +#[test] +fn webdriver_bidi_user_context_is_not_rejected_by_an_arbitrary_domain_length_cap() { + let remote_user_context = "u".repeat(4097); + + let identity = DisposableIsolationId::parse(&remote_user_context) + .expect("WebDriver BiDi browser.UserContext has no 4096-byte protocol limit"); + + assert_eq!(identity.as_str(), remote_user_context); +} diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index 345071fd6..c49b2d251 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -2,124 +2,174 @@ - Status: Proposed - Date: 2026-09-10 +- Last code-current review: 2026-09-12 ## Context -OriginWeave's WebDriver BiDi presentation adapter requires opaque ownership witnesses before viewport/device-pixel-ratio, timezone, or screen-area mutation can be planned. A caller that merely knows a browser-session or browsing-context identifier therefore cannot overwrite another owner's presentation state and later clear it to an implementation default. +OriginWeave's Browser Session bounded context is the domain authority for disposable browser lifecycle ownership and presentation mutation. WebDriver BiDi session ids, user-context ids, browsing-context ids, and adapter-selected values are protocol addressability, not authorization. -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 active implementation has to satisfy four constraints at once. First, `BoundBrowserSession

` must consume the one concrete lifecycle adapter without later exposing raw `&P`/`&mut P` or a replacement-port path. Second, one Browser Session incarnation can issue multiple remote creates, so each result requires an aggregate-issued per-create transaction identity before it may become authorizing. Third, dependent WebDriver BiDi presentation and reconciliation work still needs to reach the same consumed adapter after exact `PresentationMutationAuthority` validation; retaining a second adapter or generic raw callback would recreate the capability-substitution defect. Fourth, uncertain lifecycle outcomes must preserve every exact non-authorizing owned handle needed for recovery, including siblings invalidated indirectly by another context's failure, and a failed `finish()` must not discard the same bound adapter needed to repair the rejected completion. -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. +Lifecycle failures require lossless evidence while the aggregate remains available. 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. A failure on one owned context can force all other active siblings into uncertainty, so those sibling handles also have to remain enumerable. Transport liveness remains orthogonal to ownership certainty. -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. +The latest W3C-published WebDriver BiDi Working Draft verified on 2026-09-12 is the 9 September 2026 publication (`WD-webdriver-bidi-20260909`), with 3 September 2026 as the previous version. It defines `browser.UserContext` as `text`, `browser.createUserContext`, `browsingContext.create`, and `browser.removeUserContext`. The specification requires the user-context id to uniquely identify a user context but does not define a 4096-byte identifier limit. These identifiers and commands remain adapter capabilities/addressability rather than OriginWeave policy authority, and command ACK alone is not destruction proof. Runtime-qualified protocol/browser revisions remain separately controlled and are not repinned by this standards-trace update. ## Decision drivers -- Raw WebDriver/BiDi identifiers are addressability, not mutation or cleanup authority. -- Sequential aggregate recreation must not make a retained stale authority valid again. -- The lifecycle adapter must receive the same non-reused session incarnation used by authority validation; an aggregate-only nonce is insufficient. -- Known remote identities from partial creation, duplicate output, or unproven destruction must be retained as recovery evidence without becoming command authority. -- Ownership recovery and transport liveness must remain orthogonal. -- Duplicate or uncertain outcomes fail closed and must not permit false normal completion. -- Destruction I/O must use the exact stored handle and session incarnation rather than reconstructing authority from raw identifiers. +- Raw WebDriver/BiDi identifiers and adapter-chosen values are addressability, not mutation or cleanup authority. +- Browser-issued protocol identity needed for exact lifecycle ownership and recovery must remain losslessly representable; an uncited implementation constant must not silently redefine `browser.UserContext` semantics. +- No arbitrary adapter callback may be required to establish lifecycle-port ownership. +- A caller must not be able to substitute or recover the concrete adapter after Browser Session binding. +- Browser Session create/destroy capabilities remain non-caller-constructible. +- Every successful remote create result must be correlated to one exact Browser Session-issued attempt before it can become authorizing. +- Browser Session, not the adapter, decides whether a returned domain handle is accepted or rejected. +- Protocol-specific pending/accepted/quarantined tuples remain the WebDriver BiDi ACL owner's truth. +- Presentation/reconciliation I/O must use the exact consumed adapter only after current aggregate authority validation. +- Diagnostic formatting must not invoke adapter-owned `Debug` or expose adapter-internal state. +- Silent loss of active/uncertain ownership on ordinary `BoundBrowserSession` drop must be observable without performing browser I/O from `Drop`. +- A rejected `finish()` must retain the exact bound lifecycle owner so cleanup/reconciliation and a later retry remain possible. +- Sequential aggregate recreation must not make retained stale authority valid again. +- Recovery evidence and transport liveness remain orthogonal. - Browser Session remains the domain authority; WebDriver BiDi, CDP, MCP, and LLMs remain adapters or consumers. ## Decision -Introduce `originweave-browser-session` as an independent Rust bounded context and retain ADR status `Proposed` until protected-main and real-browser acceptance exist. - -1. `BrowserSession` is the aggregate root. `BrowserSession::start` allocates a process-local, monotonically non-reused `BrowserSessionIncarnation` before browser I/O. Allocation fails closed before `u64` wrap. -2. Presentation authority is intentionally non-serializable. A process restart destroys every outstanding in-memory authority. Within one process, `BrowserSessionIncarnation` prevents sequential ABA when a later aggregate reuses the same external session, isolation, context, and local epoch values. -3. The same `BrowserSessionIncarnation` is passed through `DisposableContextPort` create and destroy calls. Adapters must scope their remote ownership mapping to that incarnation. Ignoring it violates the port contract. -4. A context enters the owned set only after `DisposableContextPort::create_disposable_context` returns a `DisposableContextHandle`. Raw `BrowsingContextId` input never creates ownership. -5. `PresentationMutationAuthority` is opaque and binds browser session, Browser Session incarnation, disposable isolation, browsing context, and context epoch. All fields must match current aggregate ownership before adapter I/O. -6. `DisposableContextCreateError::CreateFailedClean` is valid only when no remote boundary exists. `DisposableContextCreateError::CreateFailedUncertain(Option)` enters `RecoveryRequired`; when the browser-issued isolation/user-context identity is known, it is preserved exactly. -7. Duplicate browsing-context or isolation output enters `RecoveryRequired` and stores the complete offending `DisposableContextHandle` as recovery evidence. OriginWeave does not auto-destroy it because the adapter may have returned foreign state. -8. `BrowserSessionRecoveryEvidence` records only reconciliation evidence: `PartialCreationIsolation`, `DuplicateAdapterHandle`, and `UnprovenDestruction`. It grants no browser command authority. -9. Destruction validates exact authority before I/O, passes the current incarnation and stored handle to the port, and succeeds only after the adapter proves the exact boundary is gone. `DisposableContextDestroyError` moves the record and aggregate into recovery and retains the exact failed handle. -10. Transport liveness is stored separately from ownership state. The first `record_transport_loss()` records the fact even after `RecoveryRequired`; later duplicate reports are idempotent. If transport is lost while the aggregate is `Active`, the lifecycle state becomes `TransportLost` and active contexts become uncertain. If ownership was already uncertain, `RecoveryRequired` remains the lifecycle state and the transport-loss fact is retained alongside it. -11. `RecoveryRequired`, `TransportLost`, and `Ended` reject active-only creation, authority issuance/advance, destruction, and normal end. Reconciliation is a later, separately authorized design. -12. Context epochs remain monotonic authority identities within one aggregate. They invalidate older authority after navigation or another lifecycle boundary but are not a substitute for session incarnation. +Introduce and retain `originweave-browser-session` as an independent Rust bounded context. ADR status remains `Proposed` until protected-main and real-browser acceptance exist. + +1. `BrowserSession::start` allocates a process-local, monotonically non-reused `BrowserSessionIncarnation` before browser I/O. Exhaustion fails closed. +2. Presentation authority is intentionally non-serializable. `BrowserSessionIncarnation` prevents sequential ABA within one process. +3. Browser Session uses a **linear lifecycle-port binding**. `BrowserSession::bind_lifecycle_port` consumes both aggregate and one concrete `DisposableContextPort` into `BoundBrowserSession

` without invoking adapter code. +4. `BoundBrowserSession

` has **no public raw port accessor** and no lifecycle method that accepts an alternate port. Tests observe adapter behavior through independently retained inert counters/ledgers rather than extracting `&P`. +5. `DisposableContextPort` has no identity-preflight method. Adapter identity is structural composition, not a self-asserted scalar. +6. `DisposableContextCreateRequest` remains opaque and carries the already-reserved `BrowserContextEpoch` as an exact per-create transaction identity. The `(session, incarnation, attempt epoch)` tuple is unique for create attempts in one live aggregate and is not caller-constructible as a request. +7. After `create_disposable_context` returns a handle, Browser Session validates isolation and browsing-context ownership before granting authority. +8. Browser Session privately issues `DisposableContextCreateCompletion` for that exact attempt with `Accepted` or `Rejected`. +9. The adapter must keep a successful remote create result non-authorizing until the matching `Accepted` completion. `Rejected` results remain non-authorizing recovery/quarantine state. A completion that cannot be proven for the exact pending attempt fails closed and sends the aggregate to `RecoveryRequired`. +10. Protocol-specific remote tuple contents are not copied into Browser Session. #314/#316 owns WebDriver BiDi pending/accepted/quarantined storage and remote-liveness validation. +11. `DisposableIsolationId` preserves the browser-issued isolation identity exactly for create/destroy/recovery addressability. It must not truncate, normalize, hash, or reject an otherwise protocol-valid `browser.UserContext` solely because of an arbitrary local identifier-length constant. Resource-exhaustion limits, where required, belong at a cited protocol/frame/runtime boundary or an explicit deployment policy that still preserves lossless recovery evidence. +12. `DisposableContextDestroyRequest` remains opaque and is created only after exact presentation-authority validation. It carries Browser Session addressability, incarnation, and the exact stored handle. +13. `PresentationMutationAuthority` binds browser session, Browser Session incarnation, disposable isolation, browsing context, and context epoch. All fields must match current aggregate ownership before adapter I/O. +14. `DisposableContextCreateError::CreateFailedClean` is valid only when no remote boundary exists. `CreateFailedUncertain(Option)` enters `RecoveryRequired`; any known isolation identity is preserved exactly. +15. Duplicate browsing-context or isolation output enters `RecoveryRequired`, stores the complete offending `DisposableContextHandle`, and sends a `Rejected` completion for the exact attempt. OriginWeave does not auto-destroy ambiguous output. +16. `BrowserSessionRecoveryEvidence` includes partial-creation identity, duplicate handle, unsettled complete adapter handle, exact unproven-destruction handle, `RecoveryRequiredOwnedHandle` for every still-active sibling made uncertain by a recovery transition, and `TransportLossOwnedHandle` for each active handle whose remote liveness becomes uncertain on transport loss. Cause-specific evidence is not duplicated as generic sibling evidence. These values are explicit **unproven destruction** evidence rather than cleanup proof and grant no browser command authority. Repeated recovery/loss observation must not duplicate exact-handle evidence. +17. Destruction validates exact authority before I/O. `DisposableContextDestroyError::DestroyFailed` means destruction was not proven; the owned record becomes uncertain, the exact failed handle is retained as `UnprovenDestruction`, and the aggregate enters recovery rather than treating command acknowledgement or bookkeeping as cleanup proof. Any other active sibling is projected as `RecoveryRequiredOwnedHandle` before it becomes uncertain. +18. Transport liveness is stored separately from ownership state. The first `record_transport_loss()` records exact previously active handles as non-authorizing recovery evidence, marks them uncertain, and records the transport fact. If ownership is already `RecoveryRequired`, the stronger lifecycle state is preserved. +19. `RecoveryRequired`, `TransportLost`, and `Ended` reject normal active-only lifecycle and authority operations. +20. `AuthorizedContextOperationRequest` is non-caller-constructible. `AuthorizedContextOperationPort` lets a dependent adapter define a narrow operation vocabulary while Browser Session first validates current `PresentationMutationAuthority`, binds the exact stored handle, and routes the request through the same consumed adapter instance. `AuthorizedContextOperationError::BrowserSession` is returned before adapter I/O for stale/foreign authority; adapter execution errors remain separately typed. Browser Session does not own WebDriver BiDi command semantics. +21. `BoundBrowserSession

` implements a manual redacted `Debug` projection over inert Browser Session fields only. Formatting never calls `P::fmt` and never renders adapter-internal state. +22. `BoundBrowserSession

` is `#[must_use]`. `finish(&mut self)` admits normal completion only after all owned contexts have proven destruction. A failed `finish()` returns the domain error without consuming or dropping the wrapper, so the same exact bound adapter and ownership ledger remain available for cleanup/reconciliation and retry. After success the aggregate is `Ended`, and later wrapper destruction is inert. `Drop` never performs browser I/O; if unresolved remote ownership remains, it increments the process-local `abandoned_bound_session_count()` operability signal. +23. The abandonment counter is deliberately not destruction proof and is not durable cross-process recovery storage. Exact recovery handles must be persisted by the separately authorized recovery owner before process termination. Until that owner path is integrated, crash/process-restart reconciliation remains an explicit buyer-acceptance gap rather than an implicit guarantee. +24. Context epochs remain monotonic authority identities within one aggregate and also provide the create-attempt correlation allocated before remote create I/O. ## Alternatives considered -### Treat any known context as owned +### Adapter-supplied identity or preflight callback -Rejected. It restores the authority-confusion defect and allows one task to clear another task's state. +Rejected. A scalar can be replayed and a shared-reference callback can still have side effects before Browser Session authority exists. -### Depend only on browser-issued isolation identity +### Public read-only `&P` after binding -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. +Rejected. Rust `&P` forbids an ordinary mutable borrow but does not prohibit interior mutation or remote effects from `&self` methods. The concrete adapter would remain a capability escape. -### Add an aggregate-only random or monotonic nonce +### `#[derive(Debug)]` over `BoundBrowserSession

` -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. +Rejected. Derived formatting delegates to `P::fmt`; a side-effecting or secret-bearing adapter `Debug` becomes an authority/data-exposure escape. Manual redacted formatting is selected. -### Persist authority generations globally +### Generic `FnOnce(&mut P)` callback -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. +Rejected. Although it would reach the exact consumed adapter, it hands unrestricted adapter authority back to callers and defeats the anti-corruption boundary. A typed `AuthorizedContextOperationPort` operation is selected instead. -### Treat every uncertain lifecycle failure as transport loss +### Second retained adapter or shared client outside `BoundBrowserSession` -Rejected. Ownership uncertainty and transport liveness answer different operational questions. Collapsing them loses information needed for safe reconciliation. +Rejected. It recreates same-key/different-adapter target redirection and lets presentation/reconciliation work escape the exact lifecycle instance Browser Session accepted. -### Automatically clean duplicate or partial state +### Adapter-local create sequence number -Rejected. When ownership is ambiguous, cleanup itself can become a cross-owner destructive action. Exact recovery evidence is retained while normal authority stays blocked. +Rejected as authority. It may be useful internally, but Browser Session could not prove which pending remote tuple it was accepting. Correlation must originate in the aggregate-issued request. -### Snapshot and restore every predecessor presentation override +### Reserved BrowserContextEpoch as create-attempt identity -Deferred. OriginWeave does not yet have a complete queryable predecessor-state contract for every governed presentation surface. Disposable ownership remains the stronger first implementation. +Selected. Browser Session already reserves the epoch before create I/O, it is non-caller-constructible, monotonic within the aggregate, and the same value becomes the accepted context's first mutation epoch. -## Consequences +### Hard-coded maximum length for `browser.UserContext` -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. +Rejected unless an authoritative protocol/runtime bound is cited and versioned. The current WebDriver BiDi WD defines `browser.UserContext` as `text` and does not define a 4096-byte identifier ceiling. OriginWeave therefore must not convert a browser-issued, otherwise valid identity into ownership loss because of an implementation-chosen domain constant. -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. +### Consuming `finish(self)` before validation -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.” +Rejected. An expected `ActiveContextRemains` would destroy the only wrapper that owns the accepted adapter and private lifecycle ledger. Validation therefore occurs through `finish(&mut self)`; only successful completion changes the aggregate to `Ended`. -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. +### Browser I/O from `Drop` -## Security and governance impact +Rejected. Rust destruction is synchronous and cannot prove remote cleanup. `Drop` is restricted to non-I/O abandonment observability; normal completion is explicit through proven destruction plus `finish()`. -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. +### Automatically clean duplicate or rejected state -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. +Rejected. Ambiguous ownership makes speculative cleanup a potential cross-owner destructive action. -## Tests and exact evidence +## Consequences + +The active stack receives a breaking trait extension for presentation/reconciliation adapters: implementations that need post-create authorized operations implement `AuthorizedContextOperationPort` and keep their protocol-specific command vocabulary in the adapter. #316 must restack non-force and map this boundary into its pending/accepted/quarantined BiDi state. -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. +The bound adapter is not publicly recoverable from `BoundBrowserSession`. Application and test code that needs observability retains inert metrics or diagnostic projections separately. Manual `Debug` exposes only Browser Session domain summary fields and a redacted port marker. -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. +Entering `RecoveryRequired` now preserves exact handles for active siblings before marking them uncertain. Cause-specific evidence for the triggering context remains distinct, so recovery can enumerate every potentially live boundary without reconstructing command authority from identifiers. -`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. +Transport loss preserves exact previously active handles as non-authorizing recovery evidence. Completion or destruction failure remains ownership uncertainty and does not mint normal 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. +A failed `finish()` leaves the same `BoundBrowserSession` usable for cleanup/reconciliation and retry. Ordinary unresolved wrapper abandonment is process-locally observable, but exact crash/restart recovery still requires a canonical persistence/handoff path. This ADR does not claim that the in-memory counter is durable recovery. + +## Security and governance impact -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. +No page-controlled value, raw browser identifier, adapter-selected scalar, diagnostic reference, provider/model decision, or LLM output can mint lifecycle completion or presentation authority. Remote creation stays non-authorizing until the aggregate validates ownership and accepts that exact attempt. Post-create adapter I/O is admitted only through current aggregate authority and the exact consumed adapter instance. + +Lossless retention of browser-issued user-context identity is an ownership requirement, not authority delegation. Resource controls must not create an untracked remote isolation boundary by discarding or rewriting the only exact addressability needed for recovery. + +This decision does not replace Chromium sandboxing, EgressWeave, Keyverse, Wardnet, or central workflow security. + +## Tests and exact evidence + +Required executable cases include: + +- binding invokes no arbitrary adapter callback before an aggregate-issued create request; +- the concrete bound adapter cannot be recovered through a public `lifecycle_port()` accessor; +- a second adapter cannot be substituted for create or destroy after binding; +- two successful remote create candidates in the same session incarnation receive distinct attempt epochs; +- one candidate can be accepted and the other rejected without pending-state collision or overwrite; +- accepted-completion failure and rejected-completion failure both fail closed and preserve exact recovery evidence; +- an otherwise-valid browser-issued `browser.UserContext` longer than the former 4096-byte implementation threshold remains losslessly representable for exact destroy/recovery rather than being rejected by an arbitrary domain cap; +- `DisposableContextDestroyError::DestroyFailed` preserves the exact failed handle, enters `RecoveryRequired`, and never counts a destroy command acknowledgement as proof; +- `RecoveryRequired` preserves each indirectly invalidated active sibling exactly once as `RecoveryRequiredOwnedHandle` while retaining the triggering context's cause-specific evidence; +- transport loss preserves every previously active exact handle as `TransportLossOwnedHandle` without adapter I/O or authority resurrection; +- formatting a bound session does not invoke adapter-owned `Debug` and does not expose adapter-internal state; +- an authorized operation reaches the exact consumed adapter, adapter errors remain typed, and stale authority fails before adapter I/O; +- dropping a bound session with unresolved ownership performs no implicit browser cleanup and increments the abandonment operability signal; +- a failed `finish()` performs no browser I/O or abandonment, retains the same bound owner, permits exact cleanup, and succeeds on retry after proven destruction; +- recovery, sequential-incarnation ABA, epoch exhaustion, foreign authority, destruction failure, transport loss, and normal end remain covered. + +The historical exact `9cde981899950b900698a17e7fa739af59f6bb4f` CI `34531025582` passed exact production coverage but failed canonical Rust formatting. The historical `729603ae4feadd369eee7819a45d6850604975da` run `34541860394` passed exact production coverage but failed the repository contract because ADR 0114 had lost the `DisposableContextDestroyError` trace. Exact `d5046e76cb7555b448b728ea1bed9ba1ea8de8c3` / CI `34573175780` passed Python repository contracts but failed canonical formatting; production coverage stopped during measurement because the two intentionally RED hostile lifecycle cases were still unresolved. Historical GREEN never transfers. Successor evidence must be fresh: repository contracts, canonical formatting, locked tests, strict Clippy, rustdoc/API docs, and production function/line/region/branch coverage each exactly 100%. ## Buyer acceptance still open -This slice does not yet prove real WebDriver BiDi `browser.createUserContext`/`browsingContext.create`/`browser.removeUserContext` integration, browser-observed destruction, recovery reconciliation, Browser Session→BiDi private-witness conversion, pinned Chromium presentation post-conditions, crash/restart cleanup, #299 3/3 Agent Task replay, or protected-main release/SBOM/provenance/reproducibility/rollback. +This slice does not yet prove actual WebDriver BiDi lifecycle integration, browser-observed destruction, protocol-specific pending/accepted/quarantined binding, durable crash/process-restart recovery handoff, Browser Session authority conversion into BiDi presentation private witnesses, current Chromium post-condition observation, #299 3/3 browser trials, or protected-main release/SBOM/provenance/reproducibility/rollback. ## Migration and rollback -The change remains additive on the active stacked branch. Consumers must adopt the new `BrowserSession::start` result and incarnation-aware `DisposableContextPort` contract. Until a reviewed adapter bridge exists, presentation mutation remains fail closed behind private ownership witnesses. Rollback removes this active-PR bounded-context slice without weakening protected Chromium or central security policy. +Consumers continue to bind once with `BrowserSession::bind_lifecycle_port(port)` and perform lifecycle work through `BoundBrowserSession`. Code must not depend on recovering `&P`. Adapter implementations add exact-attempt staging/completion and, when they need post-create presentation or reconciliation I/O, implement the typed `AuthorizedContextOperationPort` operation vocabulary. + +Normal owners destroy every owned context, call `finish()`, and may then release the ended wrapper. If `finish()` rejects, they retain the same wrapper, perform permitted cleanup/reconciliation, and retry. Recovery owners must persist exact recovery evidence before terminating a process that still has unresolved ownership; the abandonment counter is an operability alert, not a persistence mechanism. + +Rollback may return to the predecessor active-PR API only if these authority findings are disproved with stronger executable evidence. It must not restore a raw adapter accessor, derived adapter `Debug`, self-reported identity, unrestricted adapter callback, consuming failed-finish path, arbitrary browser-user-context length cap, or adapter-local call order as an authorization boundary. ## Open follow-ups -- Implement the WebDriver BiDi disposable-user-context adapter with incarnation-scoped mapping and observed destruction post-condition. -- Define the Browser Session→BiDi ACL without exposing public ownership constructors. -- Design separately authorized reconciliation for `BrowserSessionRecoveryEvidence`, including browser/process restart. +- Restack #316 onto the verified Browser Session successor and implement WebDriver BiDi pending → accepted/quarantined transaction settlement plus typed presentation/reconciliation operations. +- Define the separately authorized durable recovery persistence/reconciliation owner for `BrowserSessionRecoveryEvidence` and unresolved abandonment. - Replay #299 historical pinned Chromium evidence after the canonical sandbox/runtime repair, then run a separate current-Stable qualification. -- Revisit predecessor capture/restore only if reusable attached contexts become a buyer requirement. ## Supersession / reversal conditions -Supersede this ADR if the browser platform provides a complete, queryable, generation-safe ownership primitive with exact destruction evidence, or if OriginWeave adopts another isolation primitive with equivalent guarantees. Do not regress to raw context identity as authority. +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 or adapter-selected identity as authority. ## References diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 490e46014..618f64cd8 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,6 +2,15 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, architecture decisions, or live GitHub state. It keeps buyer-visible gaps, current issues, active pull-request evidence, and commercial completion tracks in one discoverable place. Protected `main` is the implementation boundary: code in an open pull request is not shipped behavior. +## Live continuity note: 2026-09-11 + +- Protected `main` remains signature-valid at `87c4daa1830bac5a5228b6036752ad5633232085`. The live repository sweep found 132 open pull requests and 16 open non-PR issues; no release or tag exists. Active-PR work below is evidence only and is not protected-main behavior. +- Browser Session foundation #317 remains stacked on #229 exact `6d87dff5dc572fbd74d06309d574a998f23cf02f`. The active branch now keeps one concrete lifecycle adapter structurally bound, uses aggregate-issued per-create transaction identity, routes typed post-create operations through the same consumed adapter after current authority validation, redacts adapter `Debug`, and preserves exact transport-loss recovery handles. The current repair additionally preserves every indirectly invalidated active sibling as non-authorizing `RecoveryRequiredOwnedHandle` and changes `finish(&mut self)` so a rejected normal completion retains the same bound owner for cleanup/reconciliation and retry. These claims remain active-PR claims until the successor exact head passes repository contracts, canonical formatting, locked tests, strict Clippy, rustdoc/API docs, production function/line/region/branch coverage at exactly 100%, and fresh independent review. +- #316 remains Draft at exact `8ca6c5a190d9ad2b4c7843d440e91f6070d681c2`. It must non-force restack only after a verified #317 successor and continues to own WebDriver BiDi-specific pending/accepted/quarantined tuples, command semantics, fresh-authority-at-I/O, and remote-liveness reconciliation. Browser Session must not absorb that protocol truth. +- Durable crash/process-restart persistence of exact recovery evidence remains open. `abandoned_bound_session_count()` is only a process-local non-I/O operability signal; it is not destruction proof or durable recovery storage. +- W3C's latest-published WebDriver BiDi document verified for this baseline is the **24 August 2026 Working Draft** (`WD-webdriver-bidi-20260824`). Standards freshness does not authorize an automatic runtime repin. +- #299's Chrome/ChromeDriver `150.0.7871.129` Agent Task result remains historical RED: 0/3 trials reached navigation because session creation failed. Current desktop Stable was promoted on 2026-09-08 as Chrome 153 (`153.0.8010.36` on Linux; `.36/.37` on Windows/macOS). Buyer-current browser acceptance therefore still requires a separately controlled current-Stable qualification with real navigation, interaction, observed post-condition, destruction, and cleanup evidence; a command ACK or wrapper drop is not success. + ## Live continuity note: 2026-09-09 - Protected `main` was re-fetched at `87c4daa1830bac5a5228b6036752ad5633232085`. Issue #292 remains open; its buyer-visible acceptance is still pinned Chromium application followed by page-observed and post-cleanup evidence. diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index 66336ac88..7f91dc418 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -4,91 +4,129 @@ - Owning bounded context: `originweave-browser-session` - Governing proposal: ADR 0114 - Requirement owner: issue #312 -- Integration prerequisites: #229 presentation-ownership witnesses; canonical browser/sandbox owner path under #212/#148 +- Integration prerequisites: #229 presentation-ownership witnesses; #314/#316 WebDriver BiDi ACL after this foundation is exact-head GREEN ## Problem and invariant -Browser-session, user-context/isolation, and browsing-context identifiers are addresses. They are not evidence that the current Browser Session aggregate exclusively owns presentation mutation or cleanup. A retained authority must not regain meaning if a later aggregate reuses the same remote identifiers and local epoch. +Browser-session, user-context/isolation, browsing-context, and adapter-selected identifiers are addresses. They are not evidence that the current Browser Session aggregate exclusively owns lifecycle or presentation mutation. -The active implementation now establishes this chain: +The active implementation establishes this chain: ```text validated BrowserSessionId → BrowserSession::start allocates non-reused BrowserSessionIncarnation -→ DisposableContextPort receives session id + incarnation -→ adapter creates fresh task-owned isolation boundary + browsing context -→ adapter returns DisposableIsolationId + BrowsingContextId -→ aggregate records exact handle + monotonic context epoch +→ BrowserSession::bind_lifecycle_port consumes one concrete DisposableContextPort +→ BoundBrowserSession

owns aggregate + exact port; no public raw port accessor exists +→ aggregate validates Active + reserves monotonic BrowserContextEpoch +→ aggregate privately constructs DisposableContextCreateRequest(session, incarnation, attempt epoch) +→ exact owned port creates a remote candidate but must keep it non-authorizing +→ aggregate validates returned isolation/context against current ownership +→ aggregate privately constructs DisposableContextCreateCompletion(attempt, Accepted|Rejected) +→ accepted candidate may become adapter-authorizing; rejected candidate remains quarantined +→ aggregate records accepted exact handle + epoch → opaque PresentationMutationAuthority(session, incarnation, isolation, context, epoch) -→ exact authority validation before adapter I/O -→ destruction receives the same incarnation + stored handle -→ adapter proves exact disposable boundary destruction -→ context Destroyed -→ normal BrowserSession::end admitted +→ exact authority validation before any lifecycle or purpose-bounded adapter I/O +→ lifecycle destruction uses private DisposableContextDestroyRequest +→ presentation/reconciliation uses private AuthorizedContextOperationRequest +→ exact consumed adapter only +→ proven destruction for every context +→ BoundBrowserSession::finish() validates normal completion without consuming the owner on rejection ``` -`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. +`BoundBrowserSession` is the lifecycle composition boundary. Public create/destroy methods accept no arbitrary port argument, and there is **no public raw port accessor**. Application code cannot recover `&P`, `&mut P`, or a generic callback that would recreate unrestricted adapter authority. -## Lossless recovery evidence +The wrapper has a manual redacted `Debug` implementation. Formatting exposes inert Browser Session summary fields only and never calls `P::fmt`, so a side-effecting or secret-bearing adapter `Debug` cannot become a diagnostic capability escape. -`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. +`DisposableContextCreateRequest`, `DisposableContextCreateCompletion`, `DisposableContextDestroyRequest`, and `AuthorizedContextOperationRequest` have private construction paths. The create request carries the already-reserved context epoch as a **per-create transaction** identity. Purpose-bounded operations are constructed only after exact `PresentationMutationAuthority` validation. -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. +## Transactional remote creation -## Orthogonal transport liveness +A protocol adapter may stage a successful remote create result as pending when it receives the create request. It must not make that result authorizing yet. + +Browser Session examines the returned `DisposableContextHandle`: + +- if ownership validation succeeds, `DisposableContextCreateCompletion::Accepted` settles that exact attempt before normal presentation authority is returned; +- if the handle aliases an existing isolation or browsing context, `Rejected` settles that exact attempt and the aggregate enters `RecoveryRequired`; +- if exact completion cannot be proven, Browser Session stores the complete handle as `UnsettledAdapterHandle`, enters recovery, and mints no normal authority. + +Protocol-specific tuple contents and pending/accepted/quarantined storage remain #314/#316 responsibilities. Browser Session owns only attempt identity, domain validation, accept/reject decision, and current authority validation. + +## Same-bound-adapter authorized operations + +`AuthorizedContextOperationPort` extends the lifecycle port for adapters that need post-create presentation or reconciliation work. The operation/output/error vocabulary remains adapter-owned. Browser Session validates session incarnation, isolation, browsing-context identity, and context epoch before creating `AuthorizedContextOperationRequest` and routing it to the same `port: P` already consumed into `BoundBrowserSession`. + +Stale or foreign authority returns `AuthorizedContextOperationError::BrowserSession` before adapter I/O. An operation attempted by the exact bound adapter can return `AuthorizedContextOperationError::Adapter`. No raw `P` reference, second adapter, or unrestricted `FnOnce(&mut P)` is exposed. + +## Lossless recovery evidence while retained + +`CreateFailedClean` is valid only when no disposable browser state exists. `CreateFailedUncertain(Some(isolation))` retains the exact known isolation identity. Duplicate output stores the complete offending handle. Completion failure retains an unsettled complete handle. Failed or unproven destruction records the exact owned handle. When any such failure moves the aggregate to `RecoveryRequired`, every other still-active sibling is projected exactly once as `RecoveryRequiredOwnedHandle` before becoming uncertain. Transport loss records each previously active exact handle as `TransportLossOwnedHandle`. None of this evidence grants browser command authority. + +Cause-specific evidence is retained for the triggering handle and is not duplicated as generic sibling evidence. Repeated transport-loss reports are idempotent, so exact transport-loss evidence is not duplicated by repeated notification. -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. +The active successor still has open recovery-correlation work: create uncertainty and create-completion failures must retain the exact aggregate-issued attempt epoch; destructive and purpose-bounded adapter requests must retain the exact validated context epoch as non-authorizing provenance; `RecoveryRequired`/`TransportLost` need a purpose-bounded handoff that keeps the exact same adapter with the exact evidence instead of reconstructing a second adapter. -This avoids conflating “ownership uncertain while transport may still be usable for separately authorized reconciliation” with “ownership uncertain and the transport is gone.” +## Abandonment and lifecycle completion + +`BoundBrowserSession

` is `#[must_use]`. `finish(&mut self)` succeeds only after all owned contexts have proven destruction. If it returns `ActiveContextRemains`, the wrapper, exact bound adapter, and private ownership ledger remain intact. The same owner can therefore destroy or reconcile the remaining context and retry `finish()` without introducing a second adapter or ambient cleanup capability. + +`Drop` never performs browser I/O and never treats object destruction as browser destruction proof. Dropping a wrapper with active/uncertain ownership increments the process-local `abandoned_bound_session_count()` signal. This makes ordinary abandonment observable to operability/recovery code without reviving adapter authority. The counter is not durable storage and contains no exact handle payload. Exact durable crash/process-restart recovery therefore remains open until a canonical recovery owner persists `BrowserSessionRecoveryEvidence` before process termination. + +## Orthogonal transport liveness + +Transport liveness is tracked independently from ownership recovery. A first transport loss from `Active` preserves exact active handles as recovery evidence, moves those records to uncertain, and moves the aggregate to `TransportLost`. If transport loss occurs after `RecoveryRequired`, the stronger ownership-recovery state remains while `transport_lost = true` records the orthogonal fact. Repeated loss reports are idempotent. ## 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. +Aggregate A may create `(S,U,C,epoch=1)`, prove destruction, and end. Aggregate B can later start with the same external values and also begin at epoch 1. A's retained authority still fails because B has a different `BrowserSessionIncarnation`. The bound port receives the incarnation inside aggregate-issued lifecycle capabilities. -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. +## Browser-issued user-context identity + +`DisposableIsolationId` maps one-to-one to the browser-issued WebDriver BiDi `browser.UserContext` identity. That value is addressability and recovery evidence, not command authority. OriginWeave must preserve a protocol-valid browser identity losslessly so the exact remote boundary can later be destroyed or reconciled. The current active branch still contains a historical 4096-byte parser ceiling; `user_context_identity_length.rs` intentionally keeps that mismatch RED until the arbitrary domain constant is removed or replaced by a cited, versioned protocol/runtime/deployment boundary. No truncation or normalization is acceptable for a browser-issued identity. ## Standards trace -The design dossier references the 9 September 2026 WebDriver BiDi Working Draft. A user context has a user-context id set on creation. `browser.createUserContext` creates it, `browsingContext.create` can create a browsing context inside it, and `browser.removeUserContext` removes the selected user context after closing its navigables. +The latest W3C-published WebDriver BiDi Working Draft verified on 2026-09-12 is the **9 September 2026** publication (`WD-webdriver-bidi-20260909`), with 3 September 2026 as the previous published version. `browser.createUserContext` creates a user context, `browsingContext.create` can create a browsing context inside it, and `browser.removeUserContext` removes the selected user context after closing its navigables. `browser.UserContext` is defined as `text`; the published protocol does not define the active branch's 4096-byte domain ceiling. -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. +Standards freshness and runtime qualification are separate controls. Updating this citation does not repin the separately qualified Chromium/WebDriver BiDi runtime revision. -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. +OriginWeave does not treat protocol identifiers as policy authority or assume historical non-reuse after removal. A command ACK is insufficient proof that the disposable boundary is actually gone. ## Source and executable evidence | Invariant | Source / test | |---|---| | independent Browser Session bounded context | `crates/originweave-browser-session/`; `tests/test_browser_session_lifecycle_contract.py` | -| raw context cannot mint authority | `BrowserSession::presentation_authority`; `disposable_creation_is_the_only_raw_context_entry_to_authority` | -| authority includes non-reused BrowserSessionIncarnation | `PresentationMutationAuthority`; `sequential_incarnation_reuse_rejects_stale_authority` | -| lifecycle port receives the same incarnation | `DisposableContextPort`; `stale_authority_cannot_cross_sequential_session_incarnations` | -| lossless recovery evidence for known partial identity | `BrowserSessionRecoveryEvidence`; `creation_failure_preserves_known_recovery_identity` | -| duplicate adapter handle retained without speculative cleanup | `BrowserSession::create_disposable_context`; `duplicate_adapter_output_preserves_offending_handle` | -| unproven destruction retains exact handle | `BrowserSession::destroy_disposable_context`; `destroy_failure_requires_recovery_before_any_new_authority` | -| transport liveness remains orthogonal to recovery | `BrowserSession::record_transport_loss`; `destroy_failure_retains_handle_and_transport_loss_orthogonally` | -| sequential ABA authority is rejected before I/O | `BrowserSession::context_for_authority_mut`; `stale_authority_cannot_cross_sequential_session_incarnations` | -| normal end requires proved destruction | `BrowserSession::end`; `normal_end_requires_proven_destruction_and_ignores_late_transport_report` | -| incarnation exhaustion fails closed | `allocate_incarnation`; `incarnation_allocator_fails_closed_before_wrap` | - -Earlier exact-head evidence remains historical only. Exact `ab04f9522e97e1ecd6d914c48cb6f77f087eac3b` was repository GREEN in CI `34463908909` after repairing repository-contract drift, but it still contained the three Browser Session defects above. - -The sequential ABA RED was then captured on exact `ec145963ad8fe19c9416f2b3856b94660082dbf7` in CI `34469580144`: Python repository contracts and canonical formatting passed; the Rust `Run tests` step failed at the newly added hostile sequential-incarnation test. That RED is the causal predecessor for the incarnation-aware domain/port repair. No earlier GREEN transfers to the repaired successor. +| lifecycle port ownership is structural | `BoundBrowserSession`; `bound_port_is_structural_and_not_swappable` | +| no public raw port accessor | absence of `BoundBrowserSession::lifecycle_port`; repository contract | +| binding performs no arbitrary adapter callback | `BrowserSession::bind_lifecycle_port`; `lifecycle_binding_invokes_no_adapter_callback_before_authorized_create` | +| no self-asserted adapter id authority | absence of `DisposableContextPortId` / `port_id()` | +| create requests are aggregate-issued and attempt-scoped | `DisposableContextCreateRequest::attempt_epoch`; transaction hostile fixture | +| per-create transaction settles accepted/rejected candidates | `DisposableContextCreateCompletion`; `accepted_and_rejected_create_candidates_are_correlated_by_exact_attempt` | +| completion failure fails closed | `UnsettledAdapterHandle`; internal completion-failure tests | +| raw context cannot mint presentation authority | `BrowserSession::presentation_authority`; `bound_creation_is_the_only_raw_context_entry_to_authority` | +| same consumed adapter handles authorized post-create work | `AuthorizedContextOperationPort`; `authorized_operation_uses_exact_bound_port_and_rejects_stale_authority_before_io` | +| stale operation authority fails before adapter I/O | `AuthorizedContextOperationError::BrowserSession`; authorized-operation hostile fixture | +| adapter-owned Debug is not executed or rendered | manual `Debug for BoundBrowserSession

`; `bound_session_debug_never_executes_or_exposes_adapter_debug` | +| sequential ABA authority is rejected before I/O | `BrowserSessionIncarnation`; `stale_authority_cannot_cross_sequential_session_incarnations` | +| lossless recovery evidence while aggregate is retained | `BrowserSessionRecoveryEvidence`; recovery tests | +| `RecoveryRequired` preserves indirectly invalidated siblings | `RecoveryRequiredOwnedHandle`; `recovery_required_projects_exact_handles_for_indirectly_uncertain_siblings` | +| transport loss preserves exact active handles | `TransportLossOwnedHandle`; `transport_loss_preserves_exact_owned_handle_as_non_authorizing_recovery_evidence` | +| unproven destruction retains exact handle | `destroy_failure_requires_recovery_before_any_new_authority` | +| unresolved wrapper drop performs no browser I/O and is observable | `abandoned_bound_session_count`; `dropping_unresolved_bound_session_is_observable_without_implicit_browser_io` | +| failed finish retains exact bound owner | `BoundBrowserSession::finish`; `failed_finish_retains_same_bound_owner_for_cleanup_and_retry` | +| normal completion requires proven destruction | `BoundBrowserSession::finish`; `proven_destruction_can_finish_without_abandonment_path` | +| protocol-valid user-context identity is preserved losslessly | `user_context_identity_length.rs` (currently RED against the historical 4096-byte ceiling) | +| transport liveness remains orthogonal | `BrowserSession::record_transport_loss` | +| normal end requires proved destruction | `BrowserSession::end` | +| incarnation exhaustion fails closed | `allocate_incarnation` | + +Historical exact `9cde981899950b900698a17e7fa739af59f6bb4f` / CI `34531025582` is RED for this successor: production exact coverage passed, but canonical formatting failed, and the raw port accessor plus missing transaction completion remained. Historical exact `729603ae4feadd369eee7819a45d6850604975da` / CI `34541860394` passed production exact coverage but failed the repository contract after the ADR lost the `DisposableContextDestroyError` trace. Exact `d5046e76cb7555b448b728ea1bed9ba1ea8de8c3` / CI `34573175780` passed Python repository contracts, then failed canonical formatting; production coverage stopped during measurement because the two intentional hostile lifecycle REDs were still unresolved. Historical GREEN never transfers. Protected-main integration is required before capability maturity can be promoted beyond `IMPLEMENTED_ON_ACTIVE_PR`. ## Buyer acceptance still open -This slice does not yet prove: - -- actual WebDriver BiDi `browser.createUserContext`/`browsingContext.create` integration and incarnation-scoped mapping; -- observed `browser.removeUserContext` post-condition for the exact owned boundary; -- a separately authorized reconciliation service consuming `BrowserSessionRecoveryEvidence`; -- Browser Session authority conversion into BiDi presentation/screen-area private witnesses; -- pinned Chromium post-condition observation after presentation mutation; -- crash/process-restart reconciliation of uncertain disposable contexts; -- 3/3 complete #299 Agent Task browser trials; -- protected-main release, SBOM, provenance, reproducibility, or rollback evidence. +This slice does not yet prove actual WebDriver BiDi lifecycle integration, observed removal post-condition, protocol-specific pending/accepted/quarantined binding, durable crash/process-restart recovery persistence, Browser Session authority conversion into BiDi presentation private witnesses, Chromium post-condition observation, #299 3/3 browser trials, or protected-main release/SBOM/provenance/reproducibility/rollback. ## Reference diff --git a/docs/uml/browser-session-lifecycle-authority.md b/docs/uml/browser-session-lifecycle-authority.md index 5b171cfbf..09cf518f1 100644 --- a/docs/uml/browser-session-lifecycle-authority.md +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -7,56 +7,113 @@ sequenceDiagram autonumber participant C as Application service participant S as BrowserSession aggregate - participant P as DisposableContextPort + participant BS as BoundBrowserSession + participant P as DisposableContextPort / AuthorizedContextOperationPort participant B as Browser adapter (planned) C->>S: start(valid BrowserSessionId) S->>S: allocate BrowserSessionIncarnation - C->>S: create_disposable_context(port) - S->>S: reserve monotonic context epoch - S->>P: create_disposable_context(session_id, incarnation) - P->>B: create fresh isolation boundary + browsing context + C->>S: bind_lifecycle_port(port by value) + S-->>C: BoundBrowserSession owns aggregate + exact port + Note over S,P: binding invokes no adapter callback; no public raw port accessor + + C->>BS: create_disposable_context() + BS->>S: require Active + reserve monotonic epoch + S->>S: mint DisposableContextCreateRequest(session, incarnation, attempt epoch) + S->>P: create_disposable_context(request) + P->>B: create/stage isolation boundary + browsing context B-->>P: unique isolation id + BrowsingContextId or typed create error - P-->>S: DisposableContextHandle - S->>S: register exact handle + Active epoch - S-->>C: PresentationMutationAuthority(session, incarnation, isolation, context, epoch) - - Note over C,S: Raw BrowserSessionId/BrowsingContextId/user-context id cannot mint authority. - - C->>S: advance_context_epoch(context_id) - S->>S: replace epoch; old authority becomes stale + P-->>S: DisposableContextHandle (still pending in adapter) + + alt domain handle accepted + S->>S: validate no isolation/context alias + S->>S: mint DisposableContextCreateCompletion(Accepted, exact attempt) + S->>P: complete_disposable_context_creation(completion) + P->>P: pending exact attempt → accepted + S->>S: register exact handle + Active epoch + S-->>C: PresentationMutationAuthority(session, incarnation, isolation, context, epoch) + else domain handle rejected + S->>S: retain duplicate handle as recovery evidence + S->>S: retain every other Active sibling as RecoveryRequiredOwnedHandle + S->>S: mint DisposableContextCreateCompletion(Rejected, exact attempt) + S->>P: complete_disposable_context_creation(completion) + P->>P: pending exact attempt → quarantined/non-authorizing + S->>S: RecoveryRequired + else completion cannot be proven + S->>S: retain UnsettledAdapterHandle + S->>S: retain every other Active sibling as RecoveryRequiredOwnedHandle + S->>S: RecoveryRequired + end + + C->>BS: execute_authorized_context_operation(authority, operation) + BS->>S: validate session/incarnation/isolation/context/epoch + alt authority current + S-->>BS: exact stored handle + BS->>BS: mint private AuthorizedContextOperationRequest + BS->>P: execute_authorized_context_operation(request) + P->>B: adapter-owned presentation/reconciliation command + B-->>P: typed adapter result + P-->>C: output or AuthorizedContextOperationError::Adapter + else stale or foreign authority + S-->>C: AuthorizedContextOperationError::BrowserSession + Note over BS,P: adapter I/O = 0 + end + + Note over C,S: Raw ids, adapter-selected values, diagnostic references, and a second adapter cannot mint lifecycle or presentation authority. + + C->>BS: advance_context_epoch(context_id) + BS->>S: replace epoch; old authority becomes stale S-->>C: new opaque authority carrying same incarnation + isolation - C->>S: destroy_disposable_context(authority, port) - S->>S: validate exact session/incarnation/isolation/context/epoch before I/O - S->>P: destroy_disposable_context(session_id, incarnation, stored handle) + C->>BS: destroy_disposable_context(authority) + BS->>S: validate exact session/incarnation/isolation/context/epoch before I/O + S->>S: mint DisposableContextDestroyRequest with exact stored handle + S->>P: destroy_disposable_context(request) P->>B: remove exact owned isolation boundary B-->>P: observed destruction post-condition or DisposableContextDestroyError - P-->>S: success - S->>S: context = Destroyed - C->>S: end() - S->>S: require every owned context Destroyed - S-->>C: Ended + alt destruction proved + P-->>S: success + S->>S: context = Destroyed + else destruction unproven + S->>S: retain UnprovenDestruction for failed handle + S->>S: retain each other Active sibling as RecoveryRequiredOwnedHandle + S->>S: RecoveryRequired; all active siblings become Uncertain + end + + C->>BS: finish() + alt every owned context Destroyed + BS->>S: end() + S-->>C: Ended + else ownership remains + BS->>S: end() + S-->>C: ActiveContextRemains + Note over C,P: same BoundBrowserSession + exact adapter remain available for cleanup/retry + end ``` -`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. +`BoundBrowserSession` is a linear lifecycle-port binding. It consumes one concrete port, exposes no public raw `&P`, and exposes no lifecycle method that accepts a replacement port. `AuthorizedContextOperationPort` adds a typed, purpose-bounded post-create operation vocabulary without exposing the adapter itself. Tests retain inert observation state separately from the moved adapter. + +`DisposableContextCreateRequest`, `DisposableContextCreateCompletion`, `DisposableContextDestroyRequest`, and `AuthorizedContextOperationRequest` are non-caller-constructible. The create request carries the reserved `BrowserContextEpoch` as a per-create transaction id; Browser Session alone decides whether the returned domain handle is accepted or rejected and validates current authority before any later adapter operation. -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. +For WebDriver BiDi, `DisposableIsolationId` maps to the user-context id created by `browser.createUserContext`. Protocol-specific pending/accepted/quarantined remote tuples and command semantics remain in the BiDi ACL boundary rather than this domain model. -## Recovery and transport state +## Recovery, transport, and abandonment state ```mermaid stateDiagram-v2 [*] --> Active - Active --> Active: fresh isolation + context / authority minted + Active --> Active: create candidate + exact Accepted completion + authority + Active --> Active: authorized operation / current authority / exact bound adapter Active --> Active: context epoch advanced / prior authority stale Active --> Active: exact owned isolation destruction proved Active --> Active: DisposableContextCreateError::CreateFailedClean + Active --> Active: failed finish / retain same bound owner 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 + Active --> RecoveryRequired: duplicate output + exact Rejected completion + sibling RecoveryRequiredOwnedHandle + Active --> RecoveryRequired: completion unproven / retain UnsettledAdapterHandle + sibling RecoveryRequiredOwnedHandle + Active --> RecoveryRequired: DisposableContextDestroyError / cleanup unproven + sibling RecoveryRequiredOwnedHandle + Active --> Ended: all owned contexts Destroyed + finish() + Active --> TransportLost: browser transport lost / retain TransportLossOwnedHandle / mark uncertain RecoveryRequired --> RecoveryRequired: transport_lost = true / preserve recovery evidence Ended --> [*] RecoveryRequired --> [*] @@ -64,38 +121,64 @@ stateDiagram-v2 note right of RecoveryRequired BrowserSessionRecoveryEvidence retains known - partial identity, duplicate handle, or exact - unproven-destruction handle. It grants no I/O. + partial identity, duplicate/unsettled handle, + exact unproven-destruction handle, and + RecoveryRequiredOwnedHandle for indirect siblings. + 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 +```mermaid +sequenceDiagram + autonumber + participant C as Application service + participant BS as BoundBrowserSession + participant P as exact bound adapter + participant O as Operability / recovery observer + + C->>BS: create accepted remote ownership + alt premature finish + C->>BS: finish() + BS-->>C: ActiveContextRemains; wrapper retained + C->>BS: destroy exact authority + BS->>P: proven remote destruction + C->>BS: finish() + BS-->>C: Ended + else ordinary wrapper abandonment + C-xBS: drop without proven cleanup + Note over BS,P: Drop performs no browser I/O + BS->>O: increment abandoned_bound_session_count() + Note over O: process-local signal only; not destruction proof or durable exact-handle storage + end ``` +The abandonment signal is deliberately weaker than durable recovery. Exact crash/process-restart reconciliation remains open until a canonical recovery owner persists `BrowserSessionRecoveryEvidence` before process termination. + ## 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 + participant A as BoundBrowserSession A + participant B as BoundBrowserSession B + participant PA as Lifecycle port A + participant PB as Lifecycle port B + + A->>A: start(S) => incarnation A; bind PA + A->>PA: create(request S, incarnation A, attempt 1) + PA-->>A: U, C pending + A->>PA: completion Accepted(attempt 1) + A->>PA: destroy(request S, incarnation A, U/C) + A->>A: finish() + + B->>B: start(S) => incarnation B; bind PB + B->>PB: create(request S, incarnation B, attempt 1) + PB-->>B: same U, same C pending + B->>PB: completion Accepted(attempt 1) + Note over A,B: local attempt/epoch may both equal 1, but incarnations differ B->>B: validate retained authority A - B-->>A: AuthorityMismatch before adapter I/O - B->>P: destroy with authority B + incarnation B + B-->>A: AuthorityMismatch before PB adapter I/O + B->>PB: operate/destroy only with authority B + incarnation B ``` -`RecoveryRequired` and `TransportLost` remain terminal for normal authority in this slice. A later reconciliation design may inspect `BrowserSessionRecoveryEvidence`, but it must not reconstruct cleanup authority from raw identifiers or treat command ACK as proof of destruction. +`RecoveryRequired` and `TransportLost` remain terminal for normal authority in this slice. Later reconciliation may inspect recovery evidence, 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 6e4487988..a37ea034a 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -31,30 +31,67 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: """Raw driver identifiers must never become caller-mintable authority tokens.""" source = (CRATE / "src/lib.rs").read_text(encoding="utf-8") - self.assertIn("pub struct BrowserSession", source) - self.assertIn("pub trait DisposableContextPort", source) - self.assertIn("pub struct DisposableIsolationId", source) - self.assertIn("pub struct DisposableContextHandle", source) - self.assertIn("pub struct BrowserSessionIncarnation", source) - self.assertIn("pub struct PresentationMutationAuthority", source) - self.assertIn("pub enum BrowserSessionRecoveryEvidence", source) + required_symbols = ( + "pub struct BrowserSession", + "pub struct BoundBrowserSession", + "pub trait DisposableContextPort", + "pub struct DisposableIsolationId", + "pub struct DisposableContextHandle", + "pub struct BrowserSessionIncarnation", + "pub struct PresentationMutationAuthority", + "pub struct DisposableContextCreateRequest", + "pub struct DisposableContextCreateCompletion", + "pub enum DisposableContextCreateDisposition", + "pub enum DisposableContextCreateCompletionError", + "pub struct DisposableContextDestroyRequest", + "pub enum BrowserSessionRecoveryEvidence", + "pub struct AuthorizedContextOperationRequest", + "pub enum AuthorizedContextOperationError", + "pub trait AuthorizedContextOperationPort", + "pub enum DisposableContextCreateError", + "pub enum DisposableContextDestroyError", + "pub fn abandoned_bound_session_count", + "pub fn finish", + "pub fn execute_authorized_context_operation", + ) + for symbol in required_symbols: + self.assertIn(symbol, 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.assertNotIn("DisposableContextPortId", source) + self.assertNotIn("fn port_id(&self)", source) + self.assertNotIn("pub const fn lifecycle_port", source) + self.assertNotIn("pub fn lifecycle_port", source) + self.assertNotIn("pub fn create_disposable_context", source) + self.assertIn("pub fn bind_lifecycle_port", source) + self.assertIn("attempt_epoch: BrowserContextEpoch", source) + self.assertIn("fn complete_disposable_context_creation(", source) + self.assertIn("DisposableContextCreateDisposition::Accepted", source) + self.assertIn("DisposableContextCreateDisposition::Rejected", source) self.assertIn("CreateFailedClean", source) self.assertIn("CreateFailedUncertain", source) self.assertIn("PartialCreationIsolation", source) self.assertIn("DuplicateAdapterHandle", source) + self.assertIn("UnsettledAdapterHandle", source) self.assertIn("UnprovenDestruction", source) - self.assertIn("create_disposable_context", source) + self.assertIn("RecoveryRequiredOwnedHandle", source) + self.assertIn("TransportLossOwnedHandle", source) + self.assertIn("create_disposable_context_with_port", 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("AuthorizedContextOperationError::BrowserSession", source) + self.assertIn("AuthorizedContextOperationError::Adapter", source) + self.assertIn("#[must_use =", source) + self.assertIn("impl

Drop for BoundBrowserSession

", source) + self.assertIn("", source) + self.assertIn("user-context", source) self.assertIn("Reconstructing cleanup authority", source) self.assertIn("sequential_incarnation_reuse_rejects_stale_authority", source) + self.assertIn("pub fn finish(&mut self)", source) + self.assertNotIn("pub fn finish(mut self)", source) authority_impl = source.split("impl PresentationMutationAuthority", 1)[1].split( "enum OwnedContextState", 1 @@ -62,28 +99,110 @@ 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.""" + create_request_impl = source.split("impl DisposableContextCreateRequest", 1)[1].split( + "pub enum DisposableContextCreateDisposition", 1 + )[0] + completion_impl = source.split("impl DisposableContextCreateCompletion", 1)[1].split( + "pub enum DisposableContextCreateCompletionError", 1 + )[0] + destroy_request_impl = source.split("impl DisposableContextDestroyRequest", 1)[1].split( + "pub trait DisposableContextPort", 1 + )[0] + operation_request_impl = source.split("impl AuthorizedContextOperationRequest", 1)[1].split( + "pub enum AuthorizedContextOperationError", 1 + )[0] + for request_impl in ( + create_request_impl, + completion_impl, + destroy_request_impl, + operation_request_impl, + ): + self.assertNotIn("pub fn new", request_impl) + self.assertNotIn("pub const fn new", request_impl) - 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, + def test_hostile_recovery_binding_and_operation_fixtures_remain_external(self) -> None: + """Recovery, binding, transactions, operations, and abandonment execute externally.""" + + destroy_hostile = (CRATE / "tests/destroy_failure_requires_recovery.rs").read_text( + encoding="utf-8" ) - 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, + reincarnation_hostile = (CRATE / "tests/sequential_incarnation_reuse.rs").read_text( + encoding="utf-8" + ) + preflight_hostile = (CRATE / "tests/lifecycle_port_preflight_side_effect.rs").read_text( + encoding="utf-8" + ) + substitution_hostile = (CRATE / "tests/lifecycle_port_same_id_spoof.rs").read_text( + encoding="utf-8" ) - self.assertIn("assert_ne!(session_a.incarnation(), session_b.incarnation());", reincarnation_hostile) - self.assertIn("assert!(port_b.destroy_incarnations.is_empty());", reincarnation_hostile) + transaction_hostile = (CRATE / "tests/creation_transaction_completion.rs").read_text( + encoding="utf-8" + ) + debug_hostile = (CRATE / "tests/bound_session_debug_redaction.rs").read_text( + encoding="utf-8" + ) + transport_hostile = (CRATE / "tests/transport_loss_recovery_evidence.rs").read_text( + encoding="utf-8" + ) + recovery_hostile = (CRATE / "tests/recovery_required_sibling_evidence.rs").read_text( + encoding="utf-8" + ) + operation_hostile = (CRATE / "tests/authorized_context_operation.rs").read_text( + encoding="utf-8" + ) + abandonment_hostile = (CRATE / "tests/bound_session_abandonment.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!(bound.record_transport_loss());", destroy_hostile) + self.assertIn("assert!(!bound.record_transport_loss());", destroy_hostile) + self.assertNotIn("lifecycle_port()", destroy_hostile) + + self.assertIn("stale_authority_cannot_cross_sequential_session_incarnations", reincarnation_hostile) + self.assertIn("assert_ne!(\n bound_a.browser_session().incarnation(),", reincarnation_hostile) + self.assertIn("assert!(destroy_b.borrow().is_empty());", reincarnation_hostile) + self.assertNotIn("lifecycle_port()", reincarnation_hostile) + + self.assertIn("lifecycle_binding_invokes_no_adapter_callback_before_authorized_create", preflight_hostile) + self.assertIn("identity_callbacks", preflight_hostile) + self.assertNotIn("bound.lifecycle_port()", preflight_hostile) + + self.assertIn("distinct_adapter_cannot_be_substituted_for_create_after_binding", substitution_hostile) + self.assertIn("distinct_adapter_cannot_be_substituted_for_destroy_after_binding", substitution_hostile) + self.assertNotIn("bound.lifecycle_port()", substitution_hostile) + + self.assertIn("accepted_and_rejected_create_candidates_are_correlated_by_exact_attempt", transaction_hostile) + self.assertIn("request.attempt_epoch().value()", transaction_hostile) + self.assertIn("DisposableContextCreateDisposition::Accepted", transaction_hostile) + self.assertIn("DisposableContextCreateDisposition::Rejected", transaction_hostile) + self.assertIn("assert!(ledger.pending.is_empty());", transaction_hostile) + + self.assertIn("bound_session_debug_never_executes_or_exposes_adapter_debug", debug_hostile) + self.assertIn("adapter-secret-sentinel", debug_hostile) + self.assertIn("debug_callbacks.get(),\n 0", debug_hostile) + + self.assertIn("transport_loss_preserves_exact_owned_handle_as_non_authorizing_recovery_evidence", transport_hostile) + self.assertIn("transport-user-context-501", transport_hostile) + self.assertIn("recovery_evidence().len(),\n 1", transport_hostile) + + self.assertIn("recovery_required_projects_exact_handles_for_indirectly_uncertain_siblings", recovery_hostile) + self.assertIn("BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle", recovery_hostile) + self.assertIn("indirectly invalidated sibling", recovery_hostile) + + self.assertIn("authorized_operation_uses_exact_bound_port_and_rejects_stale_authority_before_io", operation_hostile) + self.assertIn("AuthorizedContextOperationError::BrowserSession", operation_hostile) + self.assertIn("AuthorizedContextOperationError::Adapter", operation_hostile) + self.assertIn("stale authority must fail before the bound adapter", operation_hostile) + + self.assertIn("dropping_unresolved_bound_session_is_observable_without_implicit_browser_io", abandonment_hostile) + self.assertIn("abandoned_bound_session_count", abandonment_hostile) + self.assertIn("Drop must never pretend synchronous browser cleanup succeeded", abandonment_hostile) + self.assertIn("failed_finish_retains_same_bound_owner_for_cleanup_and_retry", abandonment_hostile) + self.assertIn("same bound lifecycle owner must remain available for cleanup", abandonment_hostile) + self.assertIn("proven_destruction_can_finish_without_abandonment_path", abandonment_hostile) + self.assertIn("bound.finish()", abandonment_hostile) def test_architecture_decision_and_traceability_are_explicit(self) -> None: """Disposable ownership must remain a Proposed, standards-traced active-PR claim.""" @@ -97,30 +216,72 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: uml = (ROOT / "docs/uml/browser-session-lifecycle-authority.md").read_text( encoding="utf-8" ) - self.assertIn("Status: Proposed", adr) - self.assertIn("WD-webdriver-bidi-20260909", adr) - self.assertIn("RecoveryRequired", adr) - self.assertIn("BrowserSessionIncarnation", adr) - self.assertIn("BrowserSessionRecoveryEvidence", adr) - self.assertIn("DisposableContextCreateError", adr) - self.assertIn("DisposableContextDestroyError", adr) - self.assertIn("CreateFailedClean", adr) - self.assertIn("CreateFailedUncertain", adr) - self.assertIn("transport liveness", adr) - self.assertIn("sequential", adr) - self.assertIn("unproven destruction", adr) - self.assertIn("IMPLEMENTED_ON_ACTIVE_PR", trace) - self.assertIn("RecoveryRequired", trace) - self.assertIn("BrowserSessionIncarnation", trace) - self.assertIn("lossless recovery evidence", trace) - self.assertIn("transport liveness", trace) - self.assertIn("sequential ABA", trace) - self.assertIn("command ACK", trace) - self.assertIn("PresentationMutationAuthority", uml) - self.assertIn("BrowserSessionIncarnation", uml) - self.assertIn("RecoveryRequired", uml) - self.assertIn("transport_lost", uml) - self.assertIn("DisposableContextDestroyError / cleanup unproven", uml) + for token in ( + "Status: Proposed", + "WD-webdriver-bidi-20260909", + "RecoveryRequired", + "RecoveryRequiredOwnedHandle", + "BrowserSessionIncarnation", + "BrowserSessionRecoveryEvidence", + "DisposableContextCreateRequest", + "DisposableContextCreateCompletion", + "DisposableContextDestroyRequest", + "BoundBrowserSession", + "linear lifecycle-port binding", + "no public raw port accessor", + "per-create transaction", + "DisposableContextCreateError", + "DisposableContextDestroyError", + "CreateFailedClean", + "CreateFailedUncertain", + "transport liveness", + "sequential", + "unproven destruction", + "AuthorizedContextOperationPort", + "TransportLossOwnedHandle", + "abandoned_bound_session_count", + "failed `finish()`", + "Drop", + "finish()", + ): + self.assertIn(token, adr) + + for token in ( + "IMPLEMENTED_ON_ACTIVE_PR", + "BoundBrowserSession", + "DisposableContextCreateCompletion", + "per-create transaction", + "no public raw port accessor", + "RecoveryRequired", + "RecoveryRequiredOwnedHandle", + "BrowserSessionIncarnation", + "lossless recovery evidence", + "transport liveness", + "Sequential ABA", + "command ACK", + "AuthorizedContextOperationPort", + "TransportLossOwnedHandle", + "abandoned_bound_session_count", + "durable crash/process-restart recovery", + ): + self.assertIn(token, trace) + + for token in ( + "PresentationMutationAuthority", + "BoundBrowserSession", + "DisposableContextCreateCompletion", + "BrowserSessionIncarnation", + "RecoveryRequired", + "RecoveryRequiredOwnedHandle", + "transport_lost", + "DisposableContextDestroyError / cleanup unproven", + "AuthorizedContextOperationRequest", + "AuthorizedContextOperationError::BrowserSession", + "TransportLossOwnedHandle", + "abandoned_bound_session_count", + "finish()", + ): + self.assertIn(token, uml) self.assertNotIn("IMPLEMENTED_ON_PROTECTED_MAIN", trace)