From 3ccbfdb0e819e844e6d4ddee3ad0bb8ccf816aa8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 04:13:49 +0900 Subject: [PATCH 01/50] test(browser-session): expose raw lifecycle port side door --- .../tests/lifecycle_port_authority.rs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 crates/originweave-browser-session/tests/lifecycle_port_authority.rs 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..78f924e6e --- /dev/null +++ b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs @@ -0,0 +1,73 @@ +use originweave_browser_session::{ + BrowserSession, BrowserSessionIncarnation, DisposableContextCreateError, + DisposableContextDestroyError, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct RecordingPort { + create_calls: usize, + destroy_calls: usize, +} + +impl DisposableContextPort for RecordingPort { + fn create_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + _incarnation: BrowserSessionIncarnation, + ) -> Result { + self.create_calls += 1; + Ok(DisposableContextHandle::new( + DisposableIsolationId::parse("raw-port-side-door").expect("valid isolation id"), + BrowsingContextId::new(41).expect("valid browsing context"), + )) + } + + fn destroy_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + _incarnation: BrowserSessionIncarnation, + _context: &DisposableContextHandle, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls += 1; + Ok(()) + } +} + +#[test] +fn raw_session_identity_cannot_directly_authorize_create_or_destroy() { + let session = BrowserSession::start(BrowserSessionId::new(7).expect("valid session id")) + .expect("incarnation capacity"); + let mut port = RecordingPort { + create_calls: 0, + destroy_calls: 0, + }; + + let direct_create = DisposableContextPort::create_disposable_context( + &mut port, + session.id(), + session.incarnation(), + ); + assert!( + direct_create.is_err(), + "raw session/incarnation values must not be sufficient lifecycle authority" + ); + assert_eq!(port.create_calls, 0, "unauthorized create reached adapter I/O"); + + let forged_handle = DisposableContextHandle::new( + DisposableIsolationId::parse("raw-port-side-door").expect("valid isolation id"), + BrowsingContextId::new(41).expect("valid browsing context"), + ); + let direct_destroy = DisposableContextPort::destroy_disposable_context( + &mut port, + session.id(), + session.incarnation(), + &forged_handle, + ); + assert!( + direct_destroy.is_err(), + "raw lifecycle tuple must not be sufficient destruction authority" + ); + assert_eq!(port.destroy_calls, 0, "unauthorized destroy reached adapter I/O"); +} From 2fde15ed66c10cddf511a82ca11959ed591ba2b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 04:17:44 +0900 Subject: [PATCH 02/50] test(browser-session): format lifecycle port hostile RED --- .../tests/lifecycle_port_authority.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/originweave-browser-session/tests/lifecycle_port_authority.rs b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs index 78f924e6e..6fcfd84dd 100644 --- a/crates/originweave-browser-session/tests/lifecycle_port_authority.rs +++ b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs @@ -53,7 +53,10 @@ fn raw_session_identity_cannot_directly_authorize_create_or_destroy() { direct_create.is_err(), "raw session/incarnation values must not be sufficient lifecycle authority" ); - assert_eq!(port.create_calls, 0, "unauthorized create reached adapter I/O"); + assert_eq!( + port.create_calls, 0, + "unauthorized create reached adapter I/O" + ); let forged_handle = DisposableContextHandle::new( DisposableIsolationId::parse("raw-port-side-door").expect("valid isolation id"), @@ -69,5 +72,8 @@ fn raw_session_identity_cannot_directly_authorize_create_or_destroy() { direct_destroy.is_err(), "raw lifecycle tuple must not be sufficient destruction authority" ); - assert_eq!(port.destroy_calls, 0, "unauthorized destroy reached adapter I/O"); + assert_eq!( + port.destroy_calls, 0, + "unauthorized destroy reached adapter I/O" + ); } From d4537bc63c91d0f8ddfd3e8aa63fae04929f0fad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 04:26:48 +0900 Subject: [PATCH 03/50] fix(browser-session): bind lifecycle I/O to aggregate-issued requests --- crates/originweave-browser-session/src/lib.rs | 234 ++++++++++++++++-- 1 file changed, 210 insertions(+), 24 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 66f5753c5..17cd60cf8 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -44,6 +44,8 @@ pub enum BrowserSessionError { DuplicateBrowsingContext, /// The port returned an isolation identity already known to this aggregate. DuplicateDisposableIsolation, + /// A different lifecycle-port instance was supplied after this Browser Session bound its port. + LifecyclePortMismatch, /// The requested context is not currently owned and active in this session. ContextNotOwned, /// The supplied authority belongs to another incarnation, isolation boundary, session, context, or epoch. @@ -130,6 +132,33 @@ impl BrowserSessionIncarnation { } } +/// Stable non-zero identity for one live disposable-context lifecycle-port instance. +/// +/// A reviewed adapter assigns this identity when the adapter instance is created and keeps it stable +/// for that instance's lifetime. Browser Session binds the first port identity it uses and rejects a +/// different identity before lifecycle I/O. This value identifies an adapter instance; it grants no +/// lifecycle authority by itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DisposableContextPortId(u64); + +impl DisposableContextPortId { + /// Create a non-zero lifecycle-port instance identity. + #[must_use] + pub const fn new(value: u64) -> Option { + if value == 0 { + None + } else { + Some(Self(value)) + } + } + + /// Return the adapter-defined non-zero identity value. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + /// Adapter result for one newly created disposable browser context. /// /// The isolation identity scopes the lifecycle boundary used for destruction; the browsing-context @@ -178,12 +207,87 @@ pub enum BrowserSessionRecoveryEvidence { UnprovenDestruction(DisposableContextHandle), } +/// Opaque Browser Session-issued request for one disposable-context creation attempt. +/// +/// There is deliberately no public constructor. Raw session, incarnation, or port identifiers are +/// insufficient to call the lifecycle port; Browser Session creates this request only after it has +/// validated aggregate state and bound the lifecycle-port instance. +#[derive(Debug)] +pub struct DisposableContextCreateRequest { + port_id: DisposableContextPortId, + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, +} + +impl DisposableContextCreateRequest { + /// Return the lifecycle-port instance this request is bound to. + #[must_use] + pub const fn port_id(&self) -> DisposableContextPortId { + self.port_id + } + + /// 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 + } +} + +/// Opaque Browser Session-issued request for destruction of one exact owned disposable context. +/// +/// There is deliberately no public constructor. The request is created only after Browser Session +/// validates the supplied presentation authority against current aggregate ownership and the bound +/// lifecycle-port instance. +#[derive(Debug)] +pub struct DisposableContextDestroyRequest { + port_id: DisposableContextPortId, + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + context: DisposableContextHandle, +} + +impl DisposableContextDestroyRequest { + /// Return the lifecycle-port instance this request is bound to. + #[must_use] + pub const fn port_id(&self) -> DisposableContextPortId { + self.port_id + } + + /// 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 +/// `port_id` must be side-effect-free, stable for one live adapter instance, and distinct from other +/// simultaneously usable instances. Browser Session binds the first port id used by an aggregate and +/// rejects a different id before create or destroy I/O. This closes the raw port side door and prevents +/// a second adapter instance from becoming an alternate lifecycle target after the aggregate is bound. +/// +/// The create/destroy requests have private construction paths. A caller that merely knows a browser +/// session id, incarnation, context id, isolation id, or port id cannot issue lifecycle I/O directly. +/// For WebDriver BiDi the isolation identity maps one-to-one to the user-context identifier returned by /// `browser.createUserContext`. /// /// [`DisposableContextCreateError::CreateFailedClean`] is allowed only when the adapter proves that no @@ -191,23 +295,23 @@ pub enum BrowserSessionRecoveryEvidence { /// 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. + /// Return this live adapter instance's stable lifecycle-port identity without browser I/O. + fn port_id(&self) -> DisposableContextPortId; + + /// 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. + /// 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>; } @@ -291,6 +395,7 @@ pub struct BrowserSession { state: BrowserSessionState, transport_lost: bool, next_epoch: u64, + lifecycle_port_id: Option, contexts: BTreeMap, recovery_evidence: Vec, } @@ -315,6 +420,7 @@ impl BrowserSession { state: BrowserSessionState::Active, transport_lost: false, next_epoch: 1, + lifecycle_port_id: None, contexts: BTreeMap::new(), recovery_evidence: Vec::new(), }) @@ -356,8 +462,14 @@ impl BrowserSession { port: &mut P, ) -> Result { self.require_active()?; + let port_id = self.bind_lifecycle_port(port)?; let epoch = reserve_epoch(&mut self.next_epoch)?; - let handle = match port.create_disposable_context(self.id, self.incarnation) { + let request = DisposableContextCreateRequest { + port_id, + browser_session: self.id, + incarnation: self.incarnation, + }; + let handle = match port.create_disposable_context(&request) { Ok(handle) => handle, Err(DisposableContextCreateError::CreateFailedClean) => { return Err(BrowserSessionError::ContextCreationFailed); @@ -455,11 +567,17 @@ impl BrowserSession { authority: &PresentationMutationAuthority, port: &mut P, ) -> Result<(), BrowserSessionError> { + let port_id = self.require_bound_lifecycle_port(port)?; 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) { + let request = DisposableContextDestroyRequest { + port_id, + browser_session, + incarnation, + context: record.handle.clone(), + }; + match port.destroy_disposable_context(&request) { Ok(()) => { record.state = OwnedContextState::Destroyed; Ok(()) @@ -467,7 +585,9 @@ impl BrowserSession { Err(DisposableContextDestroyError::DestroyFailed) => { record.state = OwnedContextState::Uncertain; self.recovery_evidence - .push(BrowserSessionRecoveryEvidence::UnprovenDestruction(handle)); + .push(BrowserSessionRecoveryEvidence::UnprovenDestruction( + request.context, + )); self.enter_recovery_required(); Err(BrowserSessionError::ContextDestructionFailed) } @@ -504,6 +624,31 @@ impl BrowserSession { Ok(()) } + fn bind_lifecycle_port( + &mut self, + port: &P, + ) -> Result { + let supplied = port.port_id(); + match self.lifecycle_port_id { + None => { + self.lifecycle_port_id = Some(supplied); + Ok(supplied) + } + Some(bound) if bound == supplied => Ok(bound), + Some(_) => Err(BrowserSessionError::LifecyclePortMismatch), + } + } + + fn require_bound_lifecycle_port( + &self, + port: &P, + ) -> Result { + match self.lifecycle_port_id { + Some(bound) if bound == port.port_id() => Ok(bound), + _ => Err(BrowserSessionError::LifecyclePortMismatch), + } + } + fn require_active(&self) -> Result<(), BrowserSessionError> { if self.state == BrowserSessionState::Active { Ok(()) @@ -587,6 +732,7 @@ mod tests { #[derive(Debug)] struct TestPort { + port_id: DisposableContextPortId, next_handle: DisposableContextHandle, create_error: Option, fail_destroy: bool, @@ -599,7 +745,12 @@ mod tests { impl TestPort { fn new(context: u64, isolation: &str) -> Self { + Self::with_port_id(context, isolation, 1) + } + + fn with_port_id(context: u64, isolation: &str, port_id: u64) -> Self { Self { + port_id: DisposableContextPortId::new(port_id).expect("valid port id"), next_handle: DisposableContextHandle::new( isolation_id(isolation), context_id(context), @@ -616,13 +767,17 @@ mod tests { } impl DisposableContextPort for TestPort { + fn port_id(&self) -> DisposableContextPortId { + self.port_id + } + fn create_disposable_context( &mut self, - _browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, + request: &DisposableContextCreateRequest, ) -> Result { + assert_eq!(request.port_id(), self.port_id); self.create_calls += 1; - self.create_incarnations.push(incarnation); + self.create_incarnations.push(request.incarnation()); match self.create_error.clone() { Some(error) => Err(error), None => Ok(self.next_handle.clone()), @@ -631,13 +786,13 @@ mod tests { fn destroy_disposable_context( &mut self, - _browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, - context: &DisposableContextHandle, + request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { + assert_eq!(request.port_id(), self.port_id); self.destroy_calls += 1; - self.destroy_incarnations.push(incarnation); - self.destroyed_isolations.push(context.isolation.clone()); + self.destroy_incarnations.push(request.incarnation()); + self.destroyed_isolations + .push(request.context().isolation.clone()); if self.fail_destroy { Err(DisposableContextDestroyError::DestroyFailed) } else { @@ -685,6 +840,11 @@ mod tests { let handle = DisposableContextHandle::new(valid.clone(), context_id(10)); assert_eq!(handle.isolation(), &valid); assert_eq!(handle.browsing_context(), context_id(10)); + assert_eq!(DisposableContextPortId::new(0), None); + assert_eq!( + DisposableContextPortId::new(17).expect("valid port id").value(), + 17 + ); } #[test] @@ -798,6 +958,32 @@ mod tests { ); } + #[test] + fn lifecycle_port_binding_rejects_other_adapter_before_io() { + let mut session = session(32); + let mut first_port = TestPort::with_port_id(320, "isolation-320", 11); + let authority = session + .create_disposable_context(&mut first_port) + .expect("first lifecycle port is bound"); + + let mut other_port = TestPort::with_port_id(321, "isolation-321", 12); + assert_eq!( + session.create_disposable_context(&mut other_port), + Err(BrowserSessionError::LifecyclePortMismatch) + ); + assert_eq!(other_port.create_calls, 0); + assert_eq!( + session.destroy_disposable_context(&authority, &mut other_port), + Err(BrowserSessionError::LifecyclePortMismatch) + ); + assert_eq!(other_port.destroy_calls, 0); + + session + .destroy_disposable_context(&authority, &mut first_port) + .expect("bound lifecycle port remains authorized"); + assert_eq!(first_port.destroy_calls, 1); + } + #[test] fn epoch_exhaustion_prevents_creation_io() { let mut exhausted_session = session(4); From 97e0a4d875166ad733e78c1d3f213454ee615f01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 04:27:10 +0900 Subject: [PATCH 04/50] test(browser-session): verify aggregate-issued lifecycle requests --- .../tests/lifecycle_port_authority.rs | 88 ++++++++++--------- 1 file changed, 45 insertions(+), 43 deletions(-) diff --git a/crates/originweave-browser-session/tests/lifecycle_port_authority.rs b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs index 6fcfd84dd..5c69cecfe 100644 --- a/crates/originweave-browser-session/tests/lifecycle_port_authority.rs +++ b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs @@ -1,79 +1,81 @@ use originweave_browser_session::{ - BrowserSession, BrowserSessionIncarnation, DisposableContextCreateError, - DisposableContextDestroyError, DisposableContextHandle, DisposableContextPort, - DisposableIsolationId, + BrowserSession, BrowserSessionError, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableContextPortId, DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct RecordingPort { + port_id: DisposableContextPortId, create_calls: usize, destroy_calls: usize, } +impl RecordingPort { + fn new(port_id: u64) -> Self { + Self { + port_id: DisposableContextPortId::new(port_id).expect("valid port id"), + create_calls: 0, + destroy_calls: 0, + } + } +} + impl DisposableContextPort for RecordingPort { + fn port_id(&self) -> DisposableContextPortId { + self.port_id + } + fn create_disposable_context( &mut self, - _browser_session: BrowserSessionId, - _incarnation: BrowserSessionIncarnation, + request: &DisposableContextCreateRequest, ) -> Result { + assert_eq!(request.port_id(), self.port_id); + assert_eq!(request.browser_session(), BrowserSessionId::new(7).unwrap()); self.create_calls += 1; Ok(DisposableContextHandle::new( - DisposableIsolationId::parse("raw-port-side-door").expect("valid isolation id"), + DisposableIsolationId::parse("aggregate-issued-request").expect("valid isolation id"), BrowsingContextId::new(41).expect("valid browsing context"), )) } fn destroy_disposable_context( &mut self, - _browser_session: BrowserSessionId, - _incarnation: BrowserSessionIncarnation, - _context: &DisposableContextHandle, + request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { + assert_eq!(request.port_id(), self.port_id); + assert_eq!(request.browser_session(), BrowserSessionId::new(7).unwrap()); + assert_eq!(request.context().browsing_context(), BrowsingContextId::new(41).unwrap()); self.destroy_calls += 1; Ok(()) } } #[test] -fn raw_session_identity_cannot_directly_authorize_create_or_destroy() { - let session = BrowserSession::start(BrowserSessionId::new(7).expect("valid session id")) +fn aggregate_issued_request_binds_lifecycle_io_to_one_port() { + let mut session = BrowserSession::start(BrowserSessionId::new(7).expect("valid session id")) .expect("incarnation capacity"); - let mut port = RecordingPort { - create_calls: 0, - destroy_calls: 0, - }; + let mut bound_port = RecordingPort::new(101); + let authority = session + .create_disposable_context(&mut bound_port) + .expect("Browser Session-issued create request"); + assert_eq!(bound_port.create_calls, 1); - let direct_create = DisposableContextPort::create_disposable_context( - &mut port, - session.id(), - session.incarnation(), - ); - assert!( - direct_create.is_err(), - "raw session/incarnation values must not be sufficient lifecycle authority" - ); + let mut other_port = RecordingPort::new(102); assert_eq!( - port.create_calls, 0, - "unauthorized create reached adapter I/O" - ); - - let forged_handle = DisposableContextHandle::new( - DisposableIsolationId::parse("raw-port-side-door").expect("valid isolation id"), - BrowsingContextId::new(41).expect("valid browsing context"), - ); - let direct_destroy = DisposableContextPort::destroy_disposable_context( - &mut port, - session.id(), - session.incarnation(), - &forged_handle, - ); - assert!( - direct_destroy.is_err(), - "raw lifecycle tuple must not be sufficient destruction authority" + session.create_disposable_context(&mut other_port), + Err(BrowserSessionError::LifecyclePortMismatch) ); + assert_eq!(other_port.create_calls, 0, "wrong port reached create I/O"); assert_eq!( - port.destroy_calls, 0, - "unauthorized destroy reached adapter I/O" + session.destroy_disposable_context(&authority, &mut other_port), + Err(BrowserSessionError::LifecyclePortMismatch) ); + assert_eq!(other_port.destroy_calls, 0, "wrong port reached destroy I/O"); + + session + .destroy_disposable_context(&authority, &mut bound_port) + .expect("Browser Session-issued destroy request"); + assert_eq!(bound_port.destroy_calls, 1); } From f4b6faad15042c40c90db781055d1c87d0ff79ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 04:37:28 +0900 Subject: [PATCH 05/50] test(browser-session): expose same-id lifecycle port spoof --- .../tests/lifecycle_port_same_id_spoof.rs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 crates/originweave-browser-session/tests/lifecycle_port_same_id_spoof.rs 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..bf437dc20 --- /dev/null +++ b/crates/originweave-browser-session/tests/lifecycle_port_same_id_spoof.rs @@ -0,0 +1,94 @@ +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableContextPortId, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct RecordingPort { + port_id: DisposableContextPortId, + context: BrowsingContextId, + isolation: &'static str, + create_calls: usize, + destroy_calls: usize, +} + +impl RecordingPort { + fn new(port_id: u64, context: u64, isolation: &'static str) -> Self { + Self { + port_id: DisposableContextPortId::new(port_id).expect("valid port id"), + context: BrowsingContextId::new(context).expect("valid browsing context"), + isolation, + create_calls: 0, + destroy_calls: 0, + } + } +} + +impl DisposableContextPort for RecordingPort { + fn port_id(&self) -> DisposableContextPortId { + self.port_id + } + + fn create_disposable_context( + &mut self, + request: &DisposableContextCreateRequest, + ) -> Result { + assert_eq!(request.port_id(), self.port_id); + self.create_calls += 1; + Ok(DisposableContextHandle::new( + DisposableIsolationId::parse(self.isolation).expect("valid isolation id"), + self.context, + )) + } + + fn destroy_disposable_context( + &mut self, + request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + assert_eq!(request.port_id(), self.port_id); + self.destroy_calls += 1; + Ok(()) + } +} + +#[test] +fn distinct_port_with_same_claimed_id_cannot_create() { + let mut session = BrowserSession::start(BrowserSessionId::new(17).expect("valid session id")) + .expect("incarnation capacity"); + let mut approved_port = RecordingPort::new(101, 41, "approved-isolation"); + session + .create_disposable_context(&mut approved_port) + .expect("bind approved port"); + + let mut spoofing_port = RecordingPort::new(101, 42, "spoofed-isolation"); + assert_eq!( + session.create_disposable_context(&mut spoofing_port), + Err(BrowserSessionError::LifecyclePortMismatch) + ); + assert_eq!( + spoofing_port.create_calls, 0, + "distinct adapter with the same self-reported id reached create I/O" + ); +} + +#[test] +fn distinct_port_with_same_claimed_id_cannot_destroy() { + let mut session = BrowserSession::start(BrowserSessionId::new(18).expect("valid session id")) + .expect("incarnation capacity"); + let mut approved_port = RecordingPort::new(101, 51, "approved-isolation"); + let authority = session + .create_disposable_context(&mut approved_port) + .expect("bind approved port"); + + let mut spoofing_port = RecordingPort::new(101, 52, "spoofed-isolation"); + assert_eq!( + session.destroy_disposable_context(&authority, &mut spoofing_port), + Err(BrowserSessionError::LifecyclePortMismatch) + ); + assert_eq!( + spoofing_port.destroy_calls, 0, + "distinct adapter with the same self-reported id reached destroy I/O" + ); +} From d8388560d0c3bbebf8496812687d92139f6bb6f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 04:40:28 +0900 Subject: [PATCH 06/50] test(browser-session): update recovery port contract --- .../destroy_failure_requires_recovery.rs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) 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..5833504a5 100644 --- a/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs +++ b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs @@ -1,12 +1,14 @@ use originweave_browser_session::{ - BrowserSession, BrowserSessionError, BrowserSessionIncarnation, BrowserSessionRecoveryEvidence, - BrowserSessionState, DisposableContextCreateError, DisposableContextDestroyError, - DisposableContextHandle, DisposableContextPort, DisposableIsolationId, + BrowserSession, BrowserSessionError, BrowserSessionRecoveryEvidence, BrowserSessionState, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableContextPortId, DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct FailingDestroyPort { + port_id: DisposableContextPortId, next_handle: DisposableContextHandle, create_calls: usize, destroy_calls: usize, @@ -18,7 +20,10 @@ impl FailingDestroyPort { .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")?; + let port_id = DisposableContextPortId::new(context) + .ok_or("static fixture lifecycle port id must be non-zero")?; Ok(Self { + port_id, next_handle: DisposableContextHandle::new(isolation, browsing_context), create_calls: 0, destroy_calls: 0, @@ -27,21 +32,24 @@ impl FailingDestroyPort { } impl DisposableContextPort for FailingDestroyPort { + fn port_id(&self) -> DisposableContextPortId { + self.port_id + } + fn create_disposable_context( &mut self, - _browser_session: BrowserSessionId, - _incarnation: BrowserSessionIncarnation, + request: &DisposableContextCreateRequest, ) -> Result { + assert_eq!(request.port_id(), self.port_id); self.create_calls += 1; Ok(self.next_handle.clone()) } fn destroy_disposable_context( &mut self, - _browser_session: BrowserSessionId, - _incarnation: BrowserSessionIncarnation, - _context: &DisposableContextHandle, + request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { + assert_eq!(request.port_id(), self.port_id); self.destroy_calls += 1; Err(DisposableContextDestroyError::DestroyFailed) } From b6432ab4944098aa2c36288985b1d8ffa323cb82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 04:40:46 +0900 Subject: [PATCH 07/50] test(browser-session): update incarnation reuse port contract --- .../tests/sequential_incarnation_reuse.rs | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs index 355201280..a3e49f1b6 100644 --- a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs +++ b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs @@ -1,12 +1,13 @@ use originweave_browser_session::{ BrowserSession, BrowserSessionError, BrowserSessionIncarnation, DisposableContextCreateError, - DisposableContextDestroyError, DisposableContextHandle, DisposableContextPort, - DisposableIsolationId, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableContextPortId, DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct ReusingPort { + port_id: DisposableContextPortId, handle: DisposableContextHandle, create_incarnations: Vec, destroy_incarnations: Vec, @@ -18,7 +19,10 @@ impl ReusingPort { .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")?; + let port_id = DisposableContextPortId::new(context) + .ok_or("static fixture lifecycle port id must be non-zero")?; Ok(Self { + port_id, handle: DisposableContextHandle::new(isolation, browsing_context), create_incarnations: Vec::new(), destroy_incarnations: Vec::new(), @@ -27,22 +31,25 @@ impl ReusingPort { } impl DisposableContextPort for ReusingPort { + fn port_id(&self) -> DisposableContextPortId { + self.port_id + } + fn create_disposable_context( &mut self, - _browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, + request: &DisposableContextCreateRequest, ) -> Result { - self.create_incarnations.push(incarnation); + assert_eq!(request.port_id(), self.port_id); + self.create_incarnations.push(request.incarnation()); Ok(self.handle.clone()) } fn destroy_disposable_context( &mut self, - _browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, - _context: &DisposableContextHandle, + request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { - self.destroy_incarnations.push(incarnation); + assert_eq!(request.port_id(), self.port_id); + self.destroy_incarnations.push(request.incarnation()); Ok(()) } } From 9caf9bbe4228c443b7d5a4279831765a6a38765a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 04:43:42 +0900 Subject: [PATCH 08/50] style(browser-session): apply canonical rustfmt --- .../tests/lifecycle_port_authority.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/originweave-browser-session/tests/lifecycle_port_authority.rs b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs index 5c69cecfe..b2b23f541 100644 --- a/crates/originweave-browser-session/tests/lifecycle_port_authority.rs +++ b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs @@ -46,7 +46,10 @@ impl DisposableContextPort for RecordingPort { ) -> Result<(), DisposableContextDestroyError> { assert_eq!(request.port_id(), self.port_id); assert_eq!(request.browser_session(), BrowserSessionId::new(7).unwrap()); - assert_eq!(request.context().browsing_context(), BrowsingContextId::new(41).unwrap()); + assert_eq!( + request.context().browsing_context(), + BrowsingContextId::new(41).unwrap() + ); self.destroy_calls += 1; Ok(()) } @@ -72,7 +75,10 @@ fn aggregate_issued_request_binds_lifecycle_io_to_one_port() { session.destroy_disposable_context(&authority, &mut other_port), Err(BrowserSessionError::LifecyclePortMismatch) ); - assert_eq!(other_port.destroy_calls, 0, "wrong port reached destroy I/O"); + assert_eq!( + other_port.destroy_calls, 0, + "wrong port reached destroy I/O" + ); session .destroy_disposable_context(&authority, &mut bound_port) From d43a4d86c8487ebdb9db9f1c4650fb7ee6225afc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 05:08:26 +0900 Subject: [PATCH 09/50] test(browser-session): expose lifecycle preflight side effect --- .../lifecycle_port_preflight_side_effect.rs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs 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..9a5baafa1 --- /dev/null +++ b/crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs @@ -0,0 +1,67 @@ +use std::cell::Cell; + +use originweave_browser_session::{ + BrowserSession, DisposableContextCreateError, DisposableContextCreateRequest, + DisposableContextDestroyError, DisposableContextDestroyRequest, DisposableContextHandle, + DisposableContextPort, DisposableContextPortId, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct SideEffectingIdentityPort { + identity_callbacks: Cell, + create_calls: usize, +} + +impl SideEffectingIdentityPort { + fn new() -> Self { + Self { + identity_callbacks: Cell::new(0), + create_calls: 0, + } + } +} + +impl DisposableContextPort for SideEffectingIdentityPort { + fn port_id(&self) -> DisposableContextPortId { + self.identity_callbacks + .set(self.identity_callbacks.get().saturating_add(1)); + DisposableContextPortId::new(401).expect("valid port id") + } + + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.create_calls += 1; + Ok(DisposableContextHandle::new( + DisposableIsolationId::parse("preflight-user-context").expect("valid isolation id"), + BrowsingContextId::new(401).expect("valid browsing context"), + )) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Ok(()) + } +} + +#[test] +fn lifecycle_authority_does_not_depend_on_side_effecting_identity_preflight() { + let mut session = BrowserSession::start(BrowserSessionId::new(401).expect("valid session id")) + .expect("incarnation capacity"); + let mut port = SideEffectingIdentityPort::new(); + + session + .create_disposable_context(&mut port) + .expect("authorized create"); + + assert_eq!( + port.identity_callbacks.get(), + 0, + "Browser Session invoked an arbitrary adapter callback before lifecycle authority was established" + ); + assert_eq!(port.create_calls, 1); +} From 9cde981899950b900698a17e7fa739af59f6bb4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 06:12:36 +0900 Subject: [PATCH 10/50] fix(browser-session): bind lifecycle port structurally --- crates/originweave-browser-session/src/lib.rs | 708 +++++++++--------- .../destroy_failure_requires_recovery.rs | 61 +- .../tests/lifecycle_port_authority.rs | 53 +- .../lifecycle_port_preflight_side_effect.rs | 34 +- .../tests/lifecycle_port_same_id_spoof.rs | 73 +- .../tests/sequential_incarnation_reuse.rs | 68 +- ...er-session-disposable-context-authority.md | 107 ++- .../browser-session-lifecycle-authority.md | 66 +- .../browser-session-lifecycle-authority.md | 62 +- ...test_browser_session_lifecycle_contract.py | 63 +- 10 files changed, 659 insertions(+), 636 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 17cd60cf8..f020f1c9c 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -44,8 +44,6 @@ pub enum BrowserSessionError { DuplicateBrowsingContext, /// The port returned an isolation identity already known to this aggregate. DuplicateDisposableIsolation, - /// A different lifecycle-port instance was supplied after this Browser Session bound its port. - LifecyclePortMismatch, /// The requested context is not currently owned and active in this session. ContextNotOwned, /// The supplied authority belongs to another incarnation, isolation boundary, session, context, or epoch. @@ -132,33 +130,6 @@ impl BrowserSessionIncarnation { } } -/// Stable non-zero identity for one live disposable-context lifecycle-port instance. -/// -/// A reviewed adapter assigns this identity when the adapter instance is created and keeps it stable -/// for that instance's lifetime. Browser Session binds the first port identity it uses and rejects a -/// different identity before lifecycle I/O. This value identifies an adapter instance; it grants no -/// lifecycle authority by itself. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct DisposableContextPortId(u64); - -impl DisposableContextPortId { - /// Create a non-zero lifecycle-port instance identity. - #[must_use] - pub const fn new(value: u64) -> Option { - if value == 0 { - None - } else { - Some(Self(value)) - } - } - - /// Return the adapter-defined non-zero identity value. - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } -} - /// Adapter result for one newly created disposable browser context. /// /// The isolation identity scopes the lifecycle boundary used for destruction; the browsing-context @@ -209,23 +180,16 @@ pub enum BrowserSessionRecoveryEvidence { /// Opaque Browser Session-issued request for one disposable-context creation attempt. /// -/// There is deliberately no public constructor. Raw session, incarnation, or port identifiers are -/// insufficient to call the lifecycle port; Browser Session creates this request only after it has -/// validated aggregate state and bound the lifecycle-port instance. +/// 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 { - port_id: DisposableContextPortId, browser_session: BrowserSessionId, incarnation: BrowserSessionIncarnation, } impl DisposableContextCreateRequest { - /// Return the lifecycle-port instance this request is bound to. - #[must_use] - pub const fn port_id(&self) -> DisposableContextPortId { - self.port_id - } - /// Return the Browser Session transport identity for adapter addressability. #[must_use] pub const fn browser_session(&self) -> BrowserSessionId { @@ -241,24 +205,17 @@ impl DisposableContextCreateRequest { /// Opaque Browser Session-issued request for destruction of one exact owned disposable context. /// -/// There is deliberately no public constructor. The request is created only after Browser Session -/// validates the supplied presentation authority against current aggregate ownership and the bound -/// lifecycle-port instance. +/// 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 { - port_id: DisposableContextPortId, browser_session: BrowserSessionId, incarnation: BrowserSessionIncarnation, context: DisposableContextHandle, } impl DisposableContextDestroyRequest { - /// Return the lifecycle-port instance this request is bound to. - #[must_use] - pub const fn port_id(&self) -> DisposableContextPortId { - self.port_id - } - /// Return the Browser Session transport identity for adapter addressability. #[must_use] pub const fn browser_session(&self) -> BrowserSessionId { @@ -280,28 +237,21 @@ impl DisposableContextDestroyRequest { /// Port implemented by a reviewed browser adapter for disposable context lifecycle operations. /// -/// `port_id` must be side-effect-free, stable for one live adapter instance, and distinct from other -/// simultaneously usable instances. Browser Session binds the first port id used by an aggregate and -/// rejects a different id before create or destroy I/O. This closes the raw port side door and prevents -/// a second adapter instance from becoming an alternate lifecycle target after the aggregate is bound. -/// -/// The create/destroy requests have private construction paths. A caller that merely knows a browser -/// session id, incarnation, context id, isolation id, or port id cannot issue lifecycle I/O directly. -/// 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 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 { - /// Return this live adapter instance's stable lifecycle-port identity without browser I/O. - fn port_id(&self) -> DisposableContextPortId; - /// Create one fresh disposable isolation boundary and browsing context for this authorized request. fn create_disposable_context( &mut self, @@ -330,8 +280,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, @@ -395,11 +346,21 @@ pub struct BrowserSession { state: BrowserSessionState, transport_lost: bool, next_epoch: u64, - lifecycle_port_id: Option, contexts: BTreeMap, 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. +#[derive(Debug)] +pub struct BoundBrowserSession

{ + session: BrowserSession, + port: P, +} + impl BrowserSession { /// Start an active Browser Session around an already validated transport session identity. /// @@ -420,12 +381,23 @@ impl BrowserSession { state: BrowserSessionState::Active, transport_lost: false, next_epoch: 1, - lifecycle_port_id: None, contexts: BTreeMap::new(), recovery_evidence: Vec::new(), }) } + /// 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 { @@ -456,16 +428,85 @@ impl BrowserSession { &self.recovery_evidence } - /// Create and register one disposable context, then mint authority for its first epoch. - pub fn create_disposable_context( + /// Return current presentation authority for an already-owned active context. + pub fn presentation_authority( + &self, + browsing_context: BrowsingContextId, + ) -> Result { + self.require_active()?; + let record = self + .contexts + .get(&browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + Ok(Self::authority_for( + self.id, + self.incarnation, + &record.handle, + record.epoch, + )) + } + + /// Advance one active owned context to a new authority epoch. + pub fn advance_context_epoch( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + self.require_active()?; + let browser_session = self.id; + let incarnation = self.incarnation; + let record = self + .contexts + .get_mut(&browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + let next = reserve_epoch(&mut self.next_epoch)?; + record.epoch = next; + Ok(Self::authority_for( + browser_session, + incarnation, + &record.handle, + next, + )) + } + + /// Record browser transport loss independently from ownership-recovery state. + /// + /// Returns `true` only for the first observed transport loss. If ownership was already uncertain, + /// `RecoveryRequired` remains the lifecycle state while the transport-loss fact is retained. + pub fn record_transport_loss(&mut self) -> bool { + if self.transport_lost || self.state == BrowserSessionState::Ended { + return false; + } + self.transport_lost = true; + if self.state == BrowserSessionState::Active { + self.state = BrowserSessionState::TransportLost; + self.mark_active_contexts_uncertain(); + } + true + } + + /// End the Browser Session only after every owned context has proven destruction. + pub fn end(&mut self) -> Result<(), BrowserSessionError> { + self.require_active()?; + if self + .contexts + .values() + .any(|record| record.state != OwnedContextState::Destroyed) + { + return Err(BrowserSessionError::ActiveContextRemains); + } + self.state = BrowserSessionState::Ended; + Ok(()) + } + + fn create_disposable_context_with_port( &mut self, port: &mut P, ) -> Result { self.require_active()?; - let port_id = self.bind_lifecycle_port(port)?; let epoch = reserve_epoch(&mut self.next_epoch)?; let request = DisposableContextCreateRequest { - port_id, browser_session: self.id, incarnation: self.incarnation, }; @@ -519,60 +560,15 @@ impl BrowserSession { Ok(authority) } - /// Return current presentation authority for an already-owned active context. - pub fn presentation_authority( - &self, - browsing_context: BrowsingContextId, - ) -> Result { - self.require_active()?; - let record = self - .contexts - .get(&browsing_context) - .filter(|record| record.state == OwnedContextState::Active) - .ok_or(BrowserSessionError::ContextNotOwned)?; - Ok(Self::authority_for( - self.id, - self.incarnation, - &record.handle, - record.epoch, - )) - } - - /// Advance one active owned context to a new authority epoch. - pub fn advance_context_epoch( - &mut self, - browsing_context: BrowsingContextId, - ) -> Result { - self.require_active()?; - let browser_session = self.id; - let incarnation = self.incarnation; - let record = self - .contexts - .get_mut(&browsing_context) - .filter(|record| record.state == OwnedContextState::Active) - .ok_or(BrowserSessionError::ContextNotOwned)?; - let next = reserve_epoch(&mut self.next_epoch)?; - record.epoch = next; - Ok(Self::authority_for( - browser_session, - incarnation, - &record.handle, - next, - )) - } - - /// Destroy the disposable isolation boundary covered by the supplied exact-epoch authority. - pub fn destroy_disposable_context( + fn destroy_disposable_context_with_port( &mut self, authority: &PresentationMutationAuthority, port: &mut P, ) -> Result<(), BrowserSessionError> { - let port_id = self.require_bound_lifecycle_port(port)?; let browser_session = self.id; let incarnation = self.incarnation; let record = self.context_for_authority_mut(authority)?; let request = DisposableContextDestroyRequest { - port_id, browser_session, incarnation, context: record.handle.clone(), @@ -594,61 +590,6 @@ impl BrowserSession { } } - /// Record browser transport loss independently from ownership-recovery state. - /// - /// Returns `true` only for the first observed transport loss. If ownership was already uncertain, - /// `RecoveryRequired` remains the lifecycle state while the transport-loss fact is retained. - pub fn record_transport_loss(&mut self) -> bool { - if self.transport_lost || self.state == BrowserSessionState::Ended { - return false; - } - self.transport_lost = true; - if self.state == BrowserSessionState::Active { - self.state = BrowserSessionState::TransportLost; - self.mark_active_contexts_uncertain(); - } - true - } - - /// End the Browser Session only after every owned context has proven destruction. - pub fn end(&mut self) -> Result<(), BrowserSessionError> { - self.require_active()?; - if self - .contexts - .values() - .any(|record| record.state != OwnedContextState::Destroyed) - { - return Err(BrowserSessionError::ActiveContextRemains); - } - self.state = BrowserSessionState::Ended; - Ok(()) - } - - fn bind_lifecycle_port( - &mut self, - port: &P, - ) -> Result { - let supplied = port.port_id(); - match self.lifecycle_port_id { - None => { - self.lifecycle_port_id = Some(supplied); - Ok(supplied) - } - Some(bound) if bound == supplied => Ok(bound), - Some(_) => Err(BrowserSessionError::LifecyclePortMismatch), - } - } - - fn require_bound_lifecycle_port( - &self, - port: &P, - ) -> Result { - match self.lifecycle_port_id { - Some(bound) if bound == port.port_id() => Ok(bound), - _ => Err(BrowserSessionError::LifecyclePortMismatch), - } - } - fn require_active(&self) -> Result<(), BrowserSessionError> { if self.state == BrowserSessionState::Active { Ok(()) @@ -706,6 +647,63 @@ impl BrowserSession { } } +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 + } + + /// Return the bound lifecycle port for read-only diagnostics and adapter-local planning. + #[must_use] + pub const fn lifecycle_port(&self) -> &P { + &self.port + } + + /// 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() + } +} + fn reserve_epoch(next_epoch: &mut u64) -> Result { let epoch = BrowserContextEpoch(*next_epoch); *next_epoch = next_epoch @@ -729,37 +727,40 @@ fn allocate_incarnation( #[allow(clippy::expect_used)] mod tests { use super::*; + use std::collections::VecDeque; #[derive(Debug)] struct TestPort { - port_id: DisposableContextPortId, - next_handle: DisposableContextHandle, + handles: VecDeque, create_error: Option, fail_destroy: bool, create_calls: usize, destroy_calls: usize, + create_sessions: Vec, create_incarnations: Vec, + destroy_sessions: Vec, destroy_incarnations: Vec, destroyed_isolations: Vec, } impl TestPort { fn new(context: u64, isolation: &str) -> Self { - Self::with_port_id(context, isolation, 1) + Self::with_handles(vec![DisposableContextHandle::new( + isolation_id(isolation), + context_id(context), + )]) } - fn with_port_id(context: u64, isolation: &str, port_id: u64) -> Self { + fn with_handles(handles: Vec) -> Self { Self { - port_id: DisposableContextPortId::new(port_id).expect("valid port id"), - next_handle: DisposableContextHandle::new( - isolation_id(isolation), - context_id(context), - ), + handles: handles.into(), create_error: None, fail_destroy: false, create_calls: 0, destroy_calls: 0, + create_sessions: Vec::new(), create_incarnations: Vec::new(), + destroy_sessions: Vec::new(), destroy_incarnations: Vec::new(), destroyed_isolations: Vec::new(), } @@ -767,20 +768,19 @@ mod tests { } impl DisposableContextPort for TestPort { - fn port_id(&self) -> DisposableContextPortId { - self.port_id - } - fn create_disposable_context( &mut self, request: &DisposableContextCreateRequest, ) -> Result { - assert_eq!(request.port_id(), self.port_id); self.create_calls += 1; + self.create_sessions.push(request.browser_session()); self.create_incarnations.push(request.incarnation()); 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")), } } @@ -788,8 +788,8 @@ mod tests { &mut self, request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { - assert_eq!(request.port_id(), self.port_id); self.destroy_calls += 1; + self.destroy_sessions.push(request.browser_session()); self.destroy_incarnations.push(request.incarnation()); self.destroyed_isolations .push(request.context().isolation.clone()); @@ -840,73 +840,72 @@ mod tests { let handle = DisposableContextHandle::new(valid.clone(), context_id(10)); assert_eq!(handle.isolation(), &valid); assert_eq!(handle.browsing_context(), context_id(10)); - assert_eq!(DisposableContextPortId::new(0), None); - assert_eq!( - DisposableContextPortId::new(17).expect("valid port id").value(), - 17 - ); } #[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()); + 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!( - session.presentation_authority(context_id(10)), + 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.lifecycle_port().create_sessions, + vec![session_id(1)] + ); + assert_eq!( + bound.lifecycle_port().create_incarnations, + vec![bound.browser_session().incarnation()] + ); 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 )] @@ -919,39 +918,43 @@ 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 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![ + DisposableContextHandle::new(isolation_id("isolation-30-a"), context_id(30)), + 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(), + duplicate_context.browser_session().recovery_evidence(), &[BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( duplicate_context_handle )] ); - let mut duplicate_isolation_session = session(31); - let mut first_isolation_port = TestPort::new(310, "isolation-31"); - duplicate_isolation_session - .create_disposable_context(&mut first_isolation_port) - .expect("first owned isolation"); let duplicate_isolation_handle = DisposableContextHandle::new(isolation_id("isolation-31"), context_id(311)); - let mut duplicate_isolation_port = TestPort::new(311, "isolation-31"); + let isolation_port = TestPort::with_handles(vec![ + DisposableContextHandle::new(isolation_id("isolation-31"), context_id(310)), + 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(), + duplicate_isolation.browser_session().recovery_evidence(), &[BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( duplicate_isolation_handle )] @@ -959,234 +962,237 @@ mod tests { } #[test] - fn lifecycle_port_binding_rejects_other_adapter_before_io() { - let mut session = session(32); - let mut first_port = TestPort::with_port_id(320, "isolation-320", 11); - let authority = session - .create_disposable_context(&mut first_port) - .expect("first lifecycle port is bound"); - - let mut other_port = TestPort::with_port_id(321, "isolation-321", 12); - assert_eq!( - session.create_disposable_context(&mut other_port), - Err(BrowserSessionError::LifecyclePortMismatch) - ); - assert_eq!(other_port.create_calls, 0); - assert_eq!( - session.destroy_disposable_context(&authority, &mut other_port), - Err(BrowserSessionError::LifecyclePortMismatch) - ); - assert_eq!(other_port.destroy_calls, 0); - - session - .destroy_disposable_context(&authority, &mut first_port) - .expect("bound lifecycle port remains authorized"); - assert_eq!(first_port.destroy_calls, 1); + 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.lifecycle_port().create_calls, 1); + assert_eq!(bound.lifecycle_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.lifecycle_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) + let mut bound = session(41).bind_lifecycle_port(TestPort::new(410, "isolation-410")); + let authority = bound + .create_disposable_context() .expect("owned context"); - exhausted_session.next_epoch = u64::MAX; + bound.session.next_epoch = u64::MAX; assert_eq!( - exhausted_session.advance_context_epoch(context_id(410)), + bound.advance_context_epoch(context_id(410)), Err(BrowserSessionError::EpochExhausted) ); - assert_eq!( - exhausted_session.presentation_authority(context_id(410)), - Ok(authority) - ); + 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) + 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.lifecycle_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 mut owner = session(6).bind_lifecycle_port(TestPort::new(60, "isolation-60")); let authority = owner - .create_disposable_context(&mut owner_port) + .create_disposable_context() .expect("owner context"); - let mut foreign = session(7); - let mut foreign_port = TestPort::new(60, "isolation-60"); + 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.lifecycle_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.lifecycle_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 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(&mut port_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 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(&mut port_b) + .create_disposable_context() .expect("B context"); - assert_ne!(session_a.incarnation(), session_b.incarnation()); + 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.lifecycle_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.lifecycle_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!( - session.create_disposable_context(&mut port), + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + assert!(!bound.record_transport_loss()); + assert_eq!( + 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) + let mut bound = session(10).bind_lifecycle_port(TestPort::new(100, "isolation-100")); + let authority = bound + .create_disposable_context() .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!(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!( - session.destroy_disposable_context(&authority, &mut port), + bound.destroy_disposable_context(&authority), Err(BrowserSessionError::SessionNotActive) ); - assert_eq!(port.destroy_calls, 0); + assert_eq!(bound.lifecycle_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) + 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!( - session.end(), - Err(BrowserSessionError::ActiveContextRemains) + bound.lifecycle_port().destroy_sessions, + vec![session_id(11)] ); - 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)); + assert_eq!( + bound.lifecycle_port().destroyed_isolations, + vec![isolation_id("isolation-110")] + ); + 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/destroy_failure_requires_recovery.rs b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs index 5833504a5..a93e692f9 100644 --- a/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs +++ b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs @@ -2,13 +2,12 @@ use originweave_browser_session::{ BrowserSession, BrowserSessionError, BrowserSessionRecoveryEvidence, BrowserSessionState, DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, - DisposableContextPortId, DisposableIsolationId, + DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct FailingDestroyPort { - port_id: DisposableContextPortId, next_handle: DisposableContextHandle, create_calls: usize, destroy_calls: usize, @@ -20,10 +19,7 @@ impl FailingDestroyPort { .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")?; - let port_id = DisposableContextPortId::new(context) - .ok_or("static fixture lifecycle port id must be non-zero")?; Ok(Self { - port_id, next_handle: DisposableContextHandle::new(isolation, browsing_context), create_calls: 0, destroy_calls: 0, @@ -32,24 +28,18 @@ impl FailingDestroyPort { } impl DisposableContextPort for FailingDestroyPort { - fn port_id(&self) -> DisposableContextPortId { - self.port_id - } - fn create_disposable_context( &mut self, - request: &DisposableContextCreateRequest, + _request: &DisposableContextCreateRequest, ) -> Result { - assert_eq!(request.port_id(), self.port_id); self.create_calls += 1; Ok(self.next_handle.clone()) } fn destroy_disposable_context( &mut self, - request: &DisposableContextDestroyRequest, + _request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { - assert_eq!(request.port_id(), self.port_id); self.destroy_calls += 1; Err(DisposableContextDestroyError::DestroyFailed) } @@ -65,46 +55,51 @@ 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 failing_port = FailingDestroyPort::new(5010, "user-context-501")?; + 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!(bound.lifecycle_port().destroy_calls, 1); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); assert_eq!( - session.recovery_evidence(), + bound.browser_session().recovery_evidence(), &[BrowserSessionRecoveryEvidence::UnprovenDestruction( expected_handle )] ); - assert!(!session.transport_is_lost()); + assert!(!bound.browser_session().transport_is_lost()); - assert!(session.record_transport_loss()); - assert!(session.transport_is_lost()); - assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); - assert!(!session.record_transport_loss()); - - let mut later_port = FailingDestroyPort::new(5011, "user-context-501-later")?; + assert!(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!(bound.lifecycle_port().create_calls, 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 index b2b23f541..424e75ab2 100644 --- a/crates/originweave-browser-session/tests/lifecycle_port_authority.rs +++ b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs @@ -1,21 +1,19 @@ use originweave_browser_session::{ - BrowserSession, BrowserSessionError, DisposableContextCreateError, - DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, - DisposableContextHandle, DisposableContextPort, DisposableContextPortId, DisposableIsolationId, + BrowserSession, DisposableContextCreateError, DisposableContextCreateRequest, + DisposableContextDestroyError, DisposableContextDestroyRequest, DisposableContextHandle, + DisposableContextPort, DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct RecordingPort { - port_id: DisposableContextPortId, create_calls: usize, destroy_calls: usize, } impl RecordingPort { - fn new(port_id: u64) -> Self { + fn new() -> Self { Self { - port_id: DisposableContextPortId::new(port_id).expect("valid port id"), create_calls: 0, destroy_calls: 0, } @@ -23,15 +21,10 @@ impl RecordingPort { } impl DisposableContextPort for RecordingPort { - fn port_id(&self) -> DisposableContextPortId { - self.port_id - } - fn create_disposable_context( &mut self, request: &DisposableContextCreateRequest, ) -> Result { - assert_eq!(request.port_id(), self.port_id); assert_eq!(request.browser_session(), BrowserSessionId::new(7).unwrap()); self.create_calls += 1; Ok(DisposableContextHandle::new( @@ -44,7 +37,6 @@ impl DisposableContextPort for RecordingPort { &mut self, request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { - assert_eq!(request.port_id(), self.port_id); assert_eq!(request.browser_session(), BrowserSessionId::new(7).unwrap()); assert_eq!( request.context().browsing_context(), @@ -56,32 +48,21 @@ impl DisposableContextPort for RecordingPort { } #[test] -fn aggregate_issued_request_binds_lifecycle_io_to_one_port() { - let mut session = BrowserSession::start(BrowserSessionId::new(7).expect("valid session id")) +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 mut bound_port = RecordingPort::new(101); - let authority = session - .create_disposable_context(&mut bound_port) - .expect("Browser Session-issued create request"); - assert_eq!(bound_port.create_calls, 1); + let unbound_other_port = RecordingPort::new(); + let mut bound = session.bind_lifecycle_port(RecordingPort::new()); - let mut other_port = RecordingPort::new(102); - assert_eq!( - session.create_disposable_context(&mut other_port), - Err(BrowserSessionError::LifecyclePortMismatch) - ); - assert_eq!(other_port.create_calls, 0, "wrong port reached create I/O"); - assert_eq!( - session.destroy_disposable_context(&authority, &mut other_port), - Err(BrowserSessionError::LifecyclePortMismatch) - ); - assert_eq!( - other_port.destroy_calls, 0, - "wrong port reached destroy I/O" - ); + let authority = bound + .create_disposable_context() + .expect("Browser Session-issued create request"); + assert_eq!(bound.lifecycle_port().create_calls, 1); + assert_eq!(unbound_other_port.create_calls, 0); - session - .destroy_disposable_context(&authority, &mut bound_port) + bound + .destroy_disposable_context(&authority) .expect("Browser Session-issued destroy request"); - assert_eq!(bound_port.destroy_calls, 1); + assert_eq!(bound.lifecycle_port().destroy_calls, 1); + assert_eq!(unbound_other_port.destroy_calls, 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 index 9a5baafa1..63532a37f 100644 --- a/crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs +++ b/crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs @@ -3,7 +3,7 @@ use std::cell::Cell; use originweave_browser_session::{ BrowserSession, DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, DisposableContextHandle, - DisposableContextPort, DisposableContextPortId, DisposableIsolationId, + DisposableContextPort, DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; @@ -20,15 +20,14 @@ impl SideEffectingIdentityPort { create_calls: 0, } } -} -impl DisposableContextPort for SideEffectingIdentityPort { - fn port_id(&self) -> DisposableContextPortId { + fn identity_probe(&self) { self.identity_callbacks .set(self.identity_callbacks.get().saturating_add(1)); - DisposableContextPortId::new(401).expect("valid port id") } +} +impl DisposableContextPort for SideEffectingIdentityPort { fn create_disposable_context( &mut self, _request: &DisposableContextCreateRequest, @@ -49,19 +48,24 @@ impl DisposableContextPort for SideEffectingIdentityPort { } #[test] -fn lifecycle_authority_does_not_depend_on_side_effecting_identity_preflight() { - let mut session = BrowserSession::start(BrowserSessionId::new(401).expect("valid session id")) +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 mut port = SideEffectingIdentityPort::new(); - - session - .create_disposable_context(&mut port) - .expect("authorized create"); + let port = SideEffectingIdentityPort::new(); + let mut bound = session.bind_lifecycle_port(port); assert_eq!( - port.identity_callbacks.get(), + bound.lifecycle_port().identity_callbacks.get(), 0, - "Browser Session invoked an arbitrary adapter callback before lifecycle authority was established" + "binding invoked adapter code before aggregate-issued lifecycle authority existed" ); - assert_eq!(port.create_calls, 1); + bound + .create_disposable_context() + .expect("authorized create"); + assert_eq!(bound.lifecycle_port().identity_callbacks.get(), 0); + assert_eq!(bound.lifecycle_port().create_calls, 1); + + // Prove the fixture would detect an identity callback if production code invoked one. + bound.lifecycle_port().identity_probe(); + assert_eq!(bound.lifecycle_port().identity_callbacks.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 index bf437dc20..ae16efd9b 100644 --- a/crates/originweave-browser-session/tests/lifecycle_port_same_id_spoof.rs +++ b/crates/originweave-browser-session/tests/lifecycle_port_same_id_spoof.rs @@ -1,13 +1,12 @@ use originweave_browser_session::{ - BrowserSession, BrowserSessionError, DisposableContextCreateError, - DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, - DisposableContextHandle, DisposableContextPort, DisposableContextPortId, DisposableIsolationId, + BrowserSession, DisposableContextCreateError, DisposableContextCreateRequest, + DisposableContextDestroyError, DisposableContextDestroyRequest, DisposableContextHandle, + DisposableContextPort, DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct RecordingPort { - port_id: DisposableContextPortId, context: BrowsingContextId, isolation: &'static str, create_calls: usize, @@ -15,9 +14,8 @@ struct RecordingPort { } impl RecordingPort { - fn new(port_id: u64, context: u64, isolation: &'static str) -> Self { + fn new(context: u64, isolation: &'static str) -> Self { Self { - port_id: DisposableContextPortId::new(port_id).expect("valid port id"), context: BrowsingContextId::new(context).expect("valid browsing context"), isolation, create_calls: 0, @@ -27,15 +25,10 @@ impl RecordingPort { } impl DisposableContextPort for RecordingPort { - fn port_id(&self) -> DisposableContextPortId { - self.port_id - } - fn create_disposable_context( &mut self, - request: &DisposableContextCreateRequest, + _request: &DisposableContextCreateRequest, ) -> Result { - assert_eq!(request.port_id(), self.port_id); self.create_calls += 1; Ok(DisposableContextHandle::new( DisposableIsolationId::parse(self.isolation).expect("valid isolation id"), @@ -45,50 +38,42 @@ impl DisposableContextPort for RecordingPort { fn destroy_disposable_context( &mut self, - request: &DisposableContextDestroyRequest, + _request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { - assert_eq!(request.port_id(), self.port_id); self.destroy_calls += 1; Ok(()) } } #[test] -fn distinct_port_with_same_claimed_id_cannot_create() { - let mut session = BrowserSession::start(BrowserSessionId::new(17).expect("valid session id")) +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 mut approved_port = RecordingPort::new(101, 41, "approved-isolation"); - session - .create_disposable_context(&mut approved_port) - .expect("bind approved port"); + let approved_port = RecordingPort::new(41, "approved-isolation"); + let spoofing_port = RecordingPort::new(42, "spoofed-isolation"); + let mut bound = session.bind_lifecycle_port(approved_port); - let mut spoofing_port = RecordingPort::new(101, 42, "spoofed-isolation"); - assert_eq!( - session.create_disposable_context(&mut spoofing_port), - Err(BrowserSessionError::LifecyclePortMismatch) - ); - assert_eq!( - spoofing_port.create_calls, 0, - "distinct adapter with the same self-reported id reached create I/O" - ); + bound + .create_disposable_context() + .expect("bound adapter creates context"); + assert_eq!(bound.lifecycle_port().create_calls, 1); + assert_eq!(spoofing_port.create_calls, 0); } #[test] -fn distinct_port_with_same_claimed_id_cannot_destroy() { - let mut session = BrowserSession::start(BrowserSessionId::new(18).expect("valid session id")) +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 mut approved_port = RecordingPort::new(101, 51, "approved-isolation"); - let authority = session - .create_disposable_context(&mut approved_port) - .expect("bind approved port"); + let approved_port = RecordingPort::new(51, "approved-isolation"); + let spoofing_port = RecordingPort::new(52, "spoofed-isolation"); + let mut bound = session.bind_lifecycle_port(approved_port); + let authority = bound + .create_disposable_context() + .expect("bound adapter creates context"); - let mut spoofing_port = RecordingPort::new(101, 52, "spoofed-isolation"); - assert_eq!( - session.destroy_disposable_context(&authority, &mut spoofing_port), - Err(BrowserSessionError::LifecyclePortMismatch) - ); - assert_eq!( - spoofing_port.destroy_calls, 0, - "distinct adapter with the same self-reported id reached destroy I/O" - ); + bound + .destroy_disposable_context(&authority) + .expect("bound adapter destroys context"); + assert_eq!(bound.lifecycle_port().destroy_calls, 1); + assert_eq!(spoofing_port.destroy_calls, 0); } diff --git a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs index a3e49f1b6..9232da729 100644 --- a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs +++ b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs @@ -1,13 +1,12 @@ use originweave_browser_session::{ BrowserSession, BrowserSessionError, BrowserSessionIncarnation, DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, - DisposableContextHandle, DisposableContextPort, DisposableContextPortId, DisposableIsolationId, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct ReusingPort { - port_id: DisposableContextPortId, handle: DisposableContextHandle, create_incarnations: Vec, destroy_incarnations: Vec, @@ -19,10 +18,7 @@ impl ReusingPort { .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")?; - let port_id = DisposableContextPortId::new(context) - .ok_or("static fixture lifecycle port id must be non-zero")?; Ok(Self { - port_id, handle: DisposableContextHandle::new(isolation, browsing_context), create_incarnations: Vec::new(), destroy_incarnations: Vec::new(), @@ -31,15 +27,10 @@ impl ReusingPort { } impl DisposableContextPort for ReusingPort { - fn port_id(&self) -> DisposableContextPortId { - self.port_id - } - fn create_disposable_context( &mut self, request: &DisposableContextCreateRequest, ) -> Result { - assert_eq!(request.port_id(), self.port_id); self.create_incarnations.push(request.incarnation()); Ok(self.handle.clone()) } @@ -48,7 +39,6 @@ impl DisposableContextPort for ReusingPort { &mut self, request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { - assert_eq!(request.port_id(), self.port_id); self.destroy_incarnations.push(request.incarnation()); Ok(()) } @@ -60,38 +50,56 @@ 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 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", + )?); + 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 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", + )?); + 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!( + bound_a.lifecycle_port().create_incarnations, + vec![bound_a.browser_session().incarnation()] + ); assert_eq!( - session_b.destroy_disposable_context(&authority_a, &mut port_b), + bound_b.lifecycle_port().create_incarnations, + vec![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!(bound_b.lifecycle_port().destroy_incarnations.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!( + bound_b.lifecycle_port().destroy_incarnations, + vec![bound_b.browser_session().incarnation()] + ); Ok(()) } diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index 345071fd6..b30fb85e3 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -7,119 +7,110 @@ OriginWeave's WebDriver BiDi presentation adapter requires opaque ownership witnesses before viewport/device-pixel-ratio, timezone, or screen-area mutation can be planned. A caller that merely knows a browser-session or browsing-context identifier therefore cannot overwrite another owner's presentation state and later clear it to an implementation default. -The Browser Session boundary must establish why a context is exclusively OriginWeave-owned before presentation authority exists. External browser-session, user-context/isolation, and browsing-context identifiers are protocol addressability. They may be reused after a prior lifecycle ends, so `(BrowserSessionId, DisposableIsolationId, BrowsingContextId, local epoch)` is not by itself a durable capability generation. +Browser-session, user-context/isolation, browsing-context, and adapter-selected identifiers are protocol or implementation addressability. They are not Browser Session authority. A previous repair introduced opaque `DisposableContextCreateRequest` and `DisposableContextDestroyRequest`, but also asked each adapter to self-report a public numeric port id. That left two defects: a second adapter could select the same id, and Browser Session had to invoke arbitrary adapter code to read that id before lifecycle authority existed. Rust `&self` does not make such a callback pure. Lifecycle failures also need lossless evidence. A BiDi adapter can successfully create a user context before later browsing-context creation or verification becomes uncertain. Duplicate adapter output can expose an offending handle that must not be silently discarded or automatically destroyed. Destruction can fail without proving that the exact isolation boundary is gone. These outcomes require recovery quarantine while retaining every exact browser-issued identity that is already known. -Transport liveness is independent from ownership certainty. A session already in `RecoveryRequired` can subsequently lose its transport; that new fact must be recorded without erasing the recovery evidence. Conversely, merely entering recovery does not prove the transport is dead. +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 recovery evidence. Conversely, merely entering recovery does not prove the transport is dead. The 9 September 2026 WebDriver BiDi Working Draft defines user-context identifiers and the `browser.createUserContext`, `browsingContext.create`, and `browser.removeUserContext` lifecycle. Those commands remain adapter capabilities rather than OriginWeave policy authority, and command ACK alone is not destruction proof. ## Decision drivers -- Raw WebDriver/BiDi identifiers are addressability, not mutation or cleanup authority. +- Raw WebDriver/BiDi identifiers and adapter-chosen ids are addressability, not mutation or cleanup authority. +- No arbitrary adapter callback may be required to establish lifecycle-port ownership. +- A caller must not be able to substitute a second adapter instance after Browser Session lifecycle binding. +- Create/destroy requests must remain non-caller-constructible and usable only through the bound aggregate composition. - 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. +- Ownership recovery 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. Allocation fails closed before `u64` wrap. +2. Presentation authority is intentionally non-serializable. Within one process, `BrowserSessionIncarnation` prevents sequential ABA when a later aggregate reuses the same external session, isolation, context, and local epoch values. +3. Browser Session uses a **linear lifecycle-port binding**. `BrowserSession::bind_lifecycle_port` consumes both the aggregate and one concrete adapter value into `BoundBrowserSession

`. Binding performs no adapter callback. +4. `BoundBrowserSession

` does not expose mutable port access and its public create/destroy methods accept no alternate port argument. The exact adapter instance is therefore structural composition rather than a caller-selected or self-asserted scalar identity. +5. `DisposableContextPort` has no `port_id()` preflight method. `DisposableContextPortId` is removed. A second adapter cannot claim equality by choosing the same scalar. +6. `DisposableContextCreateRequest` and `DisposableContextDestroyRequest` remain opaque, have no public constructor, and are created only inside the bound Browser Session path after aggregate state or exact presentation authority has been validated. They carry Browser Session addressability and incarnation; the destroy request additionally carries the exact stored handle. +7. The adapter is part of the reviewed lifecycle anti-corruption boundary. A malicious adapter implementation that internally delegates an authorized request is outside what a Rust trait can prevent without inverting the dependency boundary; protocol-specific pending/accepted/quarantine ownership remains the responsibility of the separately reviewed BiDi ACL adapter in ADR 0115. +8. A context enters the owned set only after the bound port returns a `DisposableContextHandle`. Raw `BrowsingContextId` input never creates ownership. +9. `PresentationMutationAuthority` binds browser session, Browser Session incarnation, disposable isolation, browsing context, and context epoch. All fields must match current aggregate ownership before destruction I/O. +10. `DisposableContextCreateError::CreateFailedClean` is valid only when no remote boundary exists. `CreateFailedUncertain(Option)` enters `RecoveryRequired`; a known browser-issued isolation identity is preserved exactly. +11. Duplicate browsing-context or isolation output enters `RecoveryRequired` and stores the complete offending `DisposableContextHandle` as recovery evidence. OriginWeave does not auto-destroy ambiguous output. +12. `BrowserSessionRecoveryEvidence` records only reconciliation evidence: `PartialCreationIsolation`, `DuplicateAdapterHandle`, and `UnprovenDestruction`. It grants no browser command authority. +13. Destruction validates exact authority before I/O and passes the current incarnation and stored handle in `DisposableContextDestroyRequest`. `DisposableContextDestroyError` moves the record and aggregate into recovery and retains the exact failed handle. +14. Transport liveness is stored separately from ownership state. The first `record_transport_loss()` records the fact even after `RecoveryRequired`; repeated reports are idempotent. +15. `RecoveryRequired`, `TransportLost`, and `Ended` reject active-only creation, authority issuance/advance, destruction, and normal end. Reconciliation is a later, separately authorized design. +16. Context epochs remain monotonic authority identities within one aggregate. They invalidate older authority after navigation or another lifecycle boundary but are not a substitute for session incarnation. ## Alternatives considered -### Treat any known context as owned +### Adapter-supplied numeric port id -Rejected. It restores the authority-confusion defect and allows one task to clear another task's state. +Rejected. A public scalar is caller-selectable and replayable by a distinct adapter. Making the callback side-effect-free by documentation is also insufficient because Rust `&self` permits interior mutation and delegated effects. -### Depend only on browser-issued isolation identity +### Pointer-address identity -Rejected. The WebDriver BiDi user-context identifier is suitable lifecycle addressability, but this ADR does not assume a historical non-reuse guarantee after removal. A later aggregate therefore needs a separate OriginWeave lifecycle generation. +Rejected. Object addresses are implementation details, can change when values move, and can be reused after destruction. Pointer equality would replace one ABA surface with another. -### Add an aggregate-only random or monotonic nonce +### Session-owned wrapper with the concrete port -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. +Selected. Ownership is represented by Rust move semantics and private fields. No identity probe is required, the caller cannot swap a second adapter into public lifecycle methods, and opaque requests remain confined to the bound call path. ### Persist authority generations globally -Deferred and unnecessary for the current in-process authority model. Presentation authority is not durable across process restart; recovery across restart belongs to evidence/reconciliation design, not silent authority resurrection. - -### Treat every uncertain lifecycle failure as transport loss - -Rejected. Ownership uncertainty and transport liveness answer different operational questions. Collapsing them loses information needed for safe reconciliation. +Deferred. Presentation authority is not durable across process restart; restart reconciliation belongs to evidence and browser observation, not silent authority resurrection. ### Automatically clean duplicate or partial state -Rejected. When ownership is ambiguous, cleanup itself can become a cross-owner destructive action. Exact recovery evidence is retained while normal authority stays blocked. - -### Snapshot and restore every predecessor presentation override - -Deferred. OriginWeave does not yet have a complete queryable predecessor-state contract for every governed presentation surface. Disposable ownership remains the stronger first implementation. +Rejected. When ownership is ambiguous, cleanup itself can become a cross-owner destructive action. ## Consequences -The Browser Session aggregate now carries an explicit lifecycle generation through the anti-corruption boundary instead of treating protocol identifiers as durable capabilities. A retained token from aggregate A cannot validate against aggregate B solely because the browser or adapter later reused the same external identifiers and local epoch. +Browser Session no longer asks an adapter to prove its own identity before authority. The aggregate and exact lifecycle port become one composed runtime object, while adapter-specific remote identifiers remain outside the Browser Session domain model. -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. +The API change is intentionally breaking on the active stack: consumers must call `BrowserSession::bind_lifecycle_port(port)` and then perform lifecycle operations through `BoundBrowserSession`. ADR 0115/#316 must be non-force restacked and adapt its WebDriver BiDi lifecycle adapter to this composition before adoption. -Transport failure can now be observed after ownership has already become uncertain without replacing or erasing that uncertainty. This supports later recovery planning that distinguishes “ownership uncertain but transport still live” from “ownership uncertain and transport lost.” - -The selected process-local incarnation has a deliberate scope. It prevents ABA only for outstanding in-memory authority within the running process. Durable restart reconciliation must use separately persisted evidence and browser observation; this ADR does not serialize or resurrect authority across restart. +This binding closes ordinary caller substitution and self-selected-id replay. It does not claim that an adversarial implementation of the trusted `DisposableContextPort` trait cannot internally forward calls; such an implementation already executes inside the reviewed adapter TCB. The BiDi ACL still must prove pending → accepted/quarantined remote ownership, complete recovery tuples, and live-target validation independently. ## Security and governance impact -No page-controlled value, raw browser-session id, raw browsing-context id, user-context string, provider/model decision, or LLM output can mint presentation authority. The adapter receives domain-issued incarnation information only as a lifecycle-scoping input and cannot manufacture Browser Session policy authority. +No page-controlled value, raw browser-session id, raw browsing-context id, user-context string, adapter-selected scalar, provider/model decision, or LLM output can mint lifecycle requests or presentation authority. Browser Session performs no arbitrary adapter callback while establishing the lifecycle-port binding. -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. +Unknown or duplicate remote state is quarantined rather than destroyed speculatively. This does not replace Chromium sandboxing, EgressWeave, Keyverse, Wardnet, or central workflow security. ## Tests and exact evidence -The test suite covers raw-context rejection, bounded isolation identity parsing, typed clean/uncertain creation, retained partial identity, duplicate-handle evidence, epoch exhaustion, stale epoch rejection, foreign-session/isolation rejection, destruction failure, transport loss, normal end, and incarnation-allocation exhaustion. - -A dedicated hostile test, `stale_authority_cannot_cross_sequential_session_incarnations`, creates aggregate A, destroys and ends it, creates aggregate B with the same external session/user-context/browsing-context values and local epoch, and requires A's retained authority to fail before B adapter I/O while B's current authority succeeds. The port records incarnation values so the test also proves that the lifecycle mapping receives the new generation. +The suite retains recovery, sequential ABA, epoch, foreign-authority, destruction, transport-loss, and normal-end coverage. `lifecycle_binding_invokes_no_adapter_callback_before_authorized_create` proves that binding performs no adapter callback before the aggregate-issued create request. `distinct_adapter_cannot_be_substituted_for_create_after_binding` and `distinct_adapter_cannot_be_substituted_for_destroy_after_binding`, together with repository source contracts, require lifecycle methods to use only the consumed port and prohibit reintroduction of public `DisposableContextPortId`/`port_id()` or arbitrary-port Browser Session lifecycle methods. -`destroy_failure_requires_recovery_before_any_new_authority` requires an unproven destruction to retain the exact failed handle, enter `RecoveryRequired`, then record a later real transport loss without erasing ownership evidence; repeated loss reports are idempotent. +The prior hostile RED was captured on exact `d43a4d86c8487ebdb9db9f1c4650fb7ee6225afc` in CI `34524654914`: the pre-authority callback fixture observed one identity callback where zero was required. This decision replaces that self-asserted identity design rather than suppressing the test. -The RED for the sequential ABA defect was captured on exact `ec145963ad8fe19c9416f2b3856b94660082dbf7` in CI `34469580144`: repository contracts and formatting passed, and Rust `Run tests` failed at the new hostile test before Clippy/rustdoc. The production fix and subsequent documentation/test updates must earn a new exact-head GREEN; predecessor evidence does not transfer. - -Repository contracts, canonical formatting, locked Rust tests, strict Clippy, rustdoc/API docs, exact function/line/region/branch coverage, independent review, and applicable central checks remain required before ordinary adoption into #313. +Repository contracts, canonical formatting, locked Rust tests, strict Clippy, rustdoc/API docs, exact function/line/region/branch coverage, current review findings, and applicable central checks remain required on the successor exact head. Predecessor GREEN never transfers. ## 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 real WebDriver BiDi `browser.createUserContext`/`browsingContext.create`/`browser.removeUserContext` integration, pending/accepted/quarantined remote binding, browser-observed destruction, Browser Session→BiDi private-witness conversion, pinned Chromium presentation post-conditions, crash/restart cleanup, #299 3/3 Agent Task replay, or protected-main release/SBOM/provenance/reproducibility/rollback. ## Migration and rollback -The change remains additive on the active stacked branch. Consumers must adopt the new `BrowserSession::start` result and incarnation-aware `DisposableContextPort` contract. Until a reviewed adapter bridge exists, presentation mutation remains fail closed behind private ownership witnesses. Rollback removes this active-PR bounded-context slice without weakening protected Chromium or central security policy. +Consumers on the active stack replace `session.create_disposable_context(&mut port)` / `session.destroy_disposable_context(..., &mut port)` with one `let mut bound = session.bind_lifecycle_port(port)` followed by bound lifecycle calls. The wrapper exposes read-only access to the aggregate and adapter for policy validation and diagnostics but does not return mutable adapter access or an unbound session. + +Rollback returns to the predecessor active-PR API only if the lifecycle-port authority finding is rejected with stronger evidence; it must not restore self-reported scalar identity as a security 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 adapt the WebDriver BiDi lifecycle ACL to `BoundBrowserSession` without exposing a second lifecycle side door. +- Implement protocol-specific pending → accepted/quarantined creation and complete recovery tuples in the BiDi ACL owner. +- Define separately authorized reconciliation for `BrowserSessionRecoveryEvidence`, including browser/process restart. - Replay #299 historical pinned Chromium evidence after the canonical sandbox/runtime repair, then run a separate current-Stable qualification. -- Revisit predecessor capture/restore only if reusable attached contexts become a buyer requirement. ## Supersession / reversal conditions -Supersede this ADR if the browser platform provides a complete, queryable, generation-safe ownership primitive with exact destruction evidence, or if OriginWeave adopts another isolation primitive with equivalent guarantees. Do not regress to raw context identity as authority. +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/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index 66336ac88..1f77f4afa 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -4,48 +4,52 @@ - 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. A retained authority must not regain meaning if a later aggregate reuses the same remote identifiers and local epoch, and a caller must not be able to redirect a valid lifecycle request into a second adapter instance by choosing or replaying an adapter id. -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; binding invokes no adapter callback +→ aggregate validates Active + reserves monotonic context epoch +→ aggregate privately constructs DisposableContextCreateRequest(session, incarnation) +→ exact owned port creates task-owned isolation boundary + browsing context +→ aggregate records 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 +→ exact authority validation before destroy I/O +→ aggregate privately constructs DisposableContextDestroyRequest(session, incarnation, stored handle) +→ exact owned port proves destruction → context Destroyed -→ normal BrowserSession::end admitted +→ normal BrowserSession end admitted ``` -`BrowserSessionIncarnation` is process-local and monotonic. Presentation authority is not persisted across process restart, so restart invalidates outstanding authority rather than requiring a durable counter. Within one running process, the incarnation is checked by the aggregate and passed through the lifecycle port; an adapter that ignores it does not satisfy the ACL contract. +`BoundBrowserSession` is the lifecycle composition boundary. Public create/destroy methods accept no arbitrary port argument, no mutable port accessor is exposed, and `DisposableContextPort` has no identity-preflight callback. The previous public `DisposableContextPortId`/`port_id()` design was removed because the value was self-asserted and the callback itself could have side effects before authority. + +`DisposableContextCreateRequest` and `DisposableContextDestroyRequest` have private construction paths. Their getters expose only addressability needed by a reviewed adapter. A caller that knows those values cannot reconstruct the request. ## Lossless recovery evidence `DisposableContextCreateError::CreateFailedClean` is valid only when no disposable browser state exists. `CreateFailedUncertain(Some(isolation))` retains the exact known user-context/isolation identity as `BrowserSessionRecoveryEvidence::PartialCreationIsolation`; `None` remains representable when no identity was obtained. Both uncertain cases enter `RecoveryRequired` and mint no authority. -Duplicate browsing-context or isolation output stores the complete offending `DisposableContextHandle` as `DuplicateAdapterHandle` before recovery quarantine. OriginWeave deliberately does not auto-destroy duplicate output because ownership may be foreign. Failed or unproven destruction records `UnprovenDestruction` with the exact owned handle. Recovery evidence authorizes no browser command; it exists only for a later reviewed reconciliation path. +Duplicate browsing-context or isolation output stores the complete offending `DisposableContextHandle` as `DuplicateAdapterHandle` before recovery quarantine. Failed or unproven destruction records `UnprovenDestruction` with the exact owned handle. Recovery evidence authorizes no browser command. + +Protocol-specific complete BiDi tuples, pending → accepted/quarantined mapping, and remote target liveness remain #314/#316 responsibilities; they are not copied into Browser Session domain truth. ## Orthogonal transport liveness Transport liveness is tracked independently from ownership recovery. If transport loss occurs after `RecoveryRequired`, the aggregate keeps `RecoveryRequired`, preserves all recovery evidence, and separately records `transport_lost = true`. The first loss report is observable; repeated reports are idempotent. If loss occurs while `Active`, the lifecycle state becomes `TransportLost` and active context records become uncertain. -This avoids conflating “ownership uncertain while transport may still be usable for separately authorized reconciliation” with “ownership uncertain and the transport is gone.” - ## Sequential ABA safety -The sequential ABA hostile case is explicit: aggregate A creates `(S,U,C,epoch=1)`, proves destruction, and ends. Aggregate B later starts with the same external `S`; the adapter may return the same `U/C`, and B also begins at local epoch 1. A's retained authority must still fail before any B adapter I/O. B receives a different `BrowserSessionIncarnation`, and only B's newly minted authority is accepted. +Aggregate A may create `(S,U,C,epoch=1)`, prove destruction, and end. Aggregate B can later start with the same external `S`; the browser may return the same `U/C`, and B also begins at local epoch 1. A's retained authority still fails before B adapter I/O because B has a different `BrowserSessionIncarnation`. -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. +The bound port receives the incarnation inside aggregate-issued create/destroy requests. The caller cannot replace the bound adapter after creation to reinterpret that current incarnation against a different adapter-local map. ## Standards trace @@ -53,42 +57,32 @@ The design dossier references the 9 September 2026 WebDriver BiDi Working Draft. OriginWeave does not turn that protocol identifier into policy authority or assume historical non-reuse after removal. `DisposableIsolationId` remains lifecycle addressability. A successful command ACK is insufficient evidence that the disposable boundary is actually gone. -The active `originweave-bidi` adapter remains separately runtime-qualified against its documented 3 September 2026 revision. Tracking the 9 September publication here does not silently repin that runtime contract. - ## Source and executable evidence | Invariant | Source / test | |---|---| | independent Browser Session bounded context | `crates/originweave-browser-session/`; `tests/test_browser_session_lifecycle_contract.py` | -| raw context cannot mint authority | `BrowserSession::presentation_authority`; `disposable_creation_is_the_only_raw_context_entry_to_authority` | +| lifecycle port ownership is structural | `BoundBrowserSession`; `bound_port_is_structural_and_not_swappable` | +| 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()`; repository contract | +| create/destroy requests are aggregate-issued | `DisposableContextCreateRequest`; `DisposableContextDestroyRequest`; `aggregate_issued_request_is_reachable_only_through_owned_port_binding` | +| raw context cannot mint presentation authority | `BrowserSession::presentation_authority`; `bound_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` | +| duplicate adapter handle retained without speculative cleanup | `create_disposable_context_with_port`; `duplicate_adapter_output_preserves_offending_handle` | +| unproven destruction retains exact handle | `destroy_disposable_context_with_port`; `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. +The pre-authority adapter-callback RED was captured on exact `d43a4d86c8487ebdb9db9f1c4650fb7ee6225afc` in CI `34524654914`: the hostile fixture observed one identity callback where zero was required. The same predecessor also retained the self-selected scalar identity defect. The bound-session successor must earn fresh exact-head formatting, tests, Clippy, rustdoc, and function/line/region/branch 100% evidence; historical GREEN does not transfer. 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 `browser.createUserContext`/`browsingContext.create` integration, observed `browser.removeUserContext` post-condition, protocol-specific pending/accepted/quarantined binding, separately authorized recovery reconciliation, Browser Session authority conversion into BiDi presentation private witnesses, pinned Chromium post-condition observation, crash/process-restart reconciliation, #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..54bd12ead 100644 --- a/docs/uml/browser-session-lifecycle-authority.md +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -7,41 +7,50 @@ sequenceDiagram autonumber participant C as Application service participant S as BrowserSession aggregate + participant BS as BoundBrowserSession participant P as DisposableContextPort participant B as Browser adapter (planned) C->>S: start(valid BrowserSessionId) S->>S: allocate BrowserSessionIncarnation - C->>S: create_disposable_context(port) - S->>S: reserve monotonic context epoch - S->>P: create_disposable_context(session_id, incarnation) + C->>S: bind_lifecycle_port(port by value) + S-->>C: BoundBrowserSession owns aggregate + exact port + Note over S,P: binding invokes no adapter callback + + C->>BS: create_disposable_context() + BS->>S: require Active + reserve monotonic epoch + S->>S: mint DisposableContextCreateRequest + S->>P: create_disposable_context(request) P->>B: create fresh isolation boundary + browsing context B-->>P: unique isolation id + BrowsingContextId or typed create error P-->>S: DisposableContextHandle S->>S: register exact handle + Active epoch S-->>C: PresentationMutationAuthority(session, incarnation, isolation, context, epoch) - Note over C,S: Raw BrowserSessionId/BrowsingContextId/user-context id cannot mint authority. + Note over C,S: Raw ids and adapter-selected scalar identities cannot mint lifecycle or presentation authority. - C->>S: advance_context_epoch(context_id) - S->>S: replace epoch; old authority becomes stale + 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 + C->>BS: end() + BS->>S: require every owned context Destroyed S-->>C: Ended ``` -`BrowserSessionIncarnation` separates two sequential aggregate lifecycles even when the browser or adapter later reuses the same external session, user-context/isolation, browsing-context, and local epoch values. The incarnation is checked by authority validation and reaches the lifecycle port. It is therefore not merely an aggregate-local nonce that the adapter can ignore. +`BoundBrowserSession` is a linear lifecycle-port binding: it consumes one concrete port and exposes no public lifecycle method that accepts a replacement port. `DisposableContextPort` has no identity callback, so Browser Session does not execute arbitrary adapter code merely to establish adapter ownership. `DisposableContextCreateRequest` and `DisposableContextDestroyRequest` are non-caller-constructible capabilities created inside the bound path. -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. +`BrowserSessionIncarnation` separates sequential aggregate lifecycles even when the browser 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 inside the opaque request. + +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. Protocol-specific pending/accepted/quarantined remote tuples remain in the BiDi ACL boundary rather than this domain model. ## Recovery and transport state @@ -79,23 +88,24 @@ stateDiagram-v2 ```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) + 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) + PA-->>A: U, C + A->>PA: destroy(request 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 + B->>B: start(S) => incarnation B; bind PB + B->>PB: create(request S, incarnation B) + PB-->>B: same U, same C Note over A,B: both local context epochs may equal 1 B->>B: validate retained authority A - B-->>A: AuthorityMismatch before adapter I/O - B->>P: destroy with authority B + incarnation B + B-->>A: AuthorityMismatch before PB destroy I/O + B->>PB: destroy with authority B + incarnation B ``` `RecoveryRequired` and `TransportLost` remain terminal for normal authority in this slice. A later reconciliation design may inspect `BrowserSessionRecoveryEvidence`, but it must not reconstruct cleanup authority from raw identifiers or treat command ACK as proof of destruction. diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py index 6e4487988..60c099ece 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -32,27 +32,34 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: source = (CRATE / "src/lib.rs").read_text(encoding="utf-8") self.assertIn("pub struct BrowserSession", source) + self.assertIn("pub struct BoundBrowserSession", 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 struct DisposableContextCreateRequest", source) + self.assertIn("pub struct DisposableContextDestroyRequest", source) self.assertIn("pub enum BrowserSessionRecoveryEvidence", source) self.assertIn("BrowserSessionState::RecoveryRequired", source) self.assertIn("pub enum DisposableContextCreateError", source) self.assertIn("pub enum DisposableContextDestroyError", source) self.assertNotIn("pub enum DisposableContextPortError", source) + self.assertNotIn("DisposableContextPortId", source) + self.assertNotIn("fn port_id(&self)", source) + self.assertNotIn("pub fn create_disposable_context", source) + self.assertIn("pub fn bind_lifecycle_port", source) self.assertIn("CreateFailedClean", source) self.assertIn("CreateFailedUncertain", source) self.assertIn("PartialCreationIsolation", source) self.assertIn("DuplicateAdapterHandle", source) self.assertIn("UnprovenDestruction", source) - self.assertIn("create_disposable_context", source) + self.assertIn("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("user-context", source) self.assertIn("Reconstructing cleanup authority", source) self.assertIn("sequential_incarnation_reuse_rejects_stale_authority", source) @@ -62,8 +69,19 @@ 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) + create_request_impl = source.split("impl DisposableContextCreateRequest", 1)[1].split( + "pub struct DisposableContextDestroyRequest", 1 + )[0] + destroy_request_impl = source.split("impl DisposableContextDestroyRequest", 1)[1].split( + "pub trait DisposableContextPort", 1 + )[0] + self.assertNotIn("pub fn new", create_request_impl) + self.assertNotIn("pub const fn new", create_request_impl) + self.assertNotIn("pub fn new", destroy_request_impl) + self.assertNotIn("pub const fn new", destroy_request_impl) + def test_hostile_recovery_and_reincarnation_fixtures_remain_external(self) -> None: - """Recovery and sequential reuse invariants must be executable outside crate internals.""" + """Recovery, binding, and sequential reuse invariants must execute outside crate internals.""" destroy_hostile = ( CRATE / "tests/destroy_failure_requires_recovery.rs" @@ -71,19 +89,44 @@ def test_hostile_recovery_and_reincarnation_fixtures_remain_external(self) -> No 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( "destroy_failure_requires_recovery_before_any_new_authority", destroy_hostile, ) self.assertIn("BrowserSessionRecoveryEvidence::UnprovenDestruction", destroy_hostile) - self.assertIn("assert!(session.record_transport_loss());", destroy_hostile) - self.assertIn("assert!(!session.record_transport_loss());", destroy_hostile) + self.assertIn("assert!(bound.record_transport_loss());", destroy_hostile) + self.assertIn("assert!(!bound.record_transport_loss());", destroy_hostile) self.assertIn( "stale_authority_cannot_cross_sequential_session_incarnations", reincarnation_hostile, ) - self.assertIn("assert_ne!(session_a.incarnation(), session_b.incarnation());", reincarnation_hostile) - self.assertIn("assert!(port_b.destroy_incarnations.is_empty());", reincarnation_hostile) + self.assertIn( + "assert_ne!(\n bound_a.browser_session().incarnation(),", + reincarnation_hostile, + ) + self.assertIn( + "assert!(bound_b.lifecycle_port().destroy_incarnations.is_empty());", + reincarnation_hostile, + ) + self.assertIn( + "lifecycle_binding_invokes_no_adapter_callback_before_authorized_create", + preflight_hostile, + ) + self.assertIn("identity_callbacks", 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, + ) def test_architecture_decision_and_traceability_are_explicit(self) -> None: """Disposable ownership must remain a Proposed, standards-traced active-PR claim.""" @@ -102,6 +145,10 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: self.assertIn("RecoveryRequired", adr) self.assertIn("BrowserSessionIncarnation", adr) self.assertIn("BrowserSessionRecoveryEvidence", adr) + self.assertIn("DisposableContextCreateRequest", adr) + self.assertIn("DisposableContextDestroyRequest", adr) + self.assertIn("BoundBrowserSession", adr) + self.assertIn("linear lifecycle-port binding", adr) self.assertIn("DisposableContextCreateError", adr) self.assertIn("DisposableContextDestroyError", adr) self.assertIn("CreateFailedClean", adr) @@ -110,6 +157,7 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: self.assertIn("sequential", adr) self.assertIn("unproven destruction", adr) self.assertIn("IMPLEMENTED_ON_ACTIVE_PR", trace) + self.assertIn("BoundBrowserSession", trace) self.assertIn("RecoveryRequired", trace) self.assertIn("BrowserSessionIncarnation", trace) self.assertIn("lossless recovery evidence", trace) @@ -117,6 +165,7 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: self.assertIn("sequential ABA", trace) self.assertIn("command ACK", trace) self.assertIn("PresentationMutationAuthority", uml) + self.assertIn("BoundBrowserSession", uml) self.assertIn("BrowserSessionIncarnation", uml) self.assertIn("RecoveryRequired", uml) self.assertIn("transport_lost", uml) From db09d3b3c476ecdd06d2a57e422aff2b815f930a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 08:11:27 +0900 Subject: [PATCH 11/50] test(browser-session): add create transaction RED --- .../tests/creation_transaction_completion.rs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 crates/originweave-browser-session/tests/creation_transaction_completion.rs 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..2c6f17592 --- /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]); +} From a2e5b277e55d121f75f367f50f572b81486cc485 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 08:13:18 +0900 Subject: [PATCH 12/50] test(browser-session): enforce transaction and port encapsulation RED --- ...test_browser_session_lifecycle_contract.py | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py index 60c099ece..0dd8afb50 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -39,6 +39,9 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: self.assertIn("pub struct BrowserSessionIncarnation", source) self.assertIn("pub struct PresentationMutationAuthority", source) self.assertIn("pub struct DisposableContextCreateRequest", source) + self.assertIn("pub struct DisposableContextCreateCompletion", source) + self.assertIn("pub enum DisposableContextCreateDisposition", source) + self.assertIn("pub enum DisposableContextCreateCompletionError", source) self.assertIn("pub struct DisposableContextDestroyRequest", source) self.assertIn("pub enum BrowserSessionRecoveryEvidence", source) self.assertIn("BrowserSessionState::RecoveryRequired", source) @@ -47,12 +50,19 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: 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_with_port", source) self.assertIn("advance_context_epoch", source) @@ -70,18 +80,23 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: self.assertNotIn("pub const fn new", authority_impl) create_request_impl = source.split("impl DisposableContextCreateRequest", 1)[1].split( - "pub struct DisposableContextDestroyRequest", 1 + "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] self.assertNotIn("pub fn new", create_request_impl) self.assertNotIn("pub const fn new", create_request_impl) + self.assertNotIn("pub fn new", completion_impl) + self.assertNotIn("pub const fn new", completion_impl) self.assertNotIn("pub fn new", destroy_request_impl) self.assertNotIn("pub const fn new", destroy_request_impl) def test_hostile_recovery_and_reincarnation_fixtures_remain_external(self) -> None: - """Recovery, binding, and sequential reuse invariants must execute outside crate internals.""" + """Recovery, binding, transaction, and sequential reuse invariants execute externally.""" destroy_hostile = ( CRATE / "tests/destroy_failure_requires_recovery.rs" @@ -95,6 +110,10 @@ def test_hostile_recovery_and_reincarnation_fixtures_remain_external(self) -> No substitution_hostile = ( CRATE / "tests/lifecycle_port_same_id_spoof.rs" ).read_text(encoding="utf-8") + transaction_hostile = ( + CRATE / "tests/creation_transaction_completion.rs" + ).read_text(encoding="utf-8") + self.assertIn( "destroy_failure_requires_recovery_before_any_new_authority", destroy_hostile, @@ -102,6 +121,8 @@ def test_hostile_recovery_and_reincarnation_fixtures_remain_external(self) -> No 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, @@ -110,15 +131,16 @@ def test_hostile_recovery_and_reincarnation_fixtures_remain_external(self) -> No "assert_ne!(\n bound_a.browser_session().incarnation(),", reincarnation_hostile, ) - self.assertIn( - "assert!(bound_b.lifecycle_port().destroy_incarnations.is_empty());", - 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, @@ -127,6 +149,16 @@ def test_hostile_recovery_and_reincarnation_fixtures_remain_external(self) -> No "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) def test_architecture_decision_and_traceability_are_explicit(self) -> None: """Disposable ownership must remain a Proposed, standards-traced active-PR claim.""" @@ -146,9 +178,12 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: self.assertIn("BrowserSessionIncarnation", adr) self.assertIn("BrowserSessionRecoveryEvidence", adr) self.assertIn("DisposableContextCreateRequest", adr) + self.assertIn("DisposableContextCreateCompletion", adr) self.assertIn("DisposableContextDestroyRequest", adr) self.assertIn("BoundBrowserSession", adr) self.assertIn("linear lifecycle-port binding", adr) + self.assertIn("no public raw port accessor", adr) + self.assertIn("per-create transaction", adr) self.assertIn("DisposableContextCreateError", adr) self.assertIn("DisposableContextDestroyError", adr) self.assertIn("CreateFailedClean", adr) @@ -158,6 +193,9 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: self.assertIn("unproven destruction", adr) self.assertIn("IMPLEMENTED_ON_ACTIVE_PR", trace) self.assertIn("BoundBrowserSession", trace) + self.assertIn("DisposableContextCreateCompletion", trace) + self.assertIn("per-create transaction", trace) + self.assertIn("no public raw port accessor", trace) self.assertIn("RecoveryRequired", trace) self.assertIn("BrowserSessionIncarnation", trace) self.assertIn("lossless recovery evidence", trace) @@ -166,6 +204,7 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: self.assertIn("command ACK", trace) self.assertIn("PresentationMutationAuthority", uml) self.assertIn("BoundBrowserSession", uml) + self.assertIn("DisposableContextCreateCompletion", uml) self.assertIn("BrowserSessionIncarnation", uml) self.assertIn("RecoveryRequired", uml) self.assertIn("transport_lost", uml) From 32f87d6f598685a8b37ab8a269eb48b233a5b92d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 08:15:02 +0900 Subject: [PATCH 13/50] test(browser-session): keep adapter observations outside bound capability --- .../destroy_failure_requires_recovery.rs | 43 +++++++--- .../tests/lifecycle_port_authority.rs | 53 ++++++++---- .../lifecycle_port_preflight_side_effect.rs | 52 +++++++----- .../tests/lifecycle_port_same_id_spoof.rs | 80 ++++++++++++++----- .../tests/sequential_incarnation_reuse.rs | 63 +++++++++++---- 5 files changed, 212 insertions(+), 79 deletions(-) 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 a93e692f9..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,5 +1,9 @@ +use std::cell::Cell; +use std::rc::Rc; + use originweave_browser_session::{ BrowserSession, BrowserSessionError, BrowserSessionRecoveryEvidence, BrowserSessionState, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, DisposableIsolationId, @@ -9,20 +13,25 @@ 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, }) } } @@ -32,15 +41,22 @@ impl DisposableContextPort for FailingDestroyPort { &mut self, _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, _request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { - self.destroy_calls += 1; + self.destroy_calls.set(self.destroy_calls.get() + 1); Err(DisposableContextDestroyError::DestroyFailed) } } @@ -57,7 +73,14 @@ fn destroy_failure_requires_recovery_before_any_new_authority() -> Result<(), &' let expected_handle = DisposableContextHandle::new(expected_isolation, context_id); let session = BrowserSession::start(session_id) .map_err(|_| "browser session incarnation must be available")?; - let 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 = bound @@ -67,7 +90,7 @@ fn destroy_failure_requires_recovery_before_any_new_authority() -> Result<(), &' bound.destroy_disposable_context(&authority), Err(BrowserSessionError::ContextDestructionFailed) ); - assert_eq!(bound.lifecycle_port().destroy_calls, 1); + assert_eq!(destroy_calls.get(), 1); assert_eq!( bound.browser_session().state(), BrowserSessionState::RecoveryRequired @@ -91,7 +114,7 @@ fn destroy_failure_requires_recovery_before_any_new_authority() -> Result<(), &' bound.create_disposable_context(), Err(BrowserSessionError::SessionNotActive) ); - assert_eq!(bound.lifecycle_port().create_calls, 1); + assert_eq!(create_calls.get(), 1); assert_eq!( bound.presentation_authority(context_id), Err(BrowserSessionError::SessionNotActive) diff --git a/crates/originweave-browser-session/tests/lifecycle_port_authority.rs b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs index 424e75ab2..ea5432fcc 100644 --- a/crates/originweave-browser-session/tests/lifecycle_port_authority.rs +++ b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs @@ -1,21 +1,25 @@ +use std::cell::Cell; +use std::rc::Rc; + use originweave_browser_session::{ - BrowserSession, DisposableContextCreateError, DisposableContextCreateRequest, - DisposableContextDestroyError, DisposableContextDestroyRequest, DisposableContextHandle, - DisposableContextPort, DisposableIsolationId, + BrowserSession, DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct RecordingPort { - create_calls: usize, - destroy_calls: usize, + create_calls: Rc>, + destroy_calls: Rc>, } impl RecordingPort { - fn new() -> Self { + fn new(create_calls: Rc>, destroy_calls: Rc>) -> Self { Self { - create_calls: 0, - destroy_calls: 0, + create_calls, + destroy_calls, } } } @@ -26,13 +30,20 @@ impl DisposableContextPort for RecordingPort { request: &DisposableContextCreateRequest, ) -> Result { assert_eq!(request.browser_session(), BrowserSessionId::new(7).unwrap()); - self.create_calls += 1; + 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, @@ -42,7 +53,7 @@ impl DisposableContextPort for RecordingPort { request.context().browsing_context(), BrowsingContextId::new(41).unwrap() ); - self.destroy_calls += 1; + self.destroy_calls.set(self.destroy_calls.get() + 1); Ok(()) } } @@ -51,18 +62,28 @@ impl DisposableContextPort for RecordingPort { 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 unbound_other_port = RecordingPort::new(); - let mut bound = session.bind_lifecycle_port(RecordingPort::new()); + 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!(bound.lifecycle_port().create_calls, 1); - assert_eq!(unbound_other_port.create_calls, 0); + 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!(bound.lifecycle_port().destroy_calls, 1); - assert_eq!(unbound_other_port.destroy_calls, 0); + 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 index 63532a37f..01c1d658b 100644 --- a/crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs +++ b/crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs @@ -1,23 +1,25 @@ use std::cell::Cell; +use std::rc::Rc; use originweave_browser_session::{ - BrowserSession, DisposableContextCreateError, DisposableContextCreateRequest, - DisposableContextDestroyError, DisposableContextDestroyRequest, DisposableContextHandle, - DisposableContextPort, DisposableIsolationId, + BrowserSession, DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct SideEffectingIdentityPort { - identity_callbacks: Cell, - create_calls: usize, + identity_callbacks: Rc>, + create_calls: Rc>, } impl SideEffectingIdentityPort { - fn new() -> Self { + fn new(identity_callbacks: Rc>, create_calls: Rc>) -> Self { Self { - identity_callbacks: Cell::new(0), - create_calls: 0, + identity_callbacks, + create_calls, } } @@ -32,13 +34,21 @@ impl DisposableContextPort for SideEffectingIdentityPort { &mut self, _request: &DisposableContextCreateRequest, ) -> Result { - self.create_calls += 1; + 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, @@ -51,21 +61,27 @@ impl DisposableContextPort for SideEffectingIdentityPort { 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 port = SideEffectingIdentityPort::new(); - let mut bound = session.bind_lifecycle_port(port); + 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!( - bound.lifecycle_port().identity_callbacks.get(), + identity_callbacks.get(), 0, "binding invoked adapter code before aggregate-issued lifecycle authority existed" ); bound .create_disposable_context() .expect("authorized create"); - assert_eq!(bound.lifecycle_port().identity_callbacks.get(), 0); - assert_eq!(bound.lifecycle_port().create_calls, 1); - - // Prove the fixture would detect an identity callback if production code invoked one. - bound.lifecycle_port().identity_probe(); - assert_eq!(bound.lifecycle_port().identity_callbacks.get(), 1); + 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 index ae16efd9b..bc692f31d 100644 --- a/crates/originweave-browser-session/tests/lifecycle_port_same_id_spoof.rs +++ b/crates/originweave-browser-session/tests/lifecycle_port_same_id_spoof.rs @@ -1,7 +1,11 @@ +use std::cell::Cell; +use std::rc::Rc; + use originweave_browser_session::{ - BrowserSession, DisposableContextCreateError, DisposableContextCreateRequest, - DisposableContextDestroyError, DisposableContextDestroyRequest, DisposableContextHandle, - DisposableContextPort, DisposableIsolationId, + BrowserSession, DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; @@ -9,17 +13,22 @@ use originweave_core::{BrowserSessionId, BrowsingContextId}; struct RecordingPort { context: BrowsingContextId, isolation: &'static str, - create_calls: usize, - destroy_calls: usize, + create_calls: Rc>, + destroy_calls: Rc>, } impl RecordingPort { - fn new(context: u64, isolation: &'static str) -> Self { + 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: 0, - destroy_calls: 0, + create_calls, + destroy_calls, } } } @@ -29,18 +38,25 @@ impl DisposableContextPort for RecordingPort { &mut self, _request: &DisposableContextCreateRequest, ) -> Result { - self.create_calls += 1; + 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 += 1; + self.destroy_calls.set(self.destroy_calls.get() + 1); Ok(()) } } @@ -49,23 +65,51 @@ impl DisposableContextPort for RecordingPort { 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_port = RecordingPort::new(41, "approved-isolation"); - let spoofing_port = RecordingPort::new(42, "spoofed-isolation"); + 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!(bound.lifecycle_port().create_calls, 1); - assert_eq!(spoofing_port.create_calls, 0); + 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_port = RecordingPort::new(51, "approved-isolation"); - let spoofing_port = RecordingPort::new(52, "spoofed-isolation"); + 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() @@ -74,6 +118,6 @@ fn distinct_adapter_cannot_be_substituted_for_destroy_after_binding() { bound .destroy_disposable_context(&authority) .expect("bound adapter destroys context"); - assert_eq!(bound.lifecycle_port().destroy_calls, 1); - assert_eq!(spoofing_port.destroy_calls, 0); + assert_eq!(approved_destroy_calls.get(), 1); + assert_eq!(spoof_destroy_calls.get(), 0); } diff --git a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs index 9232da729..81eac3a9e 100644 --- a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs +++ b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs @@ -1,27 +1,37 @@ +use std::cell::RefCell; +use std::rc::Rc; + use originweave_browser_session::{ - BrowserSession, BrowserSessionError, BrowserSessionIncarnation, DisposableContextCreateError, - DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, - DisposableContextHandle, DisposableContextPort, DisposableIsolationId, + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, }; 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, }) } } @@ -31,15 +41,26 @@ impl DisposableContextPort for ReusingPort { &mut self, request: &DisposableContextCreateRequest, ) -> Result { - self.create_incarnations.push(request.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, request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { - self.destroy_incarnations.push(request.incarnation()); + self.destroy_incarnations + .borrow_mut() + .push(request.incarnation()); Ok(()) } } @@ -50,11 +71,15 @@ 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 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 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() @@ -66,11 +91,15 @@ fn stale_authority_cannot_cross_sequential_session_incarnations() -> Result<(), .end() .map_err(|_| "first browser session must end normally")?; + 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 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() @@ -81,25 +110,25 @@ fn stale_authority_cannot_cross_sequential_session_incarnations() -> Result<(), bound_b.browser_session().incarnation() ); assert_eq!( - bound_a.lifecycle_port().create_incarnations, - vec![bound_a.browser_session().incarnation()] + create_a.borrow().as_slice(), + &[bound_a.browser_session().incarnation()] ); assert_eq!( - bound_b.lifecycle_port().create_incarnations, - vec![bound_b.browser_session().incarnation()] + create_b.borrow().as_slice(), + &[bound_b.browser_session().incarnation()] ); assert_eq!( bound_b.destroy_disposable_context(&authority_a), Err(BrowserSessionError::AuthorityMismatch) ); - assert!(bound_b.lifecycle_port().destroy_incarnations.is_empty()); + assert!(destroy_b.borrow().is_empty()); bound_b .destroy_disposable_context(&authority_b) .map_err(|_| "current incarnation authority must remain valid")?; assert_eq!( - bound_b.lifecycle_port().destroy_incarnations, - vec![bound_b.browser_session().incarnation()] + destroy_b.borrow().as_slice(), + &[bound_b.browser_session().incarnation()] ); Ok(()) } From b3ff343b48c1201f42d5dc6bae07bc606a42c7b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 08:20:57 +0900 Subject: [PATCH 14/50] fix(browser-session): settle exact create transactions --- crates/originweave-browser-session/src/lib.rs | 297 ++++++++++++++---- 1 file changed, 242 insertions(+), 55 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index f020f1c9c..7c8632a7d 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -174,6 +174,8 @@ 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), } @@ -187,6 +189,7 @@ pub enum BrowserSessionRecoveryEvidence { pub struct DisposableContextCreateRequest { browser_session: BrowserSessionId, incarnation: BrowserSessionIncarnation, + attempt_epoch: BrowserContextEpoch, } impl DisposableContextCreateRequest { @@ -201,6 +204,68 @@ impl DisposableContextCreateRequest { 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. @@ -258,6 +323,16 @@ pub trait DisposableContextPort { request: &DisposableContextCreateRequest, ) -> Result; + /// 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, @@ -509,6 +584,7 @@ impl BrowserSession { let request = DisposableContextCreateRequest { browser_session: self.id, incarnation: self.incarnation, + attempt_epoch: epoch, }; let handle = match port.create_disposable_context(&request) { Ok(handle) => handle, @@ -526,25 +602,60 @@ impl BrowserSession { } }; - if self + 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, + 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(BrowserSessionError::DuplicateDisposableIsolation); + return Err(error); } - if self.contexts.contains_key(&handle.browsing_context) { + + 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::DuplicateAdapterHandle( + .push(BrowserSessionRecoveryEvidence::UnsettledAdapterHandle( handle, )); self.enter_recovery_required(); - return Err(BrowserSessionError::DuplicateBrowsingContext); + return Err(BrowserSessionError::ContextCreationUncertain); } let browsing_context = handle.browsing_context; @@ -654,12 +765,6 @@ impl BoundBrowserSession

{ &self.session } - /// Return the bound lifecycle port for read-only diagnostics and adapter-local planning. - #[must_use] - pub const fn lifecycle_port(&self) -> &P { - &self.port - } - /// Create one disposable context through the exact port consumed when this session was bound. pub fn create_disposable_context( &mut self, @@ -734,10 +839,18 @@ mod tests { 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, @@ -756,10 +869,13 @@ mod tests { 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(), @@ -775,6 +891,7 @@ mod tests { self.create_calls += 1; 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 @@ -784,6 +901,23 @@ mod tests { } } + 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, request: &DisposableContextDestroyRequest, @@ -857,16 +991,26 @@ mod tests { let authority = bound .create_disposable_context() .expect("owned disposable context"); + assert_eq!(bound.port.create_sessions, vec![session_id(1)]); assert_eq!( - bound.lifecycle_port().create_sessions, - vec![session_id(1)] + bound.port.create_incarnations, + vec![bound.browser_session().incarnation()] ); + assert_eq!(bound.port.create_attempts, vec![BrowserContextEpoch(1)]); assert_eq!( - bound.lifecycle_port().create_incarnations, - vec![bound.browser_session().incarnation()] + 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(), bound.browser_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); @@ -961,6 +1105,68 @@ mod tests { ); } + #[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, 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), + ] + ); + 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"); @@ -974,8 +1180,8 @@ mod tests { bound .destroy_disposable_context(&authority) .expect("same structurally bound port destroys context"); - assert_eq!(bound.lifecycle_port().create_calls, 1); - assert_eq!(bound.lifecycle_port().destroy_calls, 1); + assert_eq!(bound.port.create_calls, 1); + assert_eq!(bound.port.destroy_calls, 1); } #[test] @@ -986,15 +1192,13 @@ mod tests { bound.create_disposable_context(), Err(BrowserSessionError::EpochExhausted) ); - assert_eq!(bound.lifecycle_port().create_calls, 0); + assert_eq!(bound.port.create_calls, 0); } #[test] fn epoch_exhaustion_prevents_advance_mutation() { let mut bound = session(41).bind_lifecycle_port(TestPort::new(410, "isolation-410")); - let authority = bound - .create_disposable_context() - .expect("owned context"); + let authority = bound.create_disposable_context().expect("owned context"); bound.session.next_epoch = u64::MAX; assert_eq!( bound.advance_context_epoch(context_id(410)), @@ -1006,9 +1210,7 @@ mod tests { #[test] fn epoch_advance_invalidates_old_and_unknown_authority() { let mut bound = session(5).bind_lifecycle_port(TestPort::new(50, "isolation-50")); - let old = bound - .create_disposable_context() - .expect("owned context"); + let old = bound.create_disposable_context().expect("owned context"); assert_eq!( bound.advance_context_epoch(context_id(51)), Err(BrowserSessionError::ContextNotOwned) @@ -1025,7 +1227,7 @@ mod tests { .destroy_disposable_context(&new) .expect("destroy current epoch"); assert_eq!( - bound.lifecycle_port().destroy_incarnations, + bound.port.destroy_incarnations, vec![bound.browser_session().incarnation()] ); assert_eq!( @@ -1041,9 +1243,7 @@ mod tests { #[test] fn cross_session_and_foreign_isolation_authority_fail_before_io() { let mut owner = session(6).bind_lifecycle_port(TestPort::new(60, "isolation-60")); - let authority = owner - .create_disposable_context() - .expect("owner context"); + let authority = owner.create_disposable_context().expect("owner context"); let mut foreign = session(7).bind_lifecycle_port(TestPort::new(60, "isolation-60")); foreign @@ -1053,7 +1253,7 @@ mod tests { foreign.destroy_disposable_context(&authority), Err(BrowserSessionError::AuthorityMismatch) ); - assert_eq!(foreign.lifecycle_port().destroy_calls, 0); + assert_eq!(foreign.port.destroy_calls, 0); let forged = PresentationMutationAuthority { browser_session: owner.browser_session().id(), @@ -1066,7 +1266,7 @@ mod tests { owner.destroy_disposable_context(&forged), Err(BrowserSessionError::AuthorityMismatch) ); - assert_eq!(owner.lifecycle_port().destroy_calls, 0); + assert_eq!(owner.port.destroy_calls, 0); } #[test] @@ -1075,9 +1275,7 @@ mod tests { 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"); + let authority_a = session_a.create_disposable_context().expect("A context"); session_a .destroy_disposable_context(&authority_a) .expect("A destroy"); @@ -1086,9 +1284,7 @@ mod tests { 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"); + let authority_b = session_b.create_disposable_context().expect("B context"); assert_ne!( session_a.browser_session().incarnation(), session_b.browser_session().incarnation() @@ -1097,11 +1293,11 @@ mod tests { session_b.destroy_disposable_context(&authority_a), Err(BrowserSessionError::AuthorityMismatch) ); - assert_eq!(session_b.lifecycle_port().destroy_calls, 0); + assert_eq!(session_b.port.destroy_calls, 0); session_b .destroy_disposable_context(&authority_b) .expect("B destroy"); - assert_eq!(session_b.lifecycle_port().destroy_calls, 1); + assert_eq!(session_b.port.destroy_calls, 1); } #[test] @@ -1111,9 +1307,7 @@ mod tests { 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"); + let authority = bound.create_disposable_context().expect("owned context"); assert_eq!( bound.destroy_disposable_context(&authority), Err(BrowserSessionError::ContextDestructionFailed) @@ -1154,9 +1348,7 @@ mod tests { #[test] fn transport_loss_invalidates_active_contexts_and_is_idempotent() { let mut bound = session(10).bind_lifecycle_port(TestPort::new(100, "isolation-100")); - let authority = bound - .create_disposable_context() - .expect("owned context"); + let authority = bound.create_disposable_context().expect("owned context"); assert!(bound.record_transport_loss()); assert_eq!( bound.browser_session().state(), @@ -1168,25 +1360,20 @@ mod tests { bound.destroy_disposable_context(&authority), Err(BrowserSessionError::SessionNotActive) ); - assert_eq!(bound.lifecycle_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 bound = session(11).bind_lifecycle_port(TestPort::new(110, "isolation-110")); - let authority = bound - .create_disposable_context() - .expect("owned context"); + 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!( - bound.lifecycle_port().destroy_sessions, - vec![session_id(11)] - ); - assert_eq!( - bound.lifecycle_port().destroyed_isolations, + bound.port.destroyed_isolations, vec![isolation_id("isolation-110")] ); bound.end().expect("normal end"); From 6be41094ca81d77f4bebb88f0f5de50899d15f45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 08:21:39 +0900 Subject: [PATCH 15/50] docs(browser-session): record create transaction authority --- ...er-session-disposable-context-authority.md | 112 ++++++++++-------- 1 file changed, 61 insertions(+), 51 deletions(-) diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index b30fb85e3..1dc178b96 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -5,107 +5,117 @@ ## 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 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. -Browser-session, user-context/isolation, browsing-context, and adapter-selected identifiers are protocol or implementation addressability. They are not Browser Session authority. A previous repair introduced opaque `DisposableContextCreateRequest` and `DisposableContextDestroyRequest`, but also asked each adapter to self-report a public numeric port id. That left two defects: a second adapter could select the same id, and Browser Session had to invoke arbitrary adapter code to read that id before lifecycle authority existed. Rust `&self` does not make such a callback pure. +Two active-PR findings refine the lifecycle-port boundary. First, `BoundBrowserSession::lifecycle_port(&self) -> &P` exposed the concrete adapter after binding. Rust shared references do not prove purity: interior mutability, synchronization primitives, or an internally synchronized client can still mutate local state or perform remote I/O. A "read-only" label therefore does not create a security boundary. -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. +Second, one Browser Session incarnation can issue multiple remote create attempts. A create request carrying only `(BrowserSessionId, BrowserSessionIncarnation)` does not identify which returned protocol tuple Browser Session later accepted or rejected. A WebDriver BiDi adapter needs an exact **per-create transaction** so it can stage remote state as pending, promote only the accepted candidate, and quarantine the rejected candidate without relying on call order or adapter-local counters as authority. -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 recovery evidence. Conversely, merely entering recovery does not prove the transport is dead. +Lifecycle failures still require 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. Transport liveness remains orthogonal to ownership certainty. -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 9 September 2026 WebDriver BiDi Working Draft defines `browser.createUserContext`, `browsingContext.create`, and `browser.removeUserContext`. These commands remain adapter capabilities rather than OriginWeave policy authority, and command ACK alone is not destruction proof. ## Decision drivers -- Raw WebDriver/BiDi identifiers and adapter-chosen ids are addressability, not mutation or cleanup authority. +- Raw WebDriver/BiDi identifiers and adapter-chosen values are addressability, not mutation or cleanup authority. - No arbitrary adapter callback may be required to establish lifecycle-port ownership. -- A caller must not be able to substitute a second adapter instance after Browser Session lifecycle binding. -- Create/destroy requests must remain non-caller-constructible and usable only through the bound aggregate composition. -- Sequential aggregate recreation must not make a retained stale authority valid again. -- 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 remain orthogonal. +- 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. +- 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 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. Allocation fails closed before `u64` wrap. -2. Presentation authority is intentionally non-serializable. Within one process, `BrowserSessionIncarnation` prevents sequential ABA when a later aggregate reuses the same external session, isolation, context, and local epoch values. -3. Browser Session uses a **linear lifecycle-port binding**. `BrowserSession::bind_lifecycle_port` consumes both the aggregate and one concrete adapter value into `BoundBrowserSession

`. Binding performs no adapter callback. -4. `BoundBrowserSession

` does not expose mutable port access and its public create/destroy methods accept no alternate port argument. The exact adapter instance is therefore structural composition rather than a caller-selected or self-asserted scalar identity. -5. `DisposableContextPort` has no `port_id()` preflight method. `DisposableContextPortId` is removed. A second adapter cannot claim equality by choosing the same scalar. -6. `DisposableContextCreateRequest` and `DisposableContextDestroyRequest` remain opaque, have no public constructor, and are created only inside the bound Browser Session path after aggregate state or exact presentation authority has been validated. They carry Browser Session addressability and incarnation; the destroy request additionally carries the exact stored handle. -7. The adapter is part of the reviewed lifecycle anti-corruption boundary. A malicious adapter implementation that internally delegates an authorized request is outside what a Rust trait can prevent without inverting the dependency boundary; protocol-specific pending/accepted/quarantine ownership remains the responsibility of the separately reviewed BiDi ACL adapter in ADR 0115. -8. A context enters the owned set only after the bound port returns a `DisposableContextHandle`. Raw `BrowsingContextId` input never creates ownership. -9. `PresentationMutationAuthority` binds browser session, Browser Session incarnation, disposable isolation, browsing context, and context epoch. All fields must match current aggregate ownership before destruction I/O. -10. `DisposableContextCreateError::CreateFailedClean` is valid only when no remote boundary exists. `CreateFailedUncertain(Option)` enters `RecoveryRequired`; a known browser-issued isolation identity is preserved exactly. -11. Duplicate browsing-context or isolation output enters `RecoveryRequired` and stores the complete offending `DisposableContextHandle` as recovery evidence. OriginWeave does not auto-destroy ambiguous output. -12. `BrowserSessionRecoveryEvidence` records only reconciliation evidence: `PartialCreationIsolation`, `DuplicateAdapterHandle`, and `UnprovenDestruction`. It grants no browser command authority. -13. Destruction validates exact authority before I/O and passes the current incarnation and stored handle in `DisposableContextDestroyRequest`. `DisposableContextDestroyError` moves the record and aggregate into recovery and retains the exact failed handle. -14. Transport liveness is stored separately from ownership state. The first `record_transport_loss()` records the fact even after `RecoveryRequired`; repeated reports are idempotent. -15. `RecoveryRequired`, `TransportLost`, and `Ended` reject active-only creation, authority issuance/advance, destruction, and normal end. Reconciliation is a later, separately authorized design. -16. 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. +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 gains the already-reserved `BrowserContextEpoch` as an exact create-attempt 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 then 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. `DisposableContextDestroyRequest` remains opaque and is created only after exact presentation-authority validation. It carries Browser Session addressability, incarnation, and the exact stored handle. +12. `PresentationMutationAuthority` binds browser session, Browser Session incarnation, disposable isolation, browsing context, and context epoch. All fields must match current aggregate ownership before destruction I/O. +13. `DisposableContextCreateError::CreateFailedClean` is valid only when no remote boundary exists. `CreateFailedUncertain(Option)` enters `RecoveryRequired`; any known isolation identity is preserved exactly. +14. 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. +15. `BrowserSessionRecoveryEvidence` includes partial-creation identity, duplicate handle, an unsettled complete adapter handle when completion itself cannot be proven, and exact unproven-destruction handle. Recovery evidence grants no browser command authority. +16. Destruction validates exact authority before I/O. Unproven destruction moves the aggregate into recovery and retains the exact failed handle. +17. Transport liveness is stored separately from ownership state. The first `record_transport_loss()` records the fact even after `RecoveryRequired`; repeated reports are idempotent. +18. `RecoveryRequired`, `TransportLost`, and `Ended` reject normal active-only lifecycle and authority operations. +19. Context epochs remain monotonic authority identities within one aggregate and also provide the create-attempt correlation allocated before remote create I/O. ## Alternatives considered -### Adapter-supplied numeric port id +### Adapter-supplied identity or preflight callback -Rejected. A public scalar is caller-selectable and replayable by a distinct adapter. Making the callback side-effect-free by documentation is also insufficient because Rust `&self` permits interior mutation and delegated effects. +Rejected. A scalar can be replayed and a shared-reference callback can still have side effects before Browser Session authority exists. -### Pointer-address identity +### Public read-only `&P` after binding -Rejected. Object addresses are implementation details, can change when values move, and can be reused after destruction. Pointer equality would replace one ABA surface with another. +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. -### Session-owned wrapper with the concrete port +### Adapter-local create sequence number -Selected. Ownership is represented by Rust move semantics and private fields. No identity probe is required, the caller cannot swap a second adapter into public lifecycle methods, and opaque requests remain confined to the bound call path. +Rejected as authority. It could be useful internally, but Browser Session could not prove which pending remote tuple it was accepting. Correlation must originate in the aggregate-issued request. -### Persist authority generations globally +### Reserved BrowserContextEpoch as create-attempt identity -Deferred. Presentation authority is not durable across process restart; restart reconciliation belongs to evidence and browser observation, not silent authority resurrection. +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. -### Automatically clean duplicate or partial state +### Automatically clean duplicate or rejected state -Rejected. When ownership is ambiguous, cleanup itself can become a cross-owner destructive action. +Rejected. Ambiguous ownership makes speculative cleanup a potential cross-owner destructive action. ## Consequences -Browser Session no longer asks an adapter to prove its own identity before authority. The aggregate and exact lifecycle port become one composed runtime object, while adapter-specific remote identifiers remain outside the Browser Session domain model. +The active stack receives a breaking trait change: every `DisposableContextPort` implementation must settle successful create results through `complete_disposable_context_creation`. #316 must restack non-force and map this completion into its adapter-local pending/accepted/quarantined state. -The API change is intentionally breaking on the active stack: consumers must call `BrowserSession::bind_lifecycle_port(port)` and then perform lifecycle operations through `BoundBrowserSession`. ADR 0115/#316 must be non-force restacked and adapt its WebDriver BiDi lifecycle adapter to this composition before adoption. +The bound adapter is no longer publicly recoverable from `BoundBrowserSession`. Application and test code that needs observability must retain inert metrics or diagnostic projections separately; those projections must not expose adapter command capability. -This binding closes ordinary caller substitution and self-selected-id replay. It does not claim that an adversarial implementation of the trusted `DisposableContextPort` trait cannot internally forward calls; such an implementation already executes inside the reviewed adapter TCB. The BiDi ACL still must prove pending → accepted/quarantined remote ownership, complete recovery tuples, and live-target validation independently. +A completion failure is treated as ownership uncertainty. Browser Session retains the returned handle as recovery evidence and does not mint normal authority. ## Security and governance impact -No page-controlled value, raw browser-session id, raw browsing-context id, user-context string, adapter-selected scalar, provider/model decision, or LLM output can mint lifecycle requests or presentation authority. Browser Session performs no arbitrary adapter callback while establishing the lifecycle-port binding. +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. -Unknown or duplicate remote state is quarantined rather than destroyed speculatively. This does not replace Chromium sandboxing, EgressWeave, Keyverse, Wardnet, or central workflow security. +This decision does not replace Chromium sandboxing, EgressWeave, Keyverse, Wardnet, or central workflow security. ## Tests and exact evidence -The suite retains recovery, sequential ABA, epoch, foreign-authority, destruction, transport-loss, and normal-end coverage. `lifecycle_binding_invokes_no_adapter_callback_before_authorized_create` proves that binding performs no adapter callback before the aggregate-issued create request. `distinct_adapter_cannot_be_substituted_for_create_after_binding` and `distinct_adapter_cannot_be_substituted_for_destroy_after_binding`, together with repository source contracts, require lifecycle methods to use only the consumed port and prohibit reintroduction of public `DisposableContextPortId`/`port_id()` or arbitrary-port Browser Session lifecycle methods. +Required executable cases include: -The prior hostile RED was captured on exact `d43a4d86c8487ebdb9db9f1c4650fb7ee6225afc` in CI `34524654914`: the pre-authority callback fixture observed one identity callback where zero was required. This decision replaces that self-asserted identity design rather than suppressing the test. +- 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; +- recovery, sequential-incarnation ABA, epoch exhaustion, foreign authority, destruction failure, transport loss, and normal end remain covered. -Repository contracts, canonical formatting, locked Rust tests, strict Clippy, rustdoc/API docs, exact function/line/region/branch coverage, current review findings, and applicable central checks remain required on the successor exact head. Predecessor GREEN never transfers. +The historical exact `9cde981899950b900698a17e7fa739af59f6bb4f` CI `34531025582` passed exact production coverage but failed canonical Rust formatting. That exact head also still exposed raw `&P` and lacked per-create completion. Successor evidence must therefore 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, pending/accepted/quarantined remote binding, browser-observed destruction, 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 prove real WebDriver BiDi lifecycle integration, browser-observed destruction, Browser Session→BiDi private-witness conversion, current Chromium presentation post-conditions, crash/restart reconciliation, #299 3/3 Agent Task replay, or protected-main release/SBOM/provenance/reproducibility/rollback. ## Migration and rollback -Consumers on the active stack replace `session.create_disposable_context(&mut port)` / `session.destroy_disposable_context(..., &mut port)` with one `let mut bound = session.bind_lifecycle_port(port)` followed by bound lifecycle calls. The wrapper exposes read-only access to the aggregate and adapter for policy validation and diagnostics but does not return mutable adapter access or an unbound session. +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 and completion settlement. -Rollback returns to the predecessor active-PR API only if the lifecycle-port authority finding is rejected with stronger evidence; it must not restore self-reported scalar identity as a security boundary. +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, self-reported identity, or adapter-local call order as an authorization boundary. ## Open follow-ups -- Restack #316 onto the verified Browser Session successor and adapt the WebDriver BiDi lifecycle ACL to `BoundBrowserSession` without exposing a second lifecycle side door. -- Implement protocol-specific pending → accepted/quarantined creation and complete recovery tuples in the BiDi ACL owner. -- Define 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. +- Define separately authorized recovery reconciliation for `BrowserSessionRecoveryEvidence`. - Replay #299 historical pinned Chromium evidence after the canonical sandbox/runtime repair, then run a separate current-Stable qualification. ## Supersession / reversal conditions From aecb402fb9ecdb1d59036c408078903b699b9cc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 08:22:04 +0900 Subject: [PATCH 16/50] docs(browser-session): trace exact create settlement --- .../browser-session-lifecycle-authority.md | 70 +++++++++++-------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index 1f77f4afa..b0507d725 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -8,7 +8,7 @@ ## Problem and invariant -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. A retained authority must not regain meaning if a later aggregate reuses the same remote identifiers and local epoch, and a caller must not be able to redirect a valid lifecycle request into a second adapter instance by choosing or replaying an adapter id. +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 establishes this chain: @@ -16,11 +16,14 @@ The active implementation establishes this chain: validated BrowserSessionId → BrowserSession::start allocates non-reused BrowserSessionIncarnation → BrowserSession::bind_lifecycle_port consumes one concrete DisposableContextPort -→ BoundBrowserSession

owns aggregate + exact port; binding invokes no adapter callback -→ aggregate validates Active + reserves monotonic context epoch -→ aggregate privately constructs DisposableContextCreateRequest(session, incarnation) -→ exact owned port creates task-owned isolation boundary + browsing context -→ aggregate records exact handle + epoch +→ 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 destroy I/O → aggregate privately constructs DisposableContextDestroyRequest(session, incarnation, stored handle) @@ -29,33 +32,39 @@ validated BrowserSessionId → normal BrowserSession end admitted ``` -`BoundBrowserSession` is the lifecycle composition boundary. Public create/destroy methods accept no arbitrary port argument, no mutable port accessor is exposed, and `DisposableContextPort` has no identity-preflight callback. The previous public `DisposableContextPortId`/`port_id()` design was removed because the value was self-asserted and the callback itself could have side effects before authority. +`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` and invoke an inherent shared-reference method with interior mutation or remote I/O. -`DisposableContextCreateRequest` and `DisposableContextDestroyRequest` have private construction paths. Their getters expose only addressability needed by a reviewed adapter. A caller that knows those values cannot reconstruct the request. +`DisposableContextCreateRequest`, `DisposableContextCreateCompletion`, and `DisposableContextDestroyRequest` have private construction paths. The create request carries the already-reserved context epoch as a **per-create transaction** identity. The exact attempt is settled only after Browser Session validates the returned handle. -## Lossless recovery evidence +## Transactional remote creation + +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`: -`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. +- 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. -Duplicate browsing-context or isolation output stores the complete offending `DisposableContextHandle` as `DuplicateAdapterHandle` before recovery quarantine. Failed or unproven destruction records `UnprovenDestruction` with the exact owned handle. Recovery evidence authorizes no browser command. +Protocol-specific tuple contents and pending/accepted/quarantined storage remain #314/#316 responsibilities. Browser Session owns only the attempt identity, domain validation, and accept/reject decision. -Protocol-specific complete BiDi tuples, pending → accepted/quarantined mapping, and remote target liveness remain #314/#316 responsibilities; they are not copied into Browser Session domain truth. +## Lossless recovery evidence + +`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. None of this evidence grants browser command authority. ## Orthogonal transport liveness -Transport liveness is tracked independently from ownership recovery. If transport loss occurs after `RecoveryRequired`, the aggregate keeps `RecoveryRequired`, preserves all recovery evidence, and separately records `transport_lost = true`. The first loss report is observable; repeated reports are idempotent. If loss occurs while `Active`, the lifecycle state becomes `TransportLost` and active context records become uncertain. +Transport liveness is tracked independently from ownership recovery. If transport loss occurs after `RecoveryRequired`, the aggregate keeps recovery evidence and separately records `transport_lost = true`. Repeated loss reports are idempotent. ## Sequential ABA safety -Aggregate A may create `(S,U,C,epoch=1)`, prove destruction, and end. Aggregate B can later start with the same external `S`; the browser may return the same `U/C`, and B also begins at local epoch 1. A's retained authority still fails before B adapter I/O because B has a different `BrowserSessionIncarnation`. - -The bound port receives the incarnation inside aggregate-issued create/destroy requests. The caller cannot replace the bound adapter after creation to reinterpret that current incarnation against a different adapter-local map. +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. ## 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 design dossier references the 9 September 2026 WebDriver BiDi Working Draft. `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. -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. +OriginWeave does not treat those 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 @@ -63,26 +72,27 @@ OriginWeave does not turn that protocol identifier into policy authority or assu |---|---| | independent Browser Session bounded context | `crates/originweave-browser-session/`; `tests/test_browser_session_lifecycle_contract.py` | | 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()`; repository contract | -| create/destroy requests are aggregate-issued | `DisposableContextCreateRequest`; `DisposableContextDestroyRequest`; `aggregate_issued_request_is_reachable_only_through_owned_port_binding` | +| 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` | -| authority includes non-reused BrowserSessionIncarnation | `PresentationMutationAuthority`; `sequential_incarnation_reuse_rejects_stale_authority` | -| lossless recovery evidence for known partial identity | `BrowserSessionRecoveryEvidence`; `creation_failure_preserves_known_recovery_identity` | -| duplicate adapter handle retained without speculative cleanup | `create_disposable_context_with_port`; `duplicate_adapter_output_preserves_offending_handle` | -| unproven destruction retains exact handle | `destroy_disposable_context_with_port`; `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` | +| sequential ABA authority is rejected before I/O | `BrowserSessionIncarnation`; `stale_authority_cannot_cross_sequential_session_incarnations` | +| lossless recovery evidence | `BrowserSessionRecoveryEvidence`; recovery tests | +| unproven destruction retains exact handle | `destroy_failure_requires_recovery_before_any_new_authority` | +| transport liveness remains orthogonal | `BrowserSession::record_transport_loss` | +| normal end requires proved destruction | `BrowserSession::end` | +| incarnation exhaustion fails closed | `allocate_incarnation` | -The pre-authority adapter-callback RED was captured on exact `d43a4d86c8487ebdb9db9f1c4650fb7ee6225afc` in CI `34524654914`: the hostile fixture observed one identity callback where zero was required. The same predecessor also retained the self-selected scalar identity defect. The bound-session successor must earn fresh exact-head formatting, tests, Clippy, rustdoc, and function/line/region/branch 100% evidence; historical GREEN does not transfer. +Exact `9cde981899950b900698a17e7fa739af59f6bb4f` / CI `34531025582` is historical RED for this successor: production exact coverage passed, but canonical formatting failed, and the raw port accessor plus missing transaction completion remained. 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, observed `browser.removeUserContext` post-condition, protocol-specific pending/accepted/quarantined binding, separately authorized recovery reconciliation, Browser Session authority conversion into BiDi presentation private witnesses, pinned Chromium post-condition observation, crash/process-restart reconciliation, #299 3/3 browser trials, or protected-main release/SBOM/provenance/reproducibility/rollback. +This slice does not yet prove actual WebDriver BiDi lifecycle integration, observed removal post-condition, protocol-specific pending/accepted/quarantined binding, separately authorized recovery reconciliation, Browser Session authority conversion into BiDi presentation private witnesses, Chromium post-condition observation, crash/process-restart reconciliation, #299 3/3 browser trials, or protected-main release/SBOM/provenance/reproducibility/rollback. ## Reference From 729603ae4feadd369eee7819a45d6850604975da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 08:22:28 +0900 Subject: [PATCH 17/50] docs(browser-session): model create settlement transaction --- .../browser-session-lifecycle-authority.md | 67 ++++++++++++------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/docs/uml/browser-session-lifecycle-authority.md b/docs/uml/browser-session-lifecycle-authority.md index 54bd12ead..dc9010a52 100644 --- a/docs/uml/browser-session-lifecycle-authority.md +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -15,19 +15,35 @@ sequenceDiagram S->>S: allocate BrowserSessionIncarnation C->>S: bind_lifecycle_port(port by value) S-->>C: BoundBrowserSession owns aggregate + exact port - Note over S,P: binding invokes no adapter callback + 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 + S->>S: mint DisposableContextCreateRequest(session, incarnation, attempt epoch) S->>P: create_disposable_context(request) - P->>B: create fresh isolation boundary + browsing context + 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 ids and adapter-selected scalar identities cannot mint lifecycle or presentation authority. + 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: 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: RecoveryRequired + end + + Note over C,S: Raw ids, adapter-selected values, and diagnostic references cannot mint lifecycle or presentation authority. C->>BS: advance_context_epoch(context_id) BS->>S: replace epoch; old authority becomes stale @@ -46,23 +62,24 @@ sequenceDiagram S-->>C: Ended ``` -`BoundBrowserSession` is a linear lifecycle-port binding: it consumes one concrete port and exposes no public lifecycle method that accepts a replacement port. `DisposableContextPort` has no identity callback, so Browser Session does not execute arbitrary adapter code merely to establish adapter ownership. `DisposableContextCreateRequest` and `DisposableContextDestroyRequest` are non-caller-constructible capabilities created inside the bound path. +`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. Tests retain inert observation state separately from the moved adapter. -`BrowserSessionIncarnation` separates sequential aggregate lifecycles even when the browser 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 inside the opaque request. +`DisposableContextCreateRequest` and `DisposableContextCreateCompletion` 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. -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. Protocol-specific pending/accepted/quarantined remote tuples remain in the BiDi ACL boundary rather than this domain model. +For WebDriver BiDi, `DisposableIsolationId` maps to the user-context id created by `browser.createUserContext`. Protocol-specific pending/accepted/quarantined remote tuples remain in the BiDi ACL boundary rather than this domain model. ## Recovery and transport state ```mermaid stateDiagram-v2 [*] --> Active - Active --> Active: fresh isolation + context / authority minted + Active --> Active: create candidate + exact Accepted completion + authority Active --> Active: context epoch advanced / prior authority stale Active --> Active: exact owned isolation destruction proved Active --> Active: DisposableContextCreateError::CreateFailedClean Active --> RecoveryRequired: CreateFailedUncertain / retain known partial isolation - Active --> RecoveryRequired: duplicate output / retain offending handle + Active --> RecoveryRequired: duplicate output + exact Rejected completion + Active --> RecoveryRequired: completion unproven / retain UnsettledAdapterHandle Active --> RecoveryRequired: DisposableContextDestroyError / cleanup unproven Active --> Ended: all owned contexts Destroyed + end Active --> TransportLost: browser transport lost @@ -73,13 +90,9 @@ stateDiagram-v2 note right of RecoveryRequired BrowserSessionRecoveryEvidence retains known - partial identity, duplicate handle, or exact - unproven-destruction handle. It grants no I/O. - end note - - note right of TransportLost - Transport liveness is orthogonal to ownership - recovery. Duplicate loss reports are idempotent. + partial identity, duplicate/unsettled handle, + or exact unproven-destruction handle. + It grants no I/O. end note ``` @@ -94,18 +107,20 @@ sequenceDiagram participant PB as Lifecycle port B A->>A: start(S) => incarnation A; bind PA - A->>PA: create(request S, incarnation A) - PA-->>A: U, C + 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: end() B->>B: start(S) => incarnation B; bind PB - B->>PB: create(request S, incarnation B) - PB-->>B: same U, same C - Note over A,B: both local context epochs may equal 1 + 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 PB destroy I/O B->>PB: destroy with authority B + incarnation B ``` -`RecoveryRequired` and `TransportLost` remain terminal for normal authority in this slice. A later reconciliation design may inspect `BrowserSessionRecoveryEvidence`, but it must not reconstruct cleanup authority from raw identifiers or treat command ACK as proof of destruction. +`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. From b161a8a6ecb40853705db2878bf30ef911d3135c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 13:04:28 +0900 Subject: [PATCH 18/50] docs(browser-session): trace destroy failure contract --- docs/adr/0114-browser-session-disposable-context-authority.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index 1dc178b96..ba5e3d3c8 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -47,7 +47,7 @@ Introduce and retain `originweave-browser-session` as an independent Rust bounde 13. `DisposableContextCreateError::CreateFailedClean` is valid only when no remote boundary exists. `CreateFailedUncertain(Option)` enters `RecoveryRequired`; any known isolation identity is preserved exactly. 14. 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. 15. `BrowserSessionRecoveryEvidence` includes partial-creation identity, duplicate handle, an unsettled complete adapter handle when completion itself cannot be proven, and exact unproven-destruction handle. Recovery evidence grants no browser command authority. -16. Destruction validates exact authority before I/O. Unproven destruction moves the aggregate into recovery and retains the exact failed handle. +16. 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. 17. Transport liveness is stored separately from ownership state. The first `record_transport_loss()` records the fact even after `RecoveryRequired`; repeated reports are idempotent. 18. `RecoveryRequired`, `TransportLost`, and `Ended` reject normal active-only lifecycle and authority operations. 19. Context epochs remain monotonic authority identities within one aggregate and also provide the create-attempt correlation allocated before remote create I/O. @@ -98,6 +98,7 @@ Required executable cases include: - 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; +- `DisposableContextDestroyError::DestroyFailed` preserves the exact failed handle, enters `RecoveryRequired`, and never counts a destroy command acknowledgement as proof; - 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. That exact head also still exposed raw `&P` and lacked per-create completion. Successor evidence must therefore be fresh: repository contracts, canonical formatting, locked tests, strict Clippy, rustdoc/API docs, and production function/line/region/branch coverage each exactly 100%. From b721f6a161ae020c30c82ef93a6dc5f8a666f952 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 14:02:17 +0900 Subject: [PATCH 19/50] test(browser-session): retain transport-loss recovery handle --- .../tests/transport_loss_recovery_evidence.rs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 crates/originweave-browser-session/tests/transport_loss_recovery_evidence.rs 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..0dda28a91 --- /dev/null +++ b/crates/originweave-browser-session/tests/transport_loss_recovery_evidence.rs @@ -0,0 +1,111 @@ +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); +} From 89706a1bef8fb0c99eb2a4cd4b305860bc56ffb4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 14:02:37 +0900 Subject: [PATCH 20/50] test(browser-session): reject adapter Debug capability escape --- .../tests/bound_session_debug_redaction.rs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 crates/originweave-browser-session/tests/bound_session_debug_redaction.rs 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" + ); +} From bd662351f997ae330db7b7954e64a48cd207da6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 15:02:58 +0900 Subject: [PATCH 21/50] fix(browser-session): preserve transport recovery evidence --- crates/originweave-browser-session/src/lib.rs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 7c8632a7d..56bfc97ae 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -8,6 +8,7 @@ #![deny(missing_docs)] use std::collections::BTreeMap; +use std::fmt; use std::sync::atomic::{AtomicU64, Ordering}; use originweave_core::{BrowserSessionId, BrowsingContextId}; @@ -178,6 +179,8 @@ pub enum BrowserSessionRecoveryEvidence { UnsettledAdapterHandle(DisposableContextHandle), /// Destruction of this exact owned handle failed or could not be proven. UnprovenDestruction(DisposableContextHandle), + /// Transport loss made this previously active owned handle uncertain. + TransportLossOwnedHandle(DisposableContextHandle), } /// Opaque Browser Session-issued request for one disposable-context creation attempt. @@ -430,12 +433,21 @@ pub struct BrowserSession { /// 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. -#[derive(Debug)] 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("session", &self.session) + .field("port", &"") + .finish() + } +} + impl BrowserSession { /// Start an active Browser Session around an already validated transport session identity. /// @@ -555,6 +567,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(); } From f660ec8ec068e6d183502896f34f189b5a5e94b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 15:08:16 +0900 Subject: [PATCH 22/50] fix(browser-session): bind authorized operations and abandonment --- crates/originweave-browser-session/src/lib.rs | 150 +++++++++++++++++- .../tests/authorized_context_operation.rs | 129 +++++++++++++++ .../tests/bound_session_abandonment.rs | 92 +++++++++++ 3 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 crates/originweave-browser-session/tests/authorized_context_operation.rs create mode 100644 crates/originweave-browser-session/tests/bound_session_abandonment.rs diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 56bfc97ae..1a2e49b6b 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -14,6 +14,17 @@ 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)] @@ -343,6 +354,74 @@ pub trait DisposableContextPort { ) -> 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); @@ -433,6 +512,7 @@ pub struct BrowserSession { /// 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, @@ -442,12 +522,29 @@ impl

fmt::Debug for BoundBrowserSession

{ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("BoundBrowserSession") - .field("session", &self.session) + .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. /// @@ -778,6 +875,18 @@ 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

{ @@ -829,6 +938,45 @@ impl BoundBrowserSession

{ pub fn end(&mut self) -> Result<(), BrowserSessionError> { self.session.end() } + + /// Consume the bound session after verifying that every owned context has proven destruction. + /// + /// Failure consumes the wrapper as well; its non-I/O `Drop` fail-safe records abandonment when + /// unresolved ownership remains instead of pretending remote cleanup succeeded. + 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 { 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..6c139cf7b --- /dev/null +++ b/crates/originweave-browser-session/tests/authorized_context_operation.rs @@ -0,0 +1,129 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use originweave_browser_session::{ + AuthorizedContextOperationError, AuthorizedContextOperationPort, + AuthorizedContextOperationRequest, BrowserSession, BrowserSessionError, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +struct OperationPort { + handle: Option, + operation_calls: Rc>, + observed_operations: 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()); + 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 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), + fail_operation: Rc::clone(&fail_operation), + }; + let session = BrowserSession::start(BrowserSessionId::new(503).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!( + 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"]); + + 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"] + ); +} 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..b540ba6b7 --- /dev/null +++ b/crates/originweave-browser-session/tests/bound_session_abandonment.rs @@ -0,0 +1,92 @@ +use std::cell::Cell; +use std::rc::Rc; + +use originweave_browser_session::{ + abandoned_bound_session_count, BrowserSession, DisposableContextCreateCompletion, + DisposableContextCreateCompletionError, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +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 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 proven_destruction_can_finish_without_abandonment_path() { + 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("consume normally ended bound session"); + assert_eq!(destroy_calls.get(), 1); +} From 6bf2c0df0d2241af86c1afd709cd9c9e11d95d1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 15:12:04 +0900 Subject: [PATCH 23/50] docs(browser-session): trace authorized operation and recovery boundaries --- ...er-session-disposable-context-authority.md | 74 +++-- .../browser-session-lifecycle-authority.md | 51 +++- .../browser-session-lifecycle-authority.md | 67 ++++- ...test_browser_session_lifecycle_contract.py | 265 +++++++++++------- 4 files changed, 303 insertions(+), 154 deletions(-) diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index ba5e3d3c8..68b742eba 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -2,16 +2,15 @@ - Status: Proposed - Date: 2026-09-10 +- Last code-current review: 2026-09-11 ## Context -OriginWeave's Browser Session bounded context is the 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. +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. -Two active-PR findings refine the lifecycle-port boundary. First, `BoundBrowserSession::lifecycle_port(&self) -> &P` exposed the concrete adapter after binding. Rust shared references do not prove purity: interior mutability, synchronization primitives, or an internally synchronized client can still mutate local state or perform remote I/O. A "read-only" label therefore does not create a security boundary. +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 exact non-authorizing recovery evidence and ordinary wrapper abandonment must be observable without pretending that Rust `Drop` proves remote cleanup. -Second, one Browser Session incarnation can issue multiple remote create attempts. A create request carrying only `(BrowserSessionId, BrowserSessionIncarnation)` does not identify which returned protocol tuple Browser Session later accepted or rejected. A WebDriver BiDi adapter needs an exact **per-create transaction** so it can stage remote state as pending, promote only the accepted candidate, and quarantine the rejected candidate without relying on call order or adapter-local counters as authority. - -Lifecycle failures still require 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. Transport liveness remains orthogonal to ownership certainty. +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. Transport liveness remains orthogonal to ownership certainty. The 9 September 2026 WebDriver BiDi Working Draft defines `browser.createUserContext`, `browsingContext.create`, and `browser.removeUserContext`. These commands remain adapter capabilities rather than OriginWeave policy authority, and command ACK alone is not destruction proof. @@ -24,6 +23,9 @@ The 9 September 2026 WebDriver BiDi Working Draft defines `browser.createUserCon - 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`. - 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. @@ -37,20 +39,24 @@ Introduce and retain `originweave-browser-session` as an independent Rust bounde 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 gains the already-reserved `BrowserContextEpoch` as an exact create-attempt identity. The `(session, incarnation, attempt epoch)` tuple is unique for create attempts in one live aggregate and is not caller-constructible as a request. +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 then privately issues `DisposableContextCreateCompletion` for that exact attempt with `Accepted` or `Rejected`. +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. `DisposableContextDestroyRequest` remains opaque and is created only after exact presentation-authority validation. It carries Browser Session addressability, incarnation, and the exact stored handle. -12. `PresentationMutationAuthority` binds browser session, Browser Session incarnation, disposable isolation, browsing context, and context epoch. All fields must match current aggregate ownership before destruction I/O. +12. `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. 13. `DisposableContextCreateError::CreateFailedClean` is valid only when no remote boundary exists. `CreateFailedUncertain(Option)` enters `RecoveryRequired`; any known isolation identity is preserved exactly. 14. 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. -15. `BrowserSessionRecoveryEvidence` includes partial-creation identity, duplicate handle, an unsettled complete adapter handle when completion itself cannot be proven, and exact unproven-destruction handle. Recovery evidence grants no browser command authority. +15. `BrowserSessionRecoveryEvidence` includes partial-creation identity, duplicate handle, unsettled complete adapter handle, exact unproven-destruction handle, and `TransportLossOwnedHandle` for each active handle whose remote liveness becomes uncertain on transport loss. Evidence grants no browser command authority. Repeated transport-loss reports are idempotent. 16. 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. -17. Transport liveness is stored separately from ownership state. The first `record_transport_loss()` records the fact even after `RecoveryRequired`; repeated reports are idempotent. +17. 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. 18. `RecoveryRequired`, `TransportLost`, and `Ended` reject normal active-only lifecycle and authority operations. -19. Context epochs remain monotonic authority identities within one aggregate and also provide the create-attempt correlation allocated before remote create I/O. +19. `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. +20. `BoundBrowserSession

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

` is `#[must_use]` and provides consuming `finish()`, which admits normal completion only after all owned contexts have proven destruction. `Drop` never performs browser I/O. If unresolved remote ownership remains, `Drop` increments the process-local `abandoned_bound_session_count()` operability signal. +22. 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. +23. Context epochs remain monotonic authority identities within one aggregate and also provide the create-attempt correlation allocated before remote create I/O. ## Alternatives considered @@ -62,29 +68,47 @@ Rejected. A scalar can be replayed and a shared-reference callback can still hav 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. +### `#[derive(Debug)]` over `BoundBrowserSession

` + +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. + +### Generic `FnOnce(&mut P)` callback + +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. + +### Second retained adapter or shared client outside `BoundBrowserSession` + +Rejected. It recreates same-key/different-adapter target redirection and lets presentation/reconciliation work escape the exact lifecycle instance Browser Session accepted. + ### Adapter-local create sequence number -Rejected as authority. It could be useful internally, but Browser Session could not prove which pending remote tuple it was accepting. Correlation must originate in the aggregate-issued request. +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. ### Reserved BrowserContextEpoch as create-attempt identity 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. +### Browser I/O from `Drop` + +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()`. + ### Automatically clean duplicate or rejected state Rejected. Ambiguous ownership makes speculative cleanup a potential cross-owner destructive action. ## Consequences -The active stack receives a breaking trait change: every `DisposableContextPort` implementation must settle successful create results through `complete_disposable_context_creation`. #316 must restack non-force and map this completion into its adapter-local pending/accepted/quarantined state. +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 bound adapter is no longer publicly recoverable from `BoundBrowserSession`. Application and test code that needs observability must retain inert metrics or diagnostic projections separately; those projections must not expose adapter command capability. +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 completion failure is treated as ownership uncertainty. Browser Session retains the returned handle as recovery evidence and does not mint normal authority. +Transport loss now preserves exact previously active handles as non-authorizing recovery evidence. Completion or destruction failure remains ownership uncertainty and does not mint normal authority. + +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 -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. +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. This decision does not replace Chromium sandboxing, EgressWeave, Keyverse, Wardnet, or central workflow security. @@ -99,24 +123,30 @@ Required executable cases include: - 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; - `DisposableContextDestroyError::DestroyFailed` preserves the exact failed handle, enters `RecoveryRequired`, and never counts a destroy command acknowledgement as proof; +- 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; proven destruction followed by `finish()` is the normal consuming path; - 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. That exact head also still exposed raw `&P` and lacked per-create completion. Successor evidence must therefore be fresh: repository contracts, canonical formatting, locked tests, strict Clippy, rustdoc/API docs, and production function/line/region/branch coverage each exactly 100%. +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. 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 prove real WebDriver BiDi lifecycle integration, browser-observed destruction, Browser Session→BiDi private-witness conversion, current Chromium presentation post-conditions, crash/restart reconciliation, #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 -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 and completion settlement. +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 and consume the wrapper with `finish()`. 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, self-reported identity, or adapter-local call order as an authorization boundary. +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, or adapter-local call order as an authorization boundary. ## Open follow-ups -- Restack #316 onto the verified Browser Session successor and implement WebDriver BiDi pending → accepted/quarantined transaction settlement. -- Define separately authorized recovery reconciliation for `BrowserSessionRecoveryEvidence`. +- 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. ## Supersession / reversal conditions diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index b0507d725..498e073fb 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -25,16 +25,19 @@ validated BrowserSessionId → 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 destroy I/O -→ aggregate privately constructs DisposableContextDestroyRequest(session, incarnation, stored handle) -→ exact owned port proves 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() consumes the normal lifecycle ``` -`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` and invoke an inherent shared-reference method with interior mutation or remote I/O. +`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. -`DisposableContextCreateRequest`, `DisposableContextCreateCompletion`, and `DisposableContextDestroyRequest` have private construction paths. The create request carries the already-reserved context epoch as a **per-create transaction** identity. The exact attempt is settled only after Browser Session validates the returned handle. +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. + +`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. ## Transactional remote creation @@ -46,15 +49,29 @@ Browser Session examines the returned `DisposableContextHandle`: - 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 the attempt identity, domain validation, and accept/reject decision. +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. Transport loss records each previously active exact handle as `TransportLossOwnedHandle` before marking it uncertain. None of this evidence grants browser command authority. + +Repeated transport-loss reports are idempotent, so exact transport-loss evidence is not duplicated by repeated notification. + +## Abandonment and lifecycle completion -## Lossless recovery evidence +`BoundBrowserSession

` is `#[must_use]`. The normal consuming path is `finish()`, which succeeds only after all owned contexts have proven destruction. `Drop` never performs browser I/O and never treats object destruction as browser destruction proof. -`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. None of this evidence grants browser command authority. +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 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. If transport loss occurs after `RecoveryRequired`, the aggregate keeps recovery evidence and separately records `transport_lost = true`. Repeated loss reports are idempotent. +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 @@ -79,20 +96,26 @@ OriginWeave does not treat those protocol identifiers as policy authority or ass | 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 | `BrowserSessionRecoveryEvidence`; recovery tests | +| lossless recovery evidence while aggregate is retained | `BrowserSessionRecoveryEvidence`; recovery tests | +| 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` | +| normal consuming completion requires proven destruction | `BoundBrowserSession::finish`; `proven_destruction_can_finish_without_abandonment_path` | | transport liveness remains orthogonal | `BrowserSession::record_transport_loss` | | normal end requires proved destruction | `BrowserSession::end` | | incarnation exhaustion fails closed | `allocate_incarnation` | -Exact `9cde981899950b900698a17e7fa739af59f6bb4f` / CI `34531025582` is historical RED for this successor: production exact coverage passed, but canonical formatting failed, and the raw port accessor plus missing transaction completion remained. Historical GREEN never transfers. +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. 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 lifecycle integration, observed removal post-condition, protocol-specific pending/accepted/quarantined binding, separately authorized recovery reconciliation, Browser Session authority conversion into BiDi presentation private witnesses, Chromium post-condition observation, crash/process-restart reconciliation, #299 3/3 browser trials, or protected-main release/SBOM/provenance/reproducibility/rollback. +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 dc9010a52..788224ac0 100644 --- a/docs/uml/browser-session-lifecycle-authority.md +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -8,7 +8,7 @@ sequenceDiagram participant C as Application service participant S as BrowserSession aggregate participant BS as BoundBrowserSession - participant P as DisposableContextPort + participant P as DisposableContextPort / AuthorizedContextOperationPort participant B as Browser adapter (planned) C->>S: start(valid BrowserSessionId) @@ -43,7 +43,21 @@ sequenceDiagram S->>S: RecoveryRequired end - Note over C,S: Raw ids, adapter-selected values, and diagnostic references cannot mint lifecycle or presentation authority. + 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 @@ -57,23 +71,24 @@ sequenceDiagram B-->>P: observed destruction post-condition or DisposableContextDestroyError P-->>S: success S->>S: context = Destroyed - C->>BS: end() + C->>BS: finish() BS->>S: require every owned context Destroyed - S-->>C: Ended + S-->>C: Ended; wrapper consumed ``` -`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. Tests retain inert observation state separately from the moved adapter. +`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` and `DisposableContextCreateCompletion` 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. +`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 WebDriver BiDi, `DisposableIsolationId` maps to the user-context id created by `browser.createUserContext`. Protocol-specific pending/accepted/quarantined remote tuples remain in the BiDi ACL boundary rather than this domain model. +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: 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 @@ -81,8 +96,8 @@ stateDiagram-v2 Active --> RecoveryRequired: duplicate output + exact Rejected completion Active --> RecoveryRequired: completion unproven / retain UnsettledAdapterHandle Active --> RecoveryRequired: DisposableContextDestroyError / cleanup unproven - Active --> Ended: all owned contexts Destroyed + end - Active --> TransportLost: browser transport lost + 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 --> [*] @@ -91,11 +106,35 @@ stateDiagram-v2 note right of RecoveryRequired BrowserSessionRecoveryEvidence retains known partial identity, duplicate/unsettled handle, - or exact unproven-destruction handle. + exact unproven-destruction handle, or transport-loss handle. It grants no I/O. 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 normal completion + C->>BS: destroy exact authority + BS->>P: proven remote destruction + C->>BS: finish() + BS-->>C: consumed / 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 @@ -111,7 +150,7 @@ sequenceDiagram PA-->>A: U, C pending A->>PA: completion Accepted(attempt 1) A->>PA: destroy(request S, incarnation A, U/C) - A->>A: end() + A->>A: finish() B->>B: start(S) => incarnation B; bind PB B->>PB: create(request S, incarnation B, attempt 1) @@ -119,8 +158,8 @@ sequenceDiagram 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 PB destroy I/O - B->>PB: 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. 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 0dd8afb50..5f15da204 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -31,22 +31,33 @@ 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 struct BoundBrowserSession", 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 struct DisposableContextCreateRequest", source) - self.assertIn("pub struct DisposableContextCreateCompletion", source) - self.assertIn("pub enum DisposableContextCreateDisposition", source) - self.assertIn("pub enum DisposableContextCreateCompletionError", source) - self.assertIn("pub struct DisposableContextDestroyRequest", 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) @@ -64,11 +75,17 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: self.assertIn("DuplicateAdapterHandle", source) self.assertIn("UnsettledAdapterHandle", source) self.assertIn("UnprovenDestruction", 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("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) @@ -88,78 +105,93 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: destroy_request_impl = source.split("impl DisposableContextDestroyRequest", 1)[1].split( "pub trait DisposableContextPort", 1 )[0] - self.assertNotIn("pub fn new", create_request_impl) - self.assertNotIn("pub const fn new", create_request_impl) - self.assertNotIn("pub fn new", completion_impl) - self.assertNotIn("pub const fn new", completion_impl) - self.assertNotIn("pub fn new", destroy_request_impl) - self.assertNotIn("pub const fn new", destroy_request_impl) - - def test_hostile_recovery_and_reincarnation_fixtures_remain_external(self) -> None: - """Recovery, binding, transaction, and sequential reuse invariants execute externally.""" - - 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") - 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") - transaction_hostile = ( - CRATE / "tests/creation_transaction_completion.rs" - ).read_text(encoding="utf-8") + 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) - 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" ) + 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" + ) + 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" + ) + 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("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("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.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("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("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("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.""" @@ -172,43 +204,68 @@ 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("DisposableContextCreateRequest", adr) - self.assertIn("DisposableContextCreateCompletion", adr) - self.assertIn("DisposableContextDestroyRequest", adr) - self.assertIn("BoundBrowserSession", adr) - self.assertIn("linear lifecycle-port binding", adr) - self.assertIn("no public raw port accessor", adr) - self.assertIn("per-create transaction", 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("BoundBrowserSession", trace) - self.assertIn("DisposableContextCreateCompletion", trace) - self.assertIn("per-create transaction", trace) - self.assertIn("no public raw port accessor", 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("BoundBrowserSession", uml) - self.assertIn("DisposableContextCreateCompletion", 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", + "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", + "Drop", + "finish()", + ): + self.assertIn(token, adr) + + for token in ( + "IMPLEMENTED_ON_ACTIVE_PR", + "BoundBrowserSession", + "DisposableContextCreateCompletion", + "per-create transaction", + "no public raw port accessor", + "RecoveryRequired", + "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", + "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) From 37b37cde91264a386e5b783220b7dcf4d5c00673 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 15:21:04 +0900 Subject: [PATCH 24/50] docs(browser-session): correct lifecycle trace and BiDi reference --- .../0114-browser-session-disposable-context-authority.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index 68b742eba..48876181d 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -12,7 +12,7 @@ The active implementation has to satisfy four constraints at once. First, `Bound 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. Transport liveness remains orthogonal to ownership certainty. -The 9 September 2026 WebDriver BiDi Working Draft defines `browser.createUserContext`, `browsingContext.create`, and `browser.removeUserContext`. These 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-11 is the 24 August 2026 publication. It defines `browser.createUserContext`, `browsingContext.create`, and `browser.removeUserContext`. These commands remain adapter capabilities rather than OriginWeave policy authority, and command ACK alone is not destruction proof. A previously cited 9 September 2026 snapshot could not be verified in W3C's latest-published report or publication index and is not used as authoritative evidence here. ## Decision drivers @@ -48,7 +48,7 @@ Introduce and retain `originweave-browser-session` as an independent Rust bounde 12. `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. 13. `DisposableContextCreateError::CreateFailedClean` is valid only when no remote boundary exists. `CreateFailedUncertain(Option)` enters `RecoveryRequired`; any known isolation identity is preserved exactly. 14. 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. -15. `BrowserSessionRecoveryEvidence` includes partial-creation identity, duplicate handle, unsettled complete adapter handle, exact unproven-destruction handle, and `TransportLossOwnedHandle` for each active handle whose remote liveness becomes uncertain on transport loss. Evidence grants no browser command authority. Repeated transport-loss reports are idempotent. +15. `BrowserSessionRecoveryEvidence` includes partial-creation identity, duplicate handle, unsettled complete adapter handle, exact unproven-destruction handle, and `TransportLossOwnedHandle` for each active handle whose remote liveness becomes uncertain on transport loss. This is explicit **unproven destruction** evidence rather than cleanup proof. Evidence grants no browser command authority. Repeated transport-loss reports are idempotent. 16. 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. 17. 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. 18. `RecoveryRequired`, `TransportLost`, and `Ended` reject normal active-only lifecycle and authority operations. @@ -155,4 +155,4 @@ Supersede this ADR if the browser platform provides a complete, queryable, gener ## References -Browser Testing and Tools Working Group. (2026, September 9). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/ +Browser Testing and Tools Working Group. (2026, August 24). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260824/ From cc7ecf9ad184afda27c1b125b4f9fd9d04957b37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 15:21:41 +0900 Subject: [PATCH 25/50] test(browser-session): require verified BiDi publication --- tests/test_browser_session_lifecycle_contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py index 5f15da204..6b89cfebe 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -206,7 +206,7 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: ) for token in ( "Status: Proposed", - "WD-webdriver-bidi-20260909", + "WD-webdriver-bidi-20260824", "RecoveryRequired", "BrowserSessionIncarnation", "BrowserSessionRecoveryEvidence", From 710666229b5d9229b47c19ea980bc015a2454af8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 15:22:14 +0900 Subject: [PATCH 26/50] docs(browser-session): align BiDi standards trace --- docs/traceability/browser-session-lifecycle-authority.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index 498e073fb..b4fef0745 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -79,7 +79,7 @@ Aggregate A may create `(S,U,C,epoch=1)`, prove destruction, and end. Aggregate ## Standards trace -The design dossier references the 9 September 2026 WebDriver BiDi Working Draft. `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. +The latest W3C-published WebDriver BiDi Working Draft verified on 2026-09-11 is the 24 August 2026 publication. `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. A previously cited 9 September snapshot could not be verified in the W3C latest-published report or publication index and is therefore not treated as authoritative evidence. OriginWeave does not treat those 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. @@ -119,4 +119,4 @@ This slice does not yet prove actual WebDriver BiDi lifecycle integration, obser ## Reference -Browser Testing and Tools Working Group. (2026, September 9). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/ +Browser Testing and Tools Working Group. (2026, August 24). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260824/ From bdd4b9c898ac1b7f0a0cb0b979e765d4ebe03791 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 16:03:55 +0900 Subject: [PATCH 27/50] test(browser-session): prove failed finish retains ownership --- .../tests/bound_session_abandonment.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/originweave-browser-session/tests/bound_session_abandonment.rs b/crates/originweave-browser-session/tests/bound_session_abandonment.rs index b540ba6b7..5d9993be4 100644 --- a/crates/originweave-browser-session/tests/bound_session_abandonment.rs +++ b/crates/originweave-browser-session/tests/bound_session_abandonment.rs @@ -75,6 +75,38 @@ fn dropping_unresolved_bound_session_is_observable_without_implicit_browser_io() ); } +#[test] +fn failed_finish_must_not_be_reclassified_as_abandonment() { + 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"); + + let finish = bound.finish(); + + assert!( + matches!( + finish, + Err(originweave_browser_session::BrowserSessionError::ActiveContextRemains) + ), + "finish must reject while remote ownership remains unresolved" + ); + 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" + ); +} + #[test] fn proven_destruction_can_finish_without_abandonment_path() { let destroy_calls = Rc::new(Cell::new(0)); From f096c94dd56b0b95c83c8bf91738fc9348b4e568 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 16:04:46 +0900 Subject: [PATCH 28/50] test(browser-session): preserve sibling recovery handles --- .../recovery_required_sibling_evidence.rs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs 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..7addbe2b3 --- /dev/null +++ b/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs @@ -0,0 +1,108 @@ +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::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_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!( + evidence + .iter() + .filter_map(existing_exact_handle) + .any(|candidate| candidate == &sibling), + "a sibling made uncertain by RecoveryRequired must remain exactly enumerable for recovery" + ); +} From 1f02e0b4253e4bdac2685950d678006910d5176e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 16:06:17 +0900 Subject: [PATCH 29/50] test(browser-session): cover authorized operation identity contract --- .../tests/authorized_context_operation.rs | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/originweave-browser-session/tests/authorized_context_operation.rs b/crates/originweave-browser-session/tests/authorized_context_operation.rs index 6c139cf7b..958c6e315 100644 --- a/crates/originweave-browser-session/tests/authorized_context_operation.rs +++ b/crates/originweave-browser-session/tests/authorized_context_operation.rs @@ -4,10 +4,10 @@ use std::rc::Rc; use originweave_browser_session::{ AuthorizedContextOperationError, AuthorizedContextOperationPort, AuthorizedContextOperationRequest, BrowserSession, BrowserSessionError, - DisposableContextCreateCompletion, DisposableContextCreateCompletionError, - DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, - DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, - DisposableIsolationId, + BrowserSessionIncarnation, DisposableContextCreateCompletion, + DisposableContextCreateCompletionError, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; @@ -15,6 +15,8 @@ struct OperationPort { handle: Option, operation_calls: Rc>, observed_operations: Rc>>, + observed_sessions: Rc>>, + observed_incarnations: Rc>>, fail_operation: Rc>, } @@ -56,6 +58,12 @@ impl AuthorizedContextOperationPort for OperationPort { 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 { @@ -68,6 +76,8 @@ impl AuthorizedContextOperationPort for OperationPort { 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 { @@ -78,10 +88,13 @@ fn authorized_operation_uses_exact_bound_port_and_rejects_stale_authority_before )), 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 = BrowserSession::start(BrowserSessionId::new(503).expect("valid session id")) - .expect("incarnation capacity"); + 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 @@ -93,6 +106,8 @@ fn authorized_operation_uses_exact_bound_port_and_rejects_stale_authority_before ); 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!( @@ -126,4 +141,14 @@ fn authorized_operation_uses_exact_bound_port_and_rejects_stale_authority_before 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" + ); } From d5046e76cb7555b448b728ea1bed9ba1ea8de8c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 16:10:38 +0900 Subject: [PATCH 30/50] test(browser-session): serialize abandonment counter assertions --- .../tests/bound_session_abandonment.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/originweave-browser-session/tests/bound_session_abandonment.rs b/crates/originweave-browser-session/tests/bound_session_abandonment.rs index 5d9993be4..f9861217c 100644 --- a/crates/originweave-browser-session/tests/bound_session_abandonment.rs +++ b/crates/originweave-browser-session/tests/bound_session_abandonment.rs @@ -1,5 +1,6 @@ use std::cell::Cell; use std::rc::Rc; +use std::sync::Mutex; use originweave_browser_session::{ abandoned_bound_session_count, BrowserSession, DisposableContextCreateCompletion, @@ -9,6 +10,8 @@ use originweave_browser_session::{ }; use originweave_core::{BrowserSessionId, BrowsingContextId}; +static ABANDONMENT_COUNTER_LOCK: Mutex<()> = Mutex::new(()); + struct AbandonmentPort { handle: Option, destroy_calls: Rc>, @@ -53,6 +56,9 @@ fn port_for(context: u64, destroy_calls: &Rc>) -> AbandonmentPort { #[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")) @@ -77,6 +83,9 @@ fn dropping_unresolved_bound_session_is_observable_without_implicit_browser_io() #[test] fn failed_finish_must_not_be_reclassified_as_abandonment() { + 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")) @@ -109,6 +118,9 @@ fn failed_finish_must_not_be_reclassified_as_abandonment() { #[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"); From 561993c6b09b60d79404eb0fab2ac6993a6c4535 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:02:59 +0900 Subject: [PATCH 31/50] fix(browser-session): retain recovery ownership on failed completion --- crates/originweave-browser-session/src/lib.rs | 83 +++++++++++++------ 1 file changed, 57 insertions(+), 26 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 1a2e49b6b..4ead8d7ea 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -190,6 +190,8 @@ pub enum BrowserSessionRecoveryEvidence { 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), } @@ -527,7 +529,10 @@ impl

fmt::Debug for BoundBrowserSession

{ .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( + "recovery_evidence_count", + &self.session.recovery_evidence.len(), + ) .field("port", &"") .finish() } @@ -748,10 +753,9 @@ impl BrowserSession { .complete_disposable_context_creation(&completion) .is_err() { - self.recovery_evidence - .push(BrowserSessionRecoveryEvidence::UnsettledAdapterHandle( - handle, - )); + self.recovery_evidence.push( + BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(handle), + ); self.enter_recovery_required(); return Err(BrowserSessionError::ContextCreationUncertain); } @@ -769,10 +773,9 @@ impl BrowserSession { .complete_disposable_context_creation(&completion) .is_err() { - self.recovery_evidence - .push(BrowserSessionRecoveryEvidence::UnsettledAdapterHandle( - handle, - )); + self.recovery_evidence.push( + BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(handle), + ); self.enter_recovery_required(); return Err(BrowserSessionError::ContextCreationUncertain); } @@ -864,6 +867,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(); } @@ -939,11 +965,11 @@ impl BoundBrowserSession

{ self.session.end() } - /// Consume the bound session after verifying that every owned context has proven destruction. + /// Verify normal completion without relinquishing the exact bound lifecycle owner on failure. /// - /// Failure consumes the wrapper as well; its non-I/O `Drop` fail-safe records abandonment when - /// unresolved ownership remains instead of pretending remote cleanup succeeded. - pub fn finish(mut self) -> Result<(), BrowserSessionError> { + /// 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() } } @@ -1232,10 +1258,12 @@ mod tests { #[test] fn duplicate_adapter_output_preserves_offending_handle() { + 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 context_port = TestPort::with_handles(vec![ - DisposableContextHandle::new(isolation_id("isolation-30-a"), context_id(30)), + first_context_handle.clone(), duplicate_context_handle.clone(), ]); let mut duplicate_context = session(3).bind_lifecycle_port(context_port); @@ -1248,15 +1276,18 @@ mod tests { ); assert_eq!( duplicate_context.browser_session().recovery_evidence(), - &[BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( - duplicate_context_handle - )] + &[ + BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(duplicate_context_handle), + BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(first_context_handle), + ] ); + 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 isolation_port = TestPort::with_handles(vec![ - DisposableContextHandle::new(isolation_id("isolation-31"), context_id(310)), + first_isolation_handle.clone(), duplicate_isolation_handle.clone(), ]); let mut duplicate_isolation = session(31).bind_lifecycle_port(isolation_port); @@ -1269,16 +1300,16 @@ mod tests { ); assert_eq!( duplicate_isolation.browser_session().recovery_evidence(), - &[BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( - duplicate_isolation_handle - )] + &[ + 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 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); @@ -1309,11 +1340,10 @@ mod tests { #[test] fn rejected_create_completion_failure_preserves_duplicate_and_unsettled_evidence() { - let first = - DisposableContextHandle::new(isolation_id("isolation-316-a"), context_id(316)); + 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, duplicate.clone()]); + let mut port = TestPort::with_handles(vec![first.clone(), duplicate.clone()]); let mut bound = session(316).bind_lifecycle_port(port); bound @@ -1329,6 +1359,7 @@ mod tests { &[ BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(duplicate.clone()), BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(duplicate), + BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(first), ] ); assert_eq!( From 6e23406ec6e0128a5d32af34c8c5cca3c5639415 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:04:43 +0900 Subject: [PATCH 32/50] test(browser-session): prove sibling recovery projection --- .../recovery_required_sibling_evidence.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs b/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs index 7addbe2b3..0e9bb0243 100644 --- a/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs +++ b/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs @@ -45,11 +45,14 @@ fn handle(context: u64, isolation: &str) -> DisposableContextHandle { ) } -fn existing_exact_handle(evidence: &BrowserSessionRecoveryEvidence) -> Option<&DisposableContextHandle> { +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, } @@ -89,6 +92,12 @@ fn recovery_required_projects_exact_handles_for_indirectly_uncertain_siblings() )), "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() @@ -98,11 +107,13 @@ fn recovery_required_projects_exact_handles_for_indirectly_uncertain_siblings() 1, "the directly failed context must not be duplicated as generic recovery evidence" ); - assert!( + assert_eq!( evidence .iter() .filter_map(existing_exact_handle) - .any(|candidate| candidate == &sibling), - "a sibling made uncertain by RecoveryRequired must remain exactly enumerable for recovery" + .filter(|candidate| *candidate == &sibling) + .count(), + 1, + "an indirectly invalidated sibling must be retained exactly once" ); } From 9a1caaa76905a3aae46f67817207fed5617a505a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:05:01 +0900 Subject: [PATCH 33/50] test(browser-session): retain owner after failed finish --- .../tests/bound_session_abandonment.rs | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/crates/originweave-browser-session/tests/bound_session_abandonment.rs b/crates/originweave-browser-session/tests/bound_session_abandonment.rs index f9861217c..e0a30dfff 100644 --- a/crates/originweave-browser-session/tests/bound_session_abandonment.rs +++ b/crates/originweave-browser-session/tests/bound_session_abandonment.rs @@ -3,10 +3,11 @@ use std::rc::Rc; use std::sync::Mutex; use originweave_browser_session::{ - abandoned_bound_session_count, BrowserSession, DisposableContextCreateCompletion, - DisposableContextCreateCompletionError, DisposableContextCreateError, - DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, - DisposableContextHandle, DisposableContextPort, DisposableIsolationId, + abandoned_bound_session_count, BrowserSession, BrowserSessionError, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; @@ -82,7 +83,7 @@ fn dropping_unresolved_bound_session_is_observable_without_implicit_browser_io() } #[test] -fn failed_finish_must_not_be_reclassified_as_abandonment() { +fn failed_finish_retains_same_bound_owner_for_cleanup_and_retry() { let _guard = ABANDONMENT_COUNTER_LOCK .lock() .expect("abandonment counter test lock"); @@ -91,19 +92,11 @@ fn failed_finish_must_not_be_reclassified_as_abandonment() { 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 + let authority = bound .create_disposable_context() .expect("accepted disposable context"); - let finish = bound.finish(); - - assert!( - matches!( - finish, - Err(originweave_browser_session::BrowserSessionError::ActiveContextRemains) - ), - "finish must reject while remote ownership remains unresolved" - ); + assert_eq!(bound.finish(), Err(BrowserSessionError::ActiveContextRemains)); assert_eq!( abandoned_bound_session_count(), before, @@ -114,6 +107,18 @@ fn failed_finish_must_not_be_reclassified_as_abandonment() { 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] @@ -131,6 +136,6 @@ fn proven_destruction_can_finish_without_abandonment_path() { bound .destroy_disposable_context(&authority) .expect("proven destruction"); - bound.finish().expect("consume normally ended bound session"); + bound.finish().expect("end normally after proven destruction"); assert_eq!(destroy_calls.get(), 1); } From 730d8afc2227040eed591c2a1f9c22b58493cd23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:06:01 +0900 Subject: [PATCH 34/50] style(browser-session): apply canonical formatting --- .../tests/authorized_context_operation.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-browser-session/tests/authorized_context_operation.rs b/crates/originweave-browser-session/tests/authorized_context_operation.rs index 958c6e315..926871c1f 100644 --- a/crates/originweave-browser-session/tests/authorized_context_operation.rs +++ b/crates/originweave-browser-session/tests/authorized_context_operation.rs @@ -82,8 +82,7 @@ fn authorized_operation_uses_exact_bound_port_and_rejects_stale_authority_before 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"), + DisposableIsolationId::parse("operation-user-context-503").expect("valid isolation id"), context, )), operation_calls: Rc::clone(&operation_calls), From bc0743638993aeab108b198f16061a1a6b828d65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:06:35 +0900 Subject: [PATCH 35/50] test(repo): lock recovery and finish ownership contracts --- tests/test_browser_session_lifecycle_contract.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py index 6b89cfebe..00528d583 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -75,6 +75,7 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: self.assertIn("DuplicateAdapterHandle", source) self.assertIn("UnsettledAdapterHandle", source) self.assertIn("UnprovenDestruction", source) + self.assertIn("RecoveryRequiredOwnedHandle", source) self.assertIn("TransportLossOwnedHandle", source) self.assertIn("create_disposable_context_with_port", source) self.assertIn("advance_context_epoch", source) @@ -89,6 +90,8 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: 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 @@ -141,6 +144,9 @@ def test_hostile_recovery_binding_and_operation_fixtures_remain_external(self) - 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" ) @@ -181,6 +187,10 @@ def test_hostile_recovery_binding_and_operation_fixtures_remain_external(self) - 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) @@ -189,6 +199,8 @@ def test_hostile_recovery_binding_and_operation_fixtures_remain_external(self) - 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) @@ -208,6 +220,7 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: "Status: Proposed", "WD-webdriver-bidi-20260824", "RecoveryRequired", + "RecoveryRequiredOwnedHandle", "BrowserSessionIncarnation", "BrowserSessionRecoveryEvidence", "DisposableContextCreateRequest", @@ -227,6 +240,7 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: "AuthorizedContextOperationPort", "TransportLossOwnedHandle", "abandoned_bound_session_count", + "failed `finish()`", "Drop", "finish()", ): @@ -239,6 +253,7 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: "per-create transaction", "no public raw port accessor", "RecoveryRequired", + "RecoveryRequiredOwnedHandle", "BrowserSessionIncarnation", "lossless recovery evidence", "transport liveness", @@ -257,6 +272,7 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: "DisposableContextCreateCompletion", "BrowserSessionIncarnation", "RecoveryRequired", + "RecoveryRequiredOwnedHandle", "transport_lost", "DisposableContextDestroyError / cleanup unproven", "AuthorizedContextOperationRequest", From e82d721d4607506944b30985c5a7a6a51abfe2bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:07:21 +0900 Subject: [PATCH 36/50] docs(browser-session): align recovery and finish invariants --- ...er-session-disposable-context-authority.md | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index 48876181d..7e6e4beb2 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -8,9 +8,9 @@ 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 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 exact non-authorizing recovery evidence and ordinary wrapper abandonment must be observable without pretending that Rust `Drop` proves remote cleanup. +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 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. Transport liveness remains orthogonal to ownership certainty. +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. The latest W3C-published WebDriver BiDi Working Draft verified on 2026-09-11 is the 24 August 2026 publication. It defines `browser.createUserContext`, `browsingContext.create`, and `browser.removeUserContext`. These commands remain adapter capabilities rather than OriginWeave policy authority, and command ACK alone is not destruction proof. A previously cited 9 September 2026 snapshot could not be verified in W3C's latest-published report or publication index and is not used as authoritative evidence here. @@ -26,6 +26,7 @@ The latest W3C-published WebDriver BiDi Working Draft verified on 2026-09-11 is - 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. @@ -48,13 +49,13 @@ Introduce and retain `originweave-browser-session` as an independent Rust bounde 12. `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. 13. `DisposableContextCreateError::CreateFailedClean` is valid only when no remote boundary exists. `CreateFailedUncertain(Option)` enters `RecoveryRequired`; any known isolation identity is preserved exactly. 14. 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. -15. `BrowserSessionRecoveryEvidence` includes partial-creation identity, duplicate handle, unsettled complete adapter handle, exact unproven-destruction handle, and `TransportLossOwnedHandle` for each active handle whose remote liveness becomes uncertain on transport loss. This is explicit **unproven destruction** evidence rather than cleanup proof. Evidence grants no browser command authority. Repeated transport-loss reports are idempotent. -16. 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. +15. `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. +16. 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. 17. 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. 18. `RecoveryRequired`, `TransportLost`, and `Ended` reject normal active-only lifecycle and authority operations. 19. `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. 20. `BoundBrowserSession

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

` is `#[must_use]` and provides consuming `finish()`, which admits normal completion only after all owned contexts have proven destruction. `Drop` never performs browser I/O. If unresolved remote ownership remains, `Drop` increments the process-local `abandoned_bound_session_count()` operability signal. +21. `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. 22. 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. 23. Context epochs remain monotonic authority identities within one aggregate and also provide the create-attempt correlation allocated before remote create I/O. @@ -88,6 +89,10 @@ Rejected as authority. It may be useful internally, but Browser Session could no 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. +### Consuming `finish(self)` before validation + +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`. + ### Browser I/O from `Drop` 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()`. @@ -102,9 +107,11 @@ The active stack receives a breaking trait extension for presentation/reconcilia 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. -Transport loss now preserves exact previously active handles as non-authorizing recovery evidence. Completion or destruction failure remains ownership uncertainty and does not mint normal authority. +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. + +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. -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. +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 @@ -123,13 +130,15 @@ Required executable cases include: - 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; - `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; proven destruction followed by `finish()` is the normal consuming path; +- 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. 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%. +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 @@ -139,9 +148,9 @@ This slice does not yet prove actual WebDriver BiDi lifecycle integration, brows 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 and consume the wrapper with `finish()`. 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. +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, or adapter-local call order as an authorization boundary. +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, or adapter-local call order as an authorization boundary. ## Open follow-ups From 5992e34e5af2a5366e5e8c900f26b0d12beb916a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:07:49 +0900 Subject: [PATCH 37/50] docs(trace): record sibling recovery and finish retry --- .../browser-session-lifecycle-authority.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index b4fef0745..ec0369cf3 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -30,7 +30,7 @@ validated BrowserSessionId → presentation/reconciliation uses private AuthorizedContextOperationRequest → exact consumed adapter only → proven destruction for every context -→ BoundBrowserSession::finish() consumes the normal lifecycle +→ BoundBrowserSession::finish() validates normal completion without consuming the owner on rejection ``` `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. @@ -59,15 +59,15 @@ Stale or foreign authority returns `AuthorizedContextOperationError::BrowserSess ## 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. Transport loss records each previously active exact handle as `TransportLossOwnedHandle` before marking it uncertain. None of this evidence grants browser command authority. +`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. -Repeated transport-loss reports are idempotent, so exact transport-loss evidence is not duplicated by repeated notification. +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. ## Abandonment and lifecycle completion -`BoundBrowserSession

` is `#[must_use]`. The normal consuming path is `finish()`, which succeeds only after all owned contexts have proven destruction. `Drop` never performs browser I/O and never treats object destruction as browser destruction proof. +`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. -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 crash/process-restart recovery therefore remains open until a canonical recovery owner persists `BrowserSessionRecoveryEvidence` before process termination. +`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 @@ -101,15 +101,17 @@ OriginWeave does not treat those protocol identifiers as policy authority or ass | 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` | -| normal consuming completion requires proven destruction | `BoundBrowserSession::finish`; `proven_destruction_can_finish_without_abandonment_path` | +| 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` | | 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. Historical GREEN never transfers. +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`. From 77639717ca0e9f66ce8bc2cfc9a2173764173eb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:08:15 +0900 Subject: [PATCH 38/50] docs(uml): show recovery sibling and finish retry flows --- .../browser-session-lifecycle-authority.md | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/docs/uml/browser-session-lifecycle-authority.md b/docs/uml/browser-session-lifecycle-authority.md index 788224ac0..09cf518f1 100644 --- a/docs/uml/browser-session-lifecycle-authority.md +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -34,12 +34,14 @@ sequenceDiagram 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 @@ -69,11 +71,24 @@ sequenceDiagram 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 + 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() - BS->>S: require every owned context Destroyed - S-->>C: Ended; wrapper consumed + 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 ``` `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. @@ -92,11 +107,12 @@ stateDiagram-v2 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 + exact Rejected completion - Active --> RecoveryRequired: completion unproven / retain UnsettledAdapterHandle - Active --> RecoveryRequired: DisposableContextDestroyError / cleanup unproven - Active --> Ended: all owned contexts Destroyed + finish + 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 --> [*] @@ -106,7 +122,8 @@ stateDiagram-v2 note right of RecoveryRequired BrowserSessionRecoveryEvidence retains known partial identity, duplicate/unsettled handle, - exact unproven-destruction handle, or transport-loss handle. + exact unproven-destruction handle, and + RecoveryRequiredOwnedHandle for indirect siblings. It grants no I/O. end note ``` @@ -120,11 +137,13 @@ sequenceDiagram participant O as Operability / recovery observer C->>BS: create accepted remote ownership - alt normal completion + 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: consumed / Ended + BS-->>C: Ended else ordinary wrapper abandonment C-xBS: drop without proven cleanup Note over BS,P: Drop performs no browser I/O From 16f92dd3403c8a5c690afa82c407a479ab1fd108 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:08:38 +0900 Subject: [PATCH 39/50] style(browser-session): format transaction fixture --- .../tests/creation_transaction_completion.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-browser-session/tests/creation_transaction_completion.rs b/crates/originweave-browser-session/tests/creation_transaction_completion.rs index 2c6f17592..e0cb9c49c 100644 --- a/crates/originweave-browser-session/tests/creation_transaction_completion.rs +++ b/crates/originweave-browser-session/tests/creation_transaction_completion.rs @@ -5,9 +5,9 @@ use std::rc::Rc; use originweave_browser_session::{ BrowserSession, BrowserSessionError, BrowserSessionIncarnation, BrowserSessionState, DisposableContextCreateCompletion, DisposableContextCreateCompletionError, - DisposableContextCreateDisposition, DisposableContextCreateError, DisposableContextCreateRequest, - DisposableContextDestroyError, DisposableContextDestroyRequest, DisposableContextHandle, - DisposableContextPort, DisposableIsolationId, + DisposableContextCreateDisposition, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; From 371ca37d9979ee97167a73eb2042625f0d5ddc9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:08:54 +0900 Subject: [PATCH 40/50] style(browser-session): format preflight fixture --- .../tests/lifecycle_port_preflight_side_effect.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 index 01c1d658b..cee8c46ed 100644 --- a/crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs +++ b/crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs @@ -63,10 +63,8 @@ fn lifecycle_binding_invokes_no_adapter_callback_before_authorized_create() { .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), - ); + 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(); From 916646dd3fcc03e3c6273fc925fb3e0230b97662 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:09:11 +0900 Subject: [PATCH 41/50] style(browser-session): format transport recovery fixture --- .../tests/transport_loss_recovery_evidence.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/originweave-browser-session/tests/transport_loss_recovery_evidence.rs b/crates/originweave-browser-session/tests/transport_loss_recovery_evidence.rs index 0dda28a91..90c6b8b69 100644 --- a/crates/originweave-browser-session/tests/transport_loss_recovery_evidence.rs +++ b/crates/originweave-browser-session/tests/transport_loss_recovery_evidence.rs @@ -22,8 +22,7 @@ impl DisposableContextPort for ObservedPort { ) -> Result { self.create_calls.set(self.create_calls.get() + 1); Ok(DisposableContextHandle::new( - DisposableIsolationId::parse("transport-user-context-501") - .expect("valid isolation id"), + DisposableIsolationId::parse("transport-user-context-501").expect("valid isolation id"), BrowsingContextId::new(501).expect("valid browsing context"), )) } @@ -72,13 +71,21 @@ fn transport_loss_preserves_exact_owned_handle_as_non_authorizing_recovery_evide bound.browser_session().state(), BrowserSessionState::TransportLost ); - assert_eq!(create_calls.get(), 1, "transport loss must not create browser state"); + 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"); + assert_eq!( + destroy_calls.get(), + 0, + "transport loss is not destruction proof" + ); let evidence = bound.browser_session().recovery_evidence(); assert_eq!( From bd1bd857bba754ed06e3c326eeeba953f96faaa0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:09:43 +0900 Subject: [PATCH 42/50] style(browser-session): format abandonment fixture --- .../tests/bound_session_abandonment.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/originweave-browser-session/tests/bound_session_abandonment.rs b/crates/originweave-browser-session/tests/bound_session_abandonment.rs index e0a30dfff..5a1c07fd2 100644 --- a/crates/originweave-browser-session/tests/bound_session_abandonment.rs +++ b/crates/originweave-browser-session/tests/bound_session_abandonment.rs @@ -3,11 +3,11 @@ use std::rc::Rc; use std::sync::Mutex; use originweave_browser_session::{ - abandoned_bound_session_count, BrowserSession, BrowserSessionError, - DisposableContextCreateCompletion, DisposableContextCreateCompletionError, - DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, - DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, - DisposableIsolationId, + BrowserSession, BrowserSessionError, DisposableContextCreateCompletion, + DisposableContextCreateCompletionError, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, + abandoned_bound_session_count, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; From 509866acd1041c34a819ab68120d0553f6e6478c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:16:24 +0900 Subject: [PATCH 43/50] style(browser-session): apply hosted rustfmt diagnostics --- .../tests/bound_session_abandonment.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/originweave-browser-session/tests/bound_session_abandonment.rs b/crates/originweave-browser-session/tests/bound_session_abandonment.rs index 5a1c07fd2..6106b5f24 100644 --- a/crates/originweave-browser-session/tests/bound_session_abandonment.rs +++ b/crates/originweave-browser-session/tests/bound_session_abandonment.rs @@ -96,7 +96,10 @@ fn failed_finish_retains_same_bound_owner_for_cleanup_and_retry() { .create_disposable_context() .expect("accepted disposable context"); - assert_eq!(bound.finish(), Err(BrowserSessionError::ActiveContextRemains)); + assert_eq!( + bound.finish(), + Err(BrowserSessionError::ActiveContextRemains) + ); assert_eq!( abandoned_bound_session_count(), before, @@ -112,7 +115,9 @@ fn failed_finish_retains_same_bound_owner_for_cleanup_and_retry() { .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"); + bound + .finish() + .expect("retry succeeds after proven destruction"); drop(bound); assert_eq!( abandoned_bound_session_count(), @@ -136,6 +141,8 @@ fn proven_destruction_can_finish_without_abandonment_path() { bound .destroy_disposable_context(&authority) .expect("proven destruction"); - bound.finish().expect("end normally after proven destruction"); + bound + .finish() + .expect("end normally after proven destruction"); assert_eq!(destroy_calls.get(), 1); } From da4d049bbb815456f7d114c98197e12121a8ea6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:16:52 +0900 Subject: [PATCH 44/50] style(browser-session): apply hosted rustfmt diagnostics --- .../tests/recovery_required_sibling_evidence.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs b/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs index 0e9bb0243..164e217d7 100644 --- a/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs +++ b/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs @@ -93,9 +93,9 @@ fn recovery_required_projects_exact_handles_for_indirectly_uncertain_siblings() "the directly failed destruction must keep its cause-specific evidence" ); assert!( - evidence.contains(&BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle( - sibling.clone() - )), + evidence.contains( + &BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(sibling.clone()) + ), "the indirectly invalidated sibling must be projected as non-authorizing exact recovery evidence" ); assert_eq!( From ba3656a2165332f4ea6c367dda91c071cc34d9af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:19:17 +0900 Subject: [PATCH 45/50] style(browser-session): apply hosted rustfmt diagnostics --- crates/originweave-browser-session/src/lib.rs | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 4ead8d7ea..fa2982909 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -773,9 +773,10 @@ impl BrowserSession { .complete_disposable_context_creation(&completion) .is_err() { - self.recovery_evidence.push( - BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(handle), - ); + self.recovery_evidence + .push(BrowserSessionRecoveryEvidence::UnsettledAdapterHandle( + handle, + )); self.enter_recovery_required(); return Err(BrowserSessionError::ContextCreationUncertain); } @@ -867,24 +868,24 @@ 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 - } + 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::>(); + .collect::>(); self.recovery_evidence.extend( sibling_handles .into_iter() From 56a96ad8407b418d1775cfdf091519b57ad1e893 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 17:20:47 +0900 Subject: [PATCH 46/50] docs(product): currentize browser-session buyer gap baseline --- docs/product-technical-gap-baseline.md | 9 +++++++++ 1 file changed, 9 insertions(+) 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. From 9818ada98cbdb9ed4054e96b7c7c4f3e52d51f3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 00:05:35 +0900 Subject: [PATCH 47/50] test(browser-session): expose arbitrary user-context length cap --- .../tests/user_context_identity_length.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 crates/originweave-browser-session/tests/user_context_identity_length.rs 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); +} From eb2701bcd41aca1e442e8919e8dcef7bde805d47 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 00:08:31 +0900 Subject: [PATCH 48/50] docs(browser-session): correct BiDi provenance and identity contract --- ...er-session-disposable-context-authority.md | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index 7e6e4beb2..c49b2d251 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -2,7 +2,7 @@ - Status: Proposed - Date: 2026-09-10 -- Last code-current review: 2026-09-11 +- Last code-current review: 2026-09-12 ## Context @@ -12,11 +12,12 @@ The active implementation has to satisfy four constraints at once. First, `Bound 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. -The latest W3C-published WebDriver BiDi Working Draft verified on 2026-09-11 is the 24 August 2026 publication. It defines `browser.createUserContext`, `browsingContext.create`, and `browser.removeUserContext`. These commands remain adapter capabilities rather than OriginWeave policy authority, and command ACK alone is not destruction proof. A previously cited 9 September 2026 snapshot could not be verified in W3C's latest-published report or publication index and is not used as authoritative evidence here. +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 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. @@ -45,19 +46,20 @@ Introduce and retain `originweave-browser-session` as an independent Rust bounde 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. `DisposableContextDestroyRequest` remains opaque and is created only after exact presentation-authority validation. It carries Browser Session addressability, incarnation, and the exact stored handle. -12. `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. -13. `DisposableContextCreateError::CreateFailedClean` is valid only when no remote boundary exists. `CreateFailedUncertain(Option)` enters `RecoveryRequired`; any known isolation identity is preserved exactly. -14. 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. -15. `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. -16. 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. -17. 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. -18. `RecoveryRequired`, `TransportLost`, and `Ended` reject normal active-only lifecycle and authority operations. -19. `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. -20. `BoundBrowserSession

` implements a manual redacted `Debug` projection over inert Browser Session fields only. Formatting never calls `P::fmt` and never renders adapter-internal state. -21. `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. -22. 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. -23. Context epochs remain monotonic authority identities within one aggregate and also provide the create-attempt correlation allocated before remote create I/O. +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 @@ -89,6 +91,10 @@ Rejected as authority. It may be useful internally, but Browser Session could no 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. +### Hard-coded maximum length for `browser.UserContext` + +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. + ### Consuming `finish(self)` before validation 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`. @@ -117,6 +123,8 @@ A failed `finish()` leaves the same `BoundBrowserSession` usable for cleanup/rec 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 @@ -129,6 +137,7 @@ Required executable cases include: - 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; @@ -150,7 +159,7 @@ Consumers continue to bind once with `BrowserSession::bind_lifecycle_port(port)` 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, or adapter-local call order as an authorization boundary. +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 @@ -164,4 +173,4 @@ Supersede this ADR if the browser platform provides a complete, queryable, gener ## References -Browser Testing and Tools Working Group. (2026, August 24). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260824/ +Browser Testing and Tools Working Group. (2026, September 9). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/ From 9c17e7c8d6e491a2610e7cc5b48089a6980bfebe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 02:03:48 +0900 Subject: [PATCH 49/50] test(browser-session): track current WebDriver BiDi publication --- tests/test_browser_session_lifecycle_contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py index 00528d583..a37ea034a 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -218,7 +218,7 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: ) for token in ( "Status: Proposed", - "WD-webdriver-bidi-20260824", + "WD-webdriver-bidi-20260909", "RecoveryRequired", "RecoveryRequiredOwnedHandle", "BrowserSessionIncarnation", From f73cc5def267b99f43986cd3c504b86cb3d489d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 02:04:34 +0900 Subject: [PATCH 50/50] docs(browser-session): sync WebDriver BiDi trace to current publication --- .../browser-session-lifecycle-authority.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index ec0369cf3..7f91dc418 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -63,6 +63,8 @@ Stale or foreign authority returns `AuthorizedContextOperationError::BrowserSess 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. +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. + ## 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. @@ -77,11 +79,17 @@ Transport liveness is tracked independently from ownership recovery. A first tra 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. +## 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 latest W3C-published WebDriver BiDi Working Draft verified on 2026-09-11 is the 24 August 2026 publication. `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. A previously cited 9 September snapshot could not be verified in the W3C latest-published report or publication index and is therefore not treated as authoritative evidence. +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. + +Standards freshness and runtime qualification are separate controls. Updating this citation does not repin the separately qualified Chromium/WebDriver BiDi runtime revision. -OriginWeave does not treat those 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. +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 @@ -107,6 +115,7 @@ OriginWeave does not treat those protocol identifiers as policy authority or ass | 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` | @@ -121,4 +130,4 @@ This slice does not yet prove actual WebDriver BiDi lifecycle integration, obser ## Reference -Browser Testing and Tools Working Group. (2026, August 24). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260824/ +Browser Testing and Tools Working Group. (2026, September 9). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/