From 11fd28ec3cdb87118d7bbf88584a873cd9b48396 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:03:07 +0900 Subject: [PATCH 01/58] test: require Browser Session lifecycle authority boundary --- ...test_browser_session_lifecycle_contract.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/test_browser_session_lifecycle_contract.py diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py new file mode 100644 index 000000000..77f97526e --- /dev/null +++ b/tests/test_browser_session_lifecycle_contract.py @@ -0,0 +1,29 @@ +"""Repository contracts for Browser Session presentation authority.""" + +from __future__ import annotations + +import pathlib +import tomllib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +class BrowserSessionLifecycleContractTests(unittest.TestCase): + """Keep presentation mutation authority in an explicit Browser Session domain.""" + + def test_browser_session_is_an_independent_workspace_boundary(self) -> None: + """Browser Session authority must not be hidden in a driver adapter.""" + + workspace = tomllib.loads((ROOT / "Cargo.toml").read_text(encoding="utf-8")) + self.assertIn( + "crates/originweave-browser-session", + workspace["workspace"]["members"], + ) + self.assertTrue( + (ROOT / "crates/originweave-browser-session/src/lib.rs").is_file() + ) + + +if __name__ == "__main__": + unittest.main() From 8ceac2c8a9cc724bd02be528e656fcd0c4e93c95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:05:34 +0900 Subject: [PATCH 02/58] feat: add Browser Session domain crate --- crates/originweave-browser-session/Cargo.toml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 crates/originweave-browser-session/Cargo.toml diff --git a/crates/originweave-browser-session/Cargo.toml b/crates/originweave-browser-session/Cargo.toml new file mode 100644 index 000000000..bd5a146ea --- /dev/null +++ b/crates/originweave-browser-session/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "originweave-browser-session" +description = "OriginWeave Browser Session lifecycle and mutation-authority contracts." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +publish = false + +[dependencies] +originweave-core = { path = "../originweave-core" } + +[lints] +workspace = true From a8443bcb3550beccf8515e6e3279a18ac0a41ce1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:06:28 +0900 Subject: [PATCH 03/58] feat: model disposable Browser Session authority --- crates/originweave-browser-session/src/lib.rs | 585 ++++++++++++++++++ 1 file changed, 585 insertions(+) create mode 100644 crates/originweave-browser-session/src/lib.rs diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs new file mode 100644 index 000000000..30afb94ed --- /dev/null +++ b/crates/originweave-browser-session/src/lib.rs @@ -0,0 +1,585 @@ +//! Browser Session lifecycle authority for OriginWeave. +//! +//! This crate owns the domain transition that turns a newly created disposable +//! browser context into presentation-mutation authority. Driver identifiers remain +//! adapter data: naming a context is never sufficient to mint authority. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use std::collections::BTreeMap; + +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +/// Current lifecycle state of one Browser Session aggregate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserSessionState { + /// The session may create and own disposable contexts. + Active, + /// Every owned context was destroyed and the session was ended normally. + Ended, + /// The browser transport was lost; remaining contexts have uncertain cleanup state. + TransportLost, +} + +/// Domain failure while changing Browser Session ownership state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserSessionError { + /// The requested transition requires an active Browser Session. + SessionNotActive, + /// No unused context epoch remains, so no new authority can be issued safely. + EpochExhausted, + /// The disposable-context port could not create the requested isolated context. + ContextCreationFailed, + /// The port returned a browsing-context identity already known to this session. + DuplicateBrowsingContext, + /// The requested context is not currently owned and active in this session. + ContextNotOwned, + /// The supplied authority belongs to another session, context, or context epoch. + AuthorityMismatch, + /// The disposable-context port could not prove destruction of the owned context. + ContextDestructionFailed, + /// Normal session end was requested while an owned or uncertain context remains. + ActiveContextRemains, +} + +/// Bounded failure reported by the adapter port used for disposable context lifecycle I/O. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisposableContextPortError { + /// Creation of a fresh disposable context failed. + CreateFailed, + /// Destruction of an owned disposable context failed or could not be proven. + DestroyFailed, +} + +/// Port implemented by a reviewed browser adapter for disposable context lifecycle operations. +/// +/// `create_disposable_context` must create a fresh context owned exclusively by the supplied +/// Browser Session. An implementation that merely returns an existing/shared context violates this +/// port contract. `destroy_disposable_context` must return success only after the adapter has proved +/// that the task-owned disposable boundary is gone; a command acknowledgement alone is insufficient. +pub trait DisposableContextPort { + /// Create one fresh disposable context for the Browser Session. + fn create_disposable_context( + &mut self, + browser_session: BrowserSessionId, + ) -> Result; + + /// Destroy one context previously created through this port for the same Browser Session. + fn destroy_disposable_context( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + ) -> Result<(), DisposableContextPortError>; +} + +/// Monotonic identity for one owned browsing-context authority epoch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowserContextEpoch(u64); + +impl BrowserContextEpoch { + /// Return the internal monotonic epoch value. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// Opaque proof that Browser Session currently owns presentation mutation for one context epoch. +/// +/// The fields are private and no public constructor exists. A caller can obtain this value only after +/// the Browser Session aggregate has successfully created a disposable context through its lifecycle +/// port, or after that already-owned context advances to a new epoch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PresentationMutationAuthority { + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + context_epoch: BrowserContextEpoch, +} + +impl PresentationMutationAuthority { + /// Return the Browser Session that owns this authority. + #[must_use] + pub const fn browser_session(self) -> BrowserSessionId { + self.browser_session + } + + /// Return the owned browsing-context identity. + #[must_use] + pub const fn browsing_context(self) -> BrowsingContextId { + self.browsing_context + } + + /// Return the exact context epoch covered by this authority. + #[must_use] + pub const fn context_epoch(self) -> BrowserContextEpoch { + self.context_epoch + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OwnedContextState { + Active, + Destroyed, + Uncertain, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct OwnedContextRecord { + epoch: BrowserContextEpoch, + state: OwnedContextState, +} + +/// Aggregate root for disposable browser-context lifecycle and presentation mutation authority. +/// +/// The aggregate never accepts a remote/WebDriver context string as authority. A context enters the +/// owned set only through [`BrowserSession::create_disposable_context`], which invokes the lifecycle +/// port before minting an opaque [`PresentationMutationAuthority`]. +#[derive(Debug)] +pub struct BrowserSession { + id: BrowserSessionId, + state: BrowserSessionState, + next_epoch: u64, + contexts: BTreeMap, +} + +impl BrowserSession { + /// Start an active Browser Session around an already validated session identity. + #[must_use] + pub fn start(id: BrowserSessionId) -> Self { + Self { + id, + state: BrowserSessionState::Active, + next_epoch: 1, + contexts: BTreeMap::new(), + } + } + + /// Return this aggregate's stable browser-session identity. + #[must_use] + pub const fn id(&self) -> BrowserSessionId { + self.id + } + + /// Return the current aggregate lifecycle state. + #[must_use] + pub const fn state(&self) -> BrowserSessionState { + self.state + } + + /// Create and register one disposable context, then mint authority for its first epoch. + /// + /// Epoch capacity is reserved before external creation so an exhausted aggregate never creates an + /// untrackable context. A duplicate identity is rejected without attempting cleanup because a port + /// that violates the fresh-context contract may have returned another owner's existing context. + pub fn create_disposable_context( + &mut self, + port: &mut P, + ) -> Result { + self.require_active()?; + let epoch = self.reserve_epoch()?; + let browsing_context = port + .create_disposable_context(self.id) + .map_err(|_error| BrowserSessionError::ContextCreationFailed)?; + if self.contexts.contains_key(&browsing_context) { + return Err(BrowserSessionError::DuplicateBrowsingContext); + } + self.contexts.insert( + browsing_context, + OwnedContextRecord { + epoch, + state: OwnedContextState::Active, + }, + ); + Ok(self.authority_for(browsing_context, epoch)) + } + + /// Return current presentation authority for an already-owned active context. + /// + /// A raw context identity that was not created through this aggregate cannot enter the authority + /// path and fails closed with [`BrowserSessionError::ContextNotOwned`]. + pub fn presentation_authority( + &self, + browsing_context: BrowsingContextId, + ) -> 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(browsing_context, record.epoch)) + } + + /// Advance one active owned context to a new authority epoch. + /// + /// Navigation, renderer replacement, or another lifecycle boundary can call this transition to + /// invalidate every previously issued token while preserving disposable-context ownership. + pub fn advance_context_epoch( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + self.require_active()?; + let current = self + .contexts + .get(&browsing_context) + .copied() + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + let next = self.reserve_epoch()?; + let record = self + .contexts + .get_mut(&browsing_context) + .ok_or(BrowserSessionError::ContextNotOwned)?; + if record.epoch != current.epoch || record.state != OwnedContextState::Active { + return Err(BrowserSessionError::ContextNotOwned); + } + record.epoch = next; + Ok(self.authority_for(browsing_context, next)) + } + + /// Destroy the disposable context covered by the supplied exact-epoch authority. + /// + /// Failed or unproven destruction moves the context to an uncertain terminal state so its old + /// authority cannot be reused. OriginWeave does not interpret an adapter ACK as destruction proof. + pub fn destroy_disposable_context( + &mut self, + authority: PresentationMutationAuthority, + port: &mut P, + ) -> Result<(), BrowserSessionError> { + self.validate_authority(authority)?; + let result = port.destroy_disposable_context(self.id, authority.browsing_context); + let record = self + .contexts + .get_mut(&authority.browsing_context) + .ok_or(BrowserSessionError::ContextNotOwned)?; + match result { + Ok(()) => { + record.state = OwnedContextState::Destroyed; + Ok(()) + } + Err(_error) => { + record.state = OwnedContextState::Uncertain; + Err(BrowserSessionError::ContextDestructionFailed) + } + } + } + + /// Record browser transport loss and invalidate all still-active context authority. + /// + /// Returns `true` only for the first transition to `TransportLost`; repeated reports are idempotent. + pub fn record_transport_loss(&mut self) -> bool { + if self.state != BrowserSessionState::Active { + return false; + } + self.state = BrowserSessionState::TransportLost; + for record in self.contexts.values_mut() { + if record.state == OwnedContextState::Active { + record.state = OwnedContextState::Uncertain; + } + } + true + } + + /// End the Browser Session only after every owned context has proven destruction. + pub fn end(&mut self) -> Result<(), BrowserSessionError> { + self.require_active()?; + if self + .contexts + .values() + .any(|record| record.state != OwnedContextState::Destroyed) + { + return Err(BrowserSessionError::ActiveContextRemains); + } + self.state = BrowserSessionState::Ended; + Ok(()) + } + + fn require_active(&self) -> Result<(), BrowserSessionError> { + if self.state == BrowserSessionState::Active { + Ok(()) + } else { + Err(BrowserSessionError::SessionNotActive) + } + } + + fn reserve_epoch(&mut self) -> Result { + let epoch = BrowserContextEpoch(self.next_epoch); + self.next_epoch = self + .next_epoch + .checked_add(1) + .ok_or(BrowserSessionError::EpochExhausted)?; + Ok(epoch) + } + + fn authority_for( + &self, + browsing_context: BrowsingContextId, + context_epoch: BrowserContextEpoch, + ) -> PresentationMutationAuthority { + PresentationMutationAuthority { + browser_session: self.id, + browsing_context, + context_epoch, + } + } + + fn validate_authority( + &self, + authority: PresentationMutationAuthority, + ) -> Result<(), BrowserSessionError> { + self.require_active()?; + if authority.browser_session != self.id { + return Err(BrowserSessionError::AuthorityMismatch); + } + let record = self + .contexts + .get(&authority.browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + if record.epoch != authority.context_epoch { + return Err(BrowserSessionError::AuthorityMismatch); + } + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + + #[derive(Debug)] + struct TestPort { + next_context: BrowsingContextId, + fail_create: bool, + fail_destroy: bool, + create_calls: usize, + destroy_calls: usize, + } + + impl TestPort { + fn new(next_context: u64) -> Self { + Self { + next_context: BrowsingContextId::new(next_context).expect("valid context id"), + fail_create: false, + fail_destroy: false, + create_calls: 0, + destroy_calls: 0, + } + } + } + + impl DisposableContextPort for TestPort { + fn create_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + ) -> Result { + self.create_calls += 1; + if self.fail_create { + Err(DisposableContextPortError::CreateFailed) + } else { + Ok(self.next_context) + } + } + + fn destroy_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + _browsing_context: BrowsingContextId, + ) -> Result<(), DisposableContextPortError> { + self.destroy_calls += 1; + if self.fail_destroy { + Err(DisposableContextPortError::DestroyFailed) + } else { + Ok(()) + } + } + } + + fn session_id(value: u64) -> BrowserSessionId { + BrowserSessionId::new(value).expect("valid session id") + } + + fn context_id(value: u64) -> BrowsingContextId { + BrowsingContextId::new(value).expect("valid context id") + } + + #[test] + fn disposable_creation_is_the_only_raw_context_entry_to_authority() { + let mut session = BrowserSession::start(session_id(1)); + let mut port = TestPort::new(10); + + assert_eq!(session.id(), session_id(1)); + assert_eq!(session.state(), BrowserSessionState::Active); + assert_eq!( + session.presentation_authority(context_id(10)), + Err(BrowserSessionError::ContextNotOwned) + ); + + let authority = session + .create_disposable_context(&mut port) + .expect("owned disposable context"); + assert_eq!(port.create_calls, 1); + assert_eq!(authority.browser_session(), session_id(1)); + assert_eq!(authority.browsing_context(), context_id(10)); + assert_eq!(authority.context_epoch().value(), 1); + assert_eq!( + session.presentation_authority(context_id(10)), + Ok(authority) + ); + } + + #[test] + fn creation_failure_duplicate_and_epoch_exhaustion_fail_closed() { + let mut failed_session = BrowserSession::start(session_id(2)); + let mut failed_port = TestPort::new(20); + failed_port.fail_create = true; + assert_eq!( + failed_session.create_disposable_context(&mut failed_port), + Err(BrowserSessionError::ContextCreationFailed) + ); + + let mut duplicate_session = BrowserSession::start(session_id(3)); + let mut duplicate_port = TestPort::new(30); + duplicate_session + .create_disposable_context(&mut duplicate_port) + .expect("first owned context"); + assert_eq!( + duplicate_session.create_disposable_context(&mut duplicate_port), + Err(BrowserSessionError::DuplicateBrowsingContext) + ); + + let mut exhausted_session = BrowserSession::start(session_id(4)); + exhausted_session.next_epoch = u64::MAX; + let mut unused_port = TestPort::new(40); + assert_eq!( + exhausted_session.create_disposable_context(&mut unused_port), + Err(BrowserSessionError::EpochExhausted) + ); + assert_eq!(unused_port.create_calls, 0); + } + + #[test] + fn epoch_advance_invalidates_old_and_cross_session_authority() { + let mut session = BrowserSession::start(session_id(5)); + let mut port = TestPort::new(50); + let old = session + .create_disposable_context(&mut port) + .expect("owned context"); + let new = session + .advance_context_epoch(context_id(50)) + .expect("advanced epoch"); + assert_eq!(new.context_epoch().value(), 2); + assert_eq!( + session.destroy_disposable_context(old, &mut port), + Err(BrowserSessionError::AuthorityMismatch) + ); + + let mut foreign = BrowserSession::start(session_id(6)); + let mut foreign_port = TestPort::new(60); + foreign + .create_disposable_context(&mut foreign_port) + .expect("foreign context"); + assert_eq!( + foreign.destroy_disposable_context(new, &mut foreign_port), + Err(BrowserSessionError::AuthorityMismatch) + ); + + session + .destroy_disposable_context(new, &mut port) + .expect("destroy current epoch"); + assert_eq!(port.destroy_calls, 1); + assert_eq!( + session.presentation_authority(context_id(50)), + Err(BrowserSessionError::ContextNotOwned) + ); + assert_eq!( + session.advance_context_epoch(context_id(50)), + Err(BrowserSessionError::ContextNotOwned) + ); + } + + #[test] + fn destroy_failure_quarantines_authority_and_transport_loss_is_idempotent() { + let mut session = BrowserSession::start(session_id(7)); + let mut port = TestPort::new(70); + let authority = session + .create_disposable_context(&mut port) + .expect("owned context"); + port.fail_destroy = true; + assert_eq!( + session.destroy_disposable_context(authority, &mut port), + Err(BrowserSessionError::ContextDestructionFailed) + ); + assert_eq!(port.destroy_calls, 1); + assert_eq!( + session.presentation_authority(context_id(70)), + Err(BrowserSessionError::ContextNotOwned) + ); + assert_eq!( + session.end(), + Err(BrowserSessionError::ActiveContextRemains) + ); + assert!(session.record_transport_loss()); + assert!(!session.record_transport_loss()); + assert_eq!(session.state(), BrowserSessionState::TransportLost); + assert_eq!( + session.create_disposable_context(&mut port), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); + } + + #[test] + fn successful_destruction_is_required_before_normal_end() { + let mut session = BrowserSession::start(session_id(8)); + let mut port = TestPort::new(80); + let authority = session + .create_disposable_context(&mut port) + .expect("owned context"); + assert_eq!( + session.end(), + Err(BrowserSessionError::ActiveContextRemains) + ); + session + .destroy_disposable_context(authority, &mut port) + .expect("proven destruction"); + session.end().expect("all owned contexts destroyed"); + assert_eq!(session.state(), BrowserSessionState::Ended); + assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); + } + + #[test] + fn transport_loss_invalidates_still_active_contexts() { + let mut session = BrowserSession::start(session_id(9)); + let mut port = TestPort::new(90); + let authority = session + .create_disposable_context(&mut port) + .expect("owned context"); + assert!(session.record_transport_loss()); + assert_eq!( + session.destroy_disposable_context(authority, &mut port), + Err(BrowserSessionError::SessionNotActive) + ); + } + + #[test] + fn advance_context_epoch_rejects_unknown_and_exhausted_contexts() { + let mut session = BrowserSession::start(session_id(10)); + assert_eq!( + session.advance_context_epoch(context_id(100)), + Err(BrowserSessionError::ContextNotOwned) + ); + + let mut port = TestPort::new(101); + session + .create_disposable_context(&mut port) + .expect("owned context"); + session.next_epoch = u64::MAX; + assert_eq!( + session.advance_context_epoch(context_id(101)), + Err(BrowserSessionError::EpochExhausted) + ); + } +} From 0396ffb33695454af4c89f3885b4508317d6bb1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:06:59 +0900 Subject: [PATCH 04/58] build: register Browser Session workspace crate --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index aef0b7ee7..aec209447 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/originweave-tls", "crates/originweave-fingerprint", "crates/originweave-bidi", + "crates/originweave-browser-session", ] resolver = "3" From 4bb254e9b309aef515651fa2dc047e1246d38e7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:09:23 +0900 Subject: [PATCH 05/58] test: register Browser Session bounded context --- tests/test_repository_contract.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 44f1ffe41..818c14339 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -29,6 +29,7 @@ def test_workspace_declares_all_independently_reusable_crates(self) -> None: "crates/originweave-evidence", "crates/originweave-fingerprint", "crates/originweave-bidi", + "crates/originweave-browser-session", }, ) From abece5b7fbaf17363556ecf47d1bc9be4257afee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:11:17 +0900 Subject: [PATCH 06/58] build: lock Browser Session workspace package --- Cargo.lock | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index d67729593..affe1511a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -274,6 +274,13 @@ dependencies = [ "originweave-fingerprint", ] +[[package]] +name = "originweave-browser-session" +version = "0.1.0" +dependencies = [ + "originweave-core", +] + [[package]] name = "originweave-core" version = "0.1.0" @@ -627,7 +634,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043bef2f370cd654f4ea44b" [[package]] name = "windows-sys" From 7bf7aff9505b9de936df3ee457d1d51225e6a52f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:13:20 +0900 Subject: [PATCH 07/58] fix: preserve locked dependency checksum --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index affe1511a..f3e8da94d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +checksum = "1d30c53c26b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ "base64", "serde_core", @@ -361,7 +361,7 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" name = "proc-macro2" version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "985e7ec9bb745e6ce6535b544d84d6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -634,7 +634,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043bef2f370cd654f4ea44b" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "windows-sys" From 1dec4bb4a7fe8311f660c63b3fe341111ef7e271 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:15:14 +0900 Subject: [PATCH 08/58] fix: restore registry checksums in lockfile --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3e8da94d..c268c0ccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ "base64", "serde_core", @@ -361,7 +361,7 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" name = "proc-macro2" version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6f7ad8bd711c398938ae983b91a766d9" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] From 962d741f3ded1016a843bfa3f19ba40e128c533e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:16:03 +0900 Subject: [PATCH 09/58] docs: define disposable Browser Session authority --- ...er-session-disposable-context-authority.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/adr/0114-browser-session-disposable-context-authority.md diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md new file mode 100644 index 000000000..cc01fdcf0 --- /dev/null +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -0,0 +1,98 @@ +# ADR 0114: Browser Session disposable-context authority + +- Status: Proposed +- Date: 2026-09-10 + +## Context + +OriginWeave's WebDriver BiDi presentation adapter now requires opaque ownership witnesses before it can plan viewport/device-pixel-ratio, timezone, or screen-area mutation. That closes a dangerous adapter-level gap: a caller that merely knows a browsing-context identifier cannot overwrite another owner's presentation state and later clear it to an implementation default. + +The remaining gap is upstream of the adapter. A production Browser Session must establish why a context is exclusively OriginWeave-owned before any presentation-mutation authority can be issued. Without that lifecycle, a hidden constructor or driver shortcut would simply reintroduce ambient authority under a different type name. + +The 9 September 2026 WebDriver BiDi Working Draft provides a suitable standards-aligned isolation mechanism. `browser.createUserContext` creates a new user context. `browsingContext.create` can create a browsing context inside a selected user context. `browser.removeUserContext` closes that user context and every navigable in it without running `beforeunload` handlers. These protocol operations are adapter capabilities; they do not themselves define OriginWeave's domain ownership or prove cleanup merely because a command was acknowledged. + +## Decision drivers + +- A remote-issued browsing-context identifier is addressability, not mutation authority. +- Shared or attached human contexts must never acquire disposable-owner semantics by implication. +- Presentation reset must not destroy a predecessor override owned by another task/session. +- Navigation, renderer replacement, crash, cleanup failure, and transport loss must invalidate stale authority. +- The Browser Session domain must remain independent of WebDriver BiDi, CDP, MCP, and LLM policy decisions. +- An adapter acknowledgement is not a successful cleanup post-condition. + +## Assumptions and authority boundaries + +`originweave-browser-session` owns Browser Session lifecycle state, owned-context membership, monotonic context epochs, and opaque presentation-mutation authority. It consumes validated `BrowserSessionId` and `BrowsingContextId` values from `originweave-core`. + +A narrow `DisposableContextPort` is the anti-corruption boundary to a future browser adapter. The port may be implemented with WebDriver BiDi user contexts, a separately reviewed Chromium path, or another released adapter, but the adapter does not become the policy or ownership authority. + +The first implementation deliberately does not convert `PresentationMutationAuthority` into the WebDriver BiDi crate's private presentation/screen-area witnesses. That bridge belongs to a later integration slice after both sides' contracts are reviewed. It also does not claim real-Chromium cleanup evidence. + +## Options considered + +### A. Treat any known browsing context as owned + +Rejected. It recreates the original authority-confusion defect and allows one task to erase another task's predecessor state. + +### B. Snapshot every predecessor presentation override and restore it exactly + +Deferred. Exact predecessor capture can support reusable/attached contexts later, but today OriginWeave does not have a complete standard protocol snapshot for every governed presentation surface. Partial restoration would be a false safety claim. + +### C. Own a disposable isolated context lifecycle and issue opaque authority only after creation + +Selected for the first production slice. Isolation gives the aggregate a tractable ownership invariant and a clear terminal action: destruction of the task-owned context boundary. A future WebDriver BiDi adapter should normally map this to a fresh user context plus a browsing context created inside it, then remove the user context during cleanup. + +## Decision + +Introduce `originweave-browser-session` as an independent Rust bounded context with these invariants: + +1. `BrowserSession` is the aggregate root. It begins `Active` and may end normally only after every owned disposable context has proven destruction. +2. A context enters the aggregate's owned set only after `DisposableContextPort::create_disposable_context` succeeds. Supplying a raw `BrowsingContextId` never creates ownership. +3. Successful owned-context creation mints a non-caller-constructible `PresentationMutationAuthority` bound to the exact browser session, browsing context, and context epoch. +4. Advancing the context epoch invalidates previously issued authority. The adapter integration must use this transition at navigation/renderer lifecycle boundaries that invalidate the prior authority scope. +5. Destruction requires exact current authority. A stale, foreign-session, unknown, already-destroyed, or uncertain context fails closed. +6. If destruction cannot be proved, the context becomes `Uncertain` and its authority is invalidated. Normal session end is prohibited. +7. Transport loss moves the Browser Session to `TransportLost`, marks still-active owned contexts uncertain, and prevents further authority issuance. +8. Epoch sequence numbers are monotonic authority identities, not business counters; gaps are allowed after failed creation or rejected duplicate adapter output. + +## Consequences + +Browser Session ownership becomes a domain fact rather than an adapter convention. This gives the future BiDi/Chromium bridge a legitimate place to mint presentation witnesses without making raw driver identifiers authoritative. + +The first slice remains intentionally incomplete for buyer acceptance. No real Chromium user-context adapter, presentation-witness bridge, observed cleanup receipt, crash-recovery reconciliation, or #299 full browser replay is claimed here. + +## Failure and degraded behavior + +Creation failure produces no authority. A duplicate context returned by a supposedly fresh-context adapter is rejected and is not automatically destroyed, because destroying that identifier could target another owner's context. Destruction failure and transport loss quarantine the affected lifecycle rather than assuming cleanup. Once a Browser Session is `Ended` or `TransportLost`, creation, authority lookup, destruction, and normal end transitions that require an active session fail closed. + +## Security / privacy / governance impact + +Disposable context ownership reduces cross-task presentation-state interference and is compatible with isolated Agent Task profiles. It is not a substitute for Chromium sandboxing, egress policy, origin capability policy, Keyverse secret handling, or evidence retention controls. Those remain with their canonical owners. + +No page-controlled value, secret, provider/model choice, or LLM result can mint Browser Session authority. + +## Tests and acceptance evidence + +The owning crate tests hostile raw-context lookup, creation failure, duplicate adapter output, epoch exhaustion, stale authority, cross-session authority, cleanup failure, transport loss, unknown context, epoch advancement, and successful destroy-before-end behavior. Repository contracts require the bounded context to be a workspace member. + +Exact-head CI, Clippy, rustdoc, function/line/region/branch coverage and independent review remain required before integration. Real-browser acceptance is deferred to a later adapter slice and must prove creation, page-observed mutation, cleanup/destruction and post-cleanup isolation in pinned Chromium; command ACK alone is not success. + +## Migration and rollback + +This is additive. Until a reviewed adapter bridge consumes the new authority, existing presentation code remains fail closed behind its private ownership witnesses. Rollback removes the new crate, workspace/lockfile entries, tests and Proposed ADR without changing protected Chromium or central workflow policy. + +## Open follow-ups + +- Implement the WebDriver BiDi disposable-user-context adapter using the runtime-qualified protocol contract. +- Define the narrow conversion/ACL from `PresentationMutationAuthority` to BiDi presentation/screen-area ownership witnesses without exposing public constructors. +- Specify observed destruction/reconciliation after browser crash or transport loss. +- Replay #299 with three complete real-Chromium trials after the canonical sandbox/runtime owner path is usable. +- Evaluate exact predecessor capture/restore only if attached/reusable contexts become a buyer requirement. + +## Supersession / reversal conditions + +Supersede this ADR if WebDriver/Chromium gains a complete, queryable and exactly restorable predecessor-state contract for all governed presentation surfaces, or if OriginWeave adopts another isolation primitive with equivalent ownership and destruction evidence. Do not replace disposable ownership with raw context identity. + +## 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/ From 8a0f2cc1cdda1841a6063d2f4985173fd6420403 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:16:54 +0900 Subject: [PATCH 10/58] docs: trace Browser Session lifecycle authority --- .../browser-session-lifecycle-authority.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/traceability/browser-session-lifecycle-authority.md diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md new file mode 100644 index 000000000..aab178b5b --- /dev/null +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -0,0 +1,64 @@ +# Browser Session lifecycle authority trace + +- Status: IMPLEMENTED_ON_ACTIVE_PR +- Owning bounded context: `originweave-browser-session` +- Governing proposal: ADR 0114 +- Requirement owner: issue #312 +- Integration prerequisites: #229 presentation-ownership witnesses; canonical browser/sandbox owner path under #212/#148 + +## Problem and invariant + +A browsing-context identifier is an address. It is not evidence that the current Browser Session exclusively owns presentation mutation or cleanup for that context. + +The active implementation establishes one fail-closed chain: + +```text +validated BrowserSessionId +→ BrowserSession::start +→ DisposableContextPort creates a fresh task-owned context +→ aggregate records owned context + monotonic context epoch +→ opaque PresentationMutationAuthority(session, context, epoch) +→ exact-authority destruction request +→ adapter proves disposable boundary destruction +→ context state Destroyed +→ normal BrowserSession::end is admitted +``` + +A raw `BrowsingContextId`, stale epoch, foreign-session authority, unknown context, destruction failure, or lost transport cannot enter the successful chain. Destruction failure and transport loss invalidate active authority rather than treating a remote acknowledgement as cleanup evidence. + +## Standards trace + +The latest published WebDriver BiDi Working Draft at the time of this decision is 9 September 2026. Its browser module defines `browser.createUserContext`, whose remote-end algorithm creates a new user context. Its browsing-context create command accepts a `userContext`, enabling navigables to be created inside that isolated user context. `browser.removeUserContext` closes the selected user context and all navigables in it without running `beforeunload` handlers. + +OriginWeave does not copy those protocol concepts into the core domain. A future `DisposableContextPort` adapter may map them into the domain lifecycle, but it must additionally prove the post-condition expected by the port. A successful command ACK is insufficient evidence that the disposable boundary is actually gone. + +The active `originweave-bidi` adapter remains runtime-qualified against its separately documented 3 September 2026 revision. Tracking the 9 September publication here does not silently repin that runtime contract. + +## Source and executable evidence + +| Invariant | Source / test | +|---|---| +| independent Browser Session bounded context | `crates/originweave-browser-session/`; `tests/test_browser_session_lifecycle_contract.py` | +| raw context cannot mint authority | `BrowserSession::presentation_authority`; `disposable_creation_is_the_only_raw_context_entry_to_authority` | +| authority is session/context/epoch bound | `PresentationMutationAuthority`; `epoch_advance_invalidates_old_and_cross_session_authority` | +| adapter duplicate fails closed | `BrowserSession::create_disposable_context`; `creation_failure_duplicate_and_epoch_exhaustion_fail_closed` | +| cleanup failure invalidates authority | `BrowserSession::destroy_disposable_context`; `destroy_failure_quarantines_authority_and_transport_loss_is_idempotent` | +| transport loss invalidates active contexts | `BrowserSession::record_transport_loss`; `transport_loss_invalidates_still_active_contexts` | +| normal end requires proved destruction | `BrowserSession::end`; `successful_destruction_is_required_before_normal_end` | + +Exact-head CI/coverage is required before this dossier can be cited as verified active-PR implementation. Protected-main integration is required before any capability maturity is promoted beyond `IMPLEMENTED_ON_ACTIVE_PR`. + +## Buyer acceptance still open + +This slice does not yet prove: + +- actual WebDriver BiDi `browser.createUserContext`/`browsingContext.create` integration; +- conversion of domain authority into the BiDi presentation/screen-area private witnesses; +- pinned Chromium post-condition observation after presentation mutation; +- browser crash/restart reconciliation of uncertain disposable contexts; +- 3/3 complete #299 Agent Task browser trials; +- protected-main release, SBOM, provenance, reproducibility, or rollback evidence. + +## Reference + +Browser Testing and Tools Working Group. (2026, September 9). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/ From c5764ee828b823cea315821aa975988987059db5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:17:06 +0900 Subject: [PATCH 11/58] docs: diagram Browser Session authority lifecycle --- .../browser-session-lifecycle-authority.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/uml/browser-session-lifecycle-authority.md diff --git a/docs/uml/browser-session-lifecycle-authority.md b/docs/uml/browser-session-lifecycle-authority.md new file mode 100644 index 000000000..c1c095c77 --- /dev/null +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -0,0 +1,62 @@ +# Browser Session lifecycle authority + +This diagram describes the active-pr domain contract introduced for issue #312. It is not evidence that a WebDriver BiDi or Chromium adapter already implements the port. + +```mermaid +sequenceDiagram + autonumber + participant C as Application service + participant S as BrowserSession aggregate + participant P as DisposableContextPort + participant B as Browser adapter (planned) + + C->>S: start(valid BrowserSessionId) + C->>S: create_disposable_context(port) + S->>S: reserve monotonic context epoch + S->>P: create_disposable_context(session_id) + P->>B: create isolated disposable boundary + B-->>P: fresh BrowsingContextId + P-->>S: BrowsingContextId + S->>S: register owned Active context + S-->>C: opaque PresentationMutationAuthority + + Note over C,S: Raw BrowsingContextId alone cannot mint authority. + + C->>S: advance_context_epoch(context_id) + S->>S: replace epoch; old authority becomes stale + S-->>C: new opaque authority + + C->>S: destroy_disposable_context(authority, port) + S->>S: validate exact session/context/epoch + S->>P: destroy_disposable_context(session_id, context_id) + P->>B: destroy isolated disposable boundary + B-->>P: observed destruction post-condition + P-->>S: success + S->>S: context = Destroyed + C->>S: end() + S->>S: require every owned context Destroyed + S-->>C: Ended +``` + +## Failure state machine + +```mermaid +stateDiagram-v2 + [*] --> Active + Active --> Active: fresh context created / authority minted + Active --> Active: context epoch advanced / prior authority stale + Active --> Active: owned context destruction proved + Active --> Active: create rejected / no authority + Active --> Active: destroy fails / context becomes Uncertain + Active --> Ended: all owned contexts Destroyed + end + Active --> TransportLost: browser transport lost + Ended --> [*] + TransportLost --> [*] + + note right of Active + Normal end is rejected while any + Active or Uncertain context remains. + end note +``` + +`TransportLost` is terminal for this aggregate. Recovery of an uncertain remote browser boundary requires a separate reconciliation design; reopening the same aggregate would allow stale authority to regain meaning and is therefore not part of this slice. From 735dbccc84bafe5f6cb6a54f37d7d965b8ab1bc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:17:31 +0900 Subject: [PATCH 12/58] test: enforce Browser Session authority boundaries --- ...test_browser_session_lifecycle_contract.py | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py index 77f97526e..45cbcf436 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -7,6 +7,7 @@ import unittest ROOT = pathlib.Path(__file__).resolve().parents[1] +CRATE = ROOT / "crates/originweave-browser-session" class BrowserSessionLifecycleContractTests(unittest.TestCase): @@ -20,10 +21,48 @@ def test_browser_session_is_an_independent_workspace_boundary(self) -> None: "crates/originweave-browser-session", workspace["workspace"]["members"], ) - self.assertTrue( - (ROOT / "crates/originweave-browser-session/src/lib.rs").is_file() + package = tomllib.loads((CRATE / "Cargo.toml").read_text(encoding="utf-8")) + self.assertEqual( + package["dependencies"], + {"originweave-core": {"path": "../originweave-core"}}, ) + def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: + """A raw driver identifier must never become a caller-mintable authority token.""" + + source = (CRATE / "src/lib.rs").read_text(encoding="utf-8") + self.assertIn("pub struct BrowserSession", source) + self.assertIn("pub trait DisposableContextPort", source) + self.assertIn("pub struct PresentationMutationAuthority", source) + self.assertIn("create_disposable_context", source) + self.assertIn("advance_context_epoch", source) + self.assertIn("record_transport_loss", source) + + authority_impl = source.split("impl PresentationMutationAuthority", 1)[1].split( + "enum OwnedContextState", 1 + )[0] + self.assertNotIn("pub fn new", authority_impl) + self.assertNotIn("pub const fn new", authority_impl) + + def test_architecture_decision_and_traceability_are_explicit(self) -> None: + """Disposable ownership must remain a Proposed, standards-traced active-PR claim.""" + + adr = (ROOT / "docs/adr/0114-browser-session-disposable-context-authority.md").read_text( + encoding="utf-8" + ) + trace = (ROOT / "docs/traceability/browser-session-lifecycle-authority.md").read_text( + encoding="utf-8" + ) + uml = (ROOT / "docs/uml/browser-session-lifecycle-authority.md").read_text( + encoding="utf-8" + ) + self.assertIn("Status: Proposed", adr) + self.assertIn("WD-webdriver-bidi-20260909", adr) + self.assertIn("IMPLEMENTED_ON_ACTIVE_PR", trace) + self.assertIn("command ACK", trace) + self.assertIn("PresentationMutationAuthority", uml) + self.assertNotIn("IMPLEMENTED_ON_PROTECTED_MAIN", trace) + if __name__ == "__main__": unittest.main() From e7d34b9f0cd1c9215105d99fcf60b37253c0a353 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:18:18 +0900 Subject: [PATCH 13/58] docs: index Browser Session lifecycle ADR --- docs/adr/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/adr/README.md b/docs/adr/README.md index 25aa31c0c..2c492ba95 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -66,10 +66,11 @@ ADR 0013, ADR 0014, ADR 0110, ADR 0111, and ADR 0112 exist only on this document |---|---|---|---| | [0016](0016-bap-task-lifecycle-authority.md) | BAP task lifecycle and state authority | Proposed | BAP task states, transitions, recovery validation, transition sequencing, and authority separation | | [0113](0113-webdriver-bidi-screen-area-ownership.md) | WebDriver BiDi screen-area ownership witness | Proposed | Browser Session-owned screen-settings mutation, destructive reset boundary, and fail-closed adapter authority | +| [0114](0114-browser-session-disposable-context-authority.md) | Browser Session disposable-context authority | Proposed | owned disposable context lifecycle, exact context epochs, presentation mutation authority, cleanup uncertainty and transport-loss invalidation | -ADR 0016 belongs to the active BAP lifecycle feature branch. ADR 0113 belongs to the active WebDriver BiDi screen-area ownership successor. Indexing them makes the branch documentation graph complete while preserving Proposed lifecycle and active-PR, non-protected-main maturity. +ADR 0016 belongs to the active BAP lifecycle feature branch. ADR 0113 belongs to the active WebDriver BiDi screen-area ownership successor. ADR 0114 belongs to the Browser Session lifecycle successor for issue #312. Indexing them makes the branch documentation graph complete while preserving Proposed lifecycle and active-PR, non-protected-main maturity. -After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016 or ADR 0113 from Proposed or assert implementation maturity. +After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016, ADR 0113, or ADR 0114 from Proposed or assert implementation maturity. Other active feature PRs may contain additional Proposed ADRs. Those files are not part of this canonical documentation line until integrated or deliberately reconciled here. Historical PR checks, stale branch state, or chat decisions never transfer ADR acceptance across a changed head. From 08852df84d6ee2db06d56c9b263690e86b1f8783 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:19:40 +0900 Subject: [PATCH 14/58] refactor: make lifecycle branches causally reachable --- crates/originweave-browser-session/src/lib.rs | 75 ++++++++++--------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 30afb94ed..7b609d1a0 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -170,8 +170,9 @@ impl BrowserSession { /// Create and register one disposable context, then mint authority for its first epoch. /// /// Epoch capacity is reserved before external creation so an exhausted aggregate never creates an - /// untrackable context. A duplicate identity is rejected without attempting cleanup because a port - /// that violates the fresh-context contract may have returned another owner's existing context. + /// untrackable context. Epoch identifiers may therefore have gaps after failed creation or rejected + /// duplicate adapter output. A duplicate identity is rejected without attempting cleanup because a + /// port that violates the fresh-context contract may have returned another owner's existing context. pub fn create_disposable_context( &mut self, port: &mut P, @@ -214,26 +215,19 @@ impl BrowserSession { /// Advance one active owned context to a new authority epoch. /// /// Navigation, renderer replacement, or another lifecycle boundary can call this transition to - /// invalidate every previously issued token while preserving disposable-context ownership. + /// invalidate every previously issued token while preserving disposable-context ownership. Epoch + /// identifiers are monotonic authority identities rather than gap-free business counters. pub fn advance_context_epoch( &mut self, browsing_context: BrowsingContextId, ) -> Result { self.require_active()?; - let current = self - .contexts - .get(&browsing_context) - .copied() - .filter(|record| record.state == OwnedContextState::Active) - .ok_or(BrowserSessionError::ContextNotOwned)?; let next = self.reserve_epoch()?; let record = self .contexts .get_mut(&browsing_context) + .filter(|record| record.state == OwnedContextState::Active) .ok_or(BrowserSessionError::ContextNotOwned)?; - if record.epoch != current.epoch || record.state != OwnedContextState::Active { - return Err(BrowserSessionError::ContextNotOwned); - } record.epoch = next; Ok(self.authority_for(browsing_context, next)) } @@ -247,21 +241,24 @@ impl BrowserSession { authority: PresentationMutationAuthority, port: &mut P, ) -> Result<(), BrowserSessionError> { - self.validate_authority(authority)?; + let record = self.take_context_for_authority(authority)?; let result = port.destroy_disposable_context(self.id, authority.browsing_context); - let record = self - .contexts - .get_mut(&authority.browsing_context) - .ok_or(BrowserSessionError::ContextNotOwned)?; - match result { - Ok(()) => { - record.state = OwnedContextState::Destroyed; - Ok(()) - } - Err(_error) => { - record.state = OwnedContextState::Uncertain; - Err(BrowserSessionError::ContextDestructionFailed) - } + let state = if result.is_ok() { + OwnedContextState::Destroyed + } else { + OwnedContextState::Uncertain + }; + self.contexts.insert( + authority.browsing_context, + OwnedContextRecord { + epoch: record.epoch, + state, + }, + ); + if result.is_ok() { + Ok(()) + } else { + Err(BrowserSessionError::ContextDestructionFailed) } } @@ -324,23 +321,26 @@ impl BrowserSession { } } - fn validate_authority( - &self, + fn take_context_for_authority( + &mut self, authority: PresentationMutationAuthority, - ) -> Result<(), BrowserSessionError> { + ) -> Result { self.require_active()?; if authority.browser_session != self.id { return Err(BrowserSessionError::AuthorityMismatch); } - let record = self - .contexts - .get(&authority.browsing_context) - .filter(|record| record.state == OwnedContextState::Active) - .ok_or(BrowserSessionError::ContextNotOwned)?; + let Some(record) = self.contexts.remove(&authority.browsing_context) else { + return Err(BrowserSessionError::ContextNotOwned); + }; + if record.state != OwnedContextState::Active { + self.contexts.insert(authority.browsing_context, record); + return Err(BrowserSessionError::ContextNotOwned); + } if record.epoch != authority.context_epoch { + self.contexts.insert(authority.browsing_context, record); return Err(BrowserSessionError::AuthorityMismatch); } - Ok(()) + Ok(record) } } @@ -498,6 +498,11 @@ mod tests { session.advance_context_epoch(context_id(50)), Err(BrowserSessionError::ContextNotOwned) ); + assert_eq!( + session.destroy_disposable_context(new, &mut port), + Err(BrowserSessionError::ContextNotOwned) + ); + assert_eq!(port.destroy_calls, 1); } #[test] From 9146d62aa71a8e480dd6ec58258e2217c7d0b293 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:23:21 +0900 Subject: [PATCH 15/58] fix: close Browser Session authority edge paths --- crates/originweave-browser-session/src/lib.rs | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 7b609d1a0..2524e689c 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -7,7 +7,7 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] -use std::collections::BTreeMap; +use std::collections::{BTreeMap, btree_map::Entry}; use originweave_core::{BrowserSessionId, BrowsingContextId}; @@ -182,16 +182,15 @@ impl BrowserSession { let browsing_context = port .create_disposable_context(self.id) .map_err(|_error| BrowserSessionError::ContextCreationFailed)?; - if self.contexts.contains_key(&browsing_context) { - return Err(BrowserSessionError::DuplicateBrowsingContext); + match self.contexts.entry(browsing_context) { + Entry::Vacant(entry) => { + entry.insert(OwnedContextRecord { + epoch, + state: OwnedContextState::Active, + }); + } + Entry::Occupied(_) => return Err(BrowserSessionError::DuplicateBrowsingContext), } - self.contexts.insert( - browsing_context, - OwnedContextRecord { - epoch, - state: OwnedContextState::Active, - }, - ); Ok(self.authority_for(browsing_context, epoch)) } @@ -243,10 +242,12 @@ impl BrowserSession { ) -> Result<(), BrowserSessionError> { let record = self.take_context_for_authority(authority)?; let result = port.destroy_disposable_context(self.id, authority.browsing_context); - let state = if result.is_ok() { - OwnedContextState::Destroyed - } else { - OwnedContextState::Uncertain + let (state, outcome) = match result { + Ok(()) => (OwnedContextState::Destroyed, Ok(())), + Err(_error) => ( + OwnedContextState::Uncertain, + Err(BrowserSessionError::ContextDestructionFailed), + ), }; self.contexts.insert( authority.browsing_context, @@ -255,11 +256,7 @@ impl BrowserSession { state, }, ); - if result.is_ok() { - Ok(()) - } else { - Err(BrowserSessionError::ContextDestructionFailed) - } + outcome } /// Record browser transport loss and invalidate all still-active context authority. @@ -505,6 +502,23 @@ mod tests { assert_eq!(port.destroy_calls, 1); } + #[test] + fn unknown_internal_authority_cannot_trigger_destroy_io() { + let mut session = BrowserSession::start(session_id(11)); + let mut port = TestPort::new(110); + let unknown = PresentationMutationAuthority { + browser_session: session_id(11), + browsing_context: context_id(111), + context_epoch: BrowserContextEpoch(1), + }; + + assert_eq!( + session.destroy_disposable_context(unknown, &mut port), + Err(BrowserSessionError::ContextNotOwned) + ); + assert_eq!(port.destroy_calls, 0); + } + #[test] fn destroy_failure_quarantines_authority_and_transport_loss_is_idempotent() { let mut session = BrowserSession::start(session_id(7)); From 797157a02547e7054e6290090d148483cc7ad560 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 13:25:47 +0900 Subject: [PATCH 16/58] docs: make Browser Session boundary code-current --- ARCHITECTURE.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d6ac5750b..6a0381737 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -149,10 +149,15 @@ surfaces do not silently fall back to ambient host values. Owns the narrow WebDriver BiDi adapter contract that is expressible by one explicit specification revision. The active slice pins the W3C WebDriver BiDi Working Draft published on 3 September 2026 at `https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/` and delegates complete-profile admission back to `originweave-fingerprint`. Standard BiDi covers viewport, device-pixel-ratio, timezone, and reduced-motion surfaces. Its width/height screen command cannot prove the kernel's complete screen-and-color-depth surface, and its single locale cannot prove ordered language preferences; hardware concurrency and the complete Chromium platform/User-Agent Client Hints surface also remain outside the standard set. The adapter therefore fails first on `Screen` rather than inheriting ambient Chromium values. It can plan two typed reusable-context commands—viewport/DPR and timezone—for one bounded opaque browsing-context identifier. Reduced motion remains an expressible protocol capability, but the reusable plan does not install it because `features: null` removes the target's complete media-feature override configuration rather than restoring prior state. Generic cleanup therefore resets only viewport/DPR and timezone. No caller-mintable exclusive-reset type is exposed; a Browser Session owner must instead prove a disposable context lifecycle or restore the complete prior media configuration. Planning sends nothing and proves neither acknowledgement, cleanup, ownership, nor page-visible state. Transport, post-condition observation, and reusable-context media restoration require the pinned Chromium/BiDi path and, for Chromium-only surfaces, a separate versioned `originweave-cdp` adapter. +### `originweave-browser-session` (active PR) + +Owns the Browser Session aggregate boundary for disposable context lifecycle and presentation-mutation authority. A raw `BrowsingContextId` is addressability only. A context enters the owned set only after a narrow `DisposableContextPort` reports creation of a fresh task-owned disposable boundary. The aggregate then issues an opaque authority bound to the exact browser session, browsing context, and monotonic context epoch. Stale or foreign authority fails closed; failed destruction makes the context uncertain; browser transport loss invalidates active authority; and normal session end is rejected until every owned context has proven destruction. + +This active slice deliberately stops before browser transport. WebDriver BiDi/CDP remain adapters and do not mint policy authority. The current proposal does not yet bridge domain authority into `originweave-bidi`'s private presentation/screen-area witnesses, implement `browser.createUserContext`/`browsingContext.create`/`browser.removeUserContext`, prove a cleanup post-condition in Chromium, or establish protected-main behavior. ADR 0114, the Browser Session traceability dossier, and the lifecycle UML record those remaining boundaries. + ## 6. Planned modules ```text -originweave-session isolated browser contexts and checkpoints originweave-proxy separately approved proxy and final-target routing originweave-http request, response, redirect, and elapsed-time budgets originweave-observation AX + DOM + layout + network semantic snapshots From 0589120923a30b8c5dfef9377d7145300bab36bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 14:10:44 +0900 Subject: [PATCH 17/58] docs: index Browser Session ADR 0114 --- docs/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/README.md b/docs/README.md index fd2c19ec9..70dcf389c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,8 +47,8 @@ The PRD/TRD/Architecture/ADR/UML/ERD/data-governance/traceability/security/opera - [Resolved-destination policy implementation plan](superpowers/plans/2026-08-06-resolved-destination-policy.md) - [Direct socket binding design](superpowers/specs/2026-08-06-direct-socket-binding-design.md) - [Direct socket binding implementation plan](superpowers/plans/2026-08-06-direct-socket-binding.md) -- [TLS service-identity design](superpowers/specs/2026-08-06-tls-server-identity-design.md) -- [TLS service-identity implementation plan](superpowers/plans/2026-08-06-tls-server-identity.md) +- [TLS service-identity design](superpowers/specs/2026-08-06-tls-service-identity-design.md) +- [TLS service-identity implementation plan](superpowers/plans/2026-08-06-tls-service-identity.md) ## Accepted protected-main architecture decisions @@ -94,9 +94,10 @@ The second group exists only on this documentation branch until the branch integ - [ADR 0016: BAP task lifecycle and state authority](adr/0016-bap-task-lifecycle-authority.md) - [ADR 0113: WebDriver BiDi screen-area ownership witness](adr/0113-webdriver-bidi-screen-area-ownership.md) +- [ADR 0114: Browser Session disposable-context authority](adr/0114-browser-session-disposable-context-authority.md) -ADR 0016 is owned by the active BAP lifecycle feature branch. ADR 0113 is owned by the active WebDriver BiDi screen-area ownership successor. Their presence here makes the branch documentation graph complete without presenting either decision or implementation as protected-main truth before integration. +ADR 0016 is owned by the active BAP lifecycle feature branch. ADR 0113 is owned by the active WebDriver BiDi screen-area ownership successor. ADR 0114 is owned by the active Browser Session lifecycle successor. Their presence here makes the branch documentation graph complete without presenting any decision or implementation as protected-main truth before integration. -After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016 or ADR 0113 from Proposed or assert implementation maturity. +After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016, ADR 0113, or ADR 0114 from Proposed or assert implementation maturity. See the [ADR index](adr/README.md) for status rules, required decision structure, supersession rules, and active feature ADRs. The index and each ADR's own status metadata must agree; a PR body, chat transcript, automation prompt, or stale issue reference cannot change ADR status. From 0c6605ec5d545e11fd060e678b246a4353973288 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 14:11:48 +0900 Subject: [PATCH 18/58] fix(browser-session): bind authority to disposable isolation --- crates/originweave-browser-session/src/lib.rs | 409 +++++++++++++----- 1 file changed, 305 insertions(+), 104 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 2524e689c..7e7ef86bc 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -1,13 +1,13 @@ //! Browser Session lifecycle authority for OriginWeave. //! //! This crate owns the domain transition that turns a newly created disposable -//! browser context into presentation-mutation authority. Driver identifiers remain -//! adapter data: naming a context is never sufficient to mint authority. +//! browser isolation boundary into presentation-mutation authority. Driver identifiers +//! remain adapter data: naming a session or browsing context is never sufficient to mint authority. #![forbid(unsafe_code)] #![deny(missing_docs)] -use std::collections::{BTreeMap, btree_map::Entry}; +use std::collections::BTreeMap; use originweave_core::{BrowserSessionId, BrowsingContextId}; @@ -31,13 +31,15 @@ pub enum BrowserSessionError { EpochExhausted, /// The disposable-context port could not create the requested isolated context. ContextCreationFailed, - /// The port returned a browsing-context identity already known to this session. + /// The port returned a browsing-context identity already known to this aggregate. DuplicateBrowsingContext, + /// The port returned an isolation identity already known to this aggregate. + DuplicateDisposableIsolation, /// The requested context is not currently owned and active in this session. ContextNotOwned, - /// The supplied authority belongs to another session, context, or context epoch. + /// The supplied authority belongs to another isolation boundary, session, context, or epoch. AuthorityMismatch, - /// The disposable-context port could not prove destruction of the owned context. + /// The disposable-context port could not prove destruction of the owned isolation boundary. ContextDestructionFailed, /// Normal session end was requested while an owned or uncertain context remains. ActiveContextRemains, @@ -52,24 +54,105 @@ pub enum DisposableContextPortError { DestroyFailed, } +/// Validation failure for a browser-issued disposable isolation identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisposableIsolationIdError { + /// The identity is empty. + Empty, + /// The identity exceeds the bounded adapter evidence size. + TooLong, + /// The identity contains surrounding whitespace or control characters. + InvalidCharacter, +} + +/// Browser-issued identity for one disposable isolation boundary. +/// +/// This value is addressability, not mutation authority. A conforming adapter must return a value +/// that is non-aliasing for the live lifetime of the created boundary. A WebDriver BiDi adapter +/// should map this one-to-one to the specification-defined unique user-context identifier. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DisposableIsolationId(String); + +impl DisposableIsolationId { + /// Parse one bounded browser-issued isolation identity. + pub fn parse(value: &str) -> Result { + if value.is_empty() { + return Err(DisposableIsolationIdError::Empty); + } + if value.len() > 4096 { + return Err(DisposableIsolationIdError::TooLong); + } + if value.trim() != value || value.chars().any(char::is_control) { + return Err(DisposableIsolationIdError::InvalidCharacter); + } + Ok(Self(value.to_owned())) + } + + /// Return the validated browser-issued isolation identity. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Adapter result for one newly created disposable browser context. +/// +/// The isolation identity scopes the lifecycle boundary used for destruction; the browsing-context +/// identity addresses the independently navigable context inside that boundary. Neither field alone +/// is presentation-mutation authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisposableContextHandle { + isolation: DisposableIsolationId, + browsing_context: BrowsingContextId, +} + +impl DisposableContextHandle { + /// Bind one validated isolation identity to its created browsing context. + #[must_use] + pub fn new(isolation: DisposableIsolationId, browsing_context: BrowsingContextId) -> Self { + Self { + isolation, + browsing_context, + } + } + + /// Return the non-aliasing disposable isolation identity. + #[must_use] + pub fn isolation(&self) -> &DisposableIsolationId { + &self.isolation + } + + /// Return the browsing-context address inside the disposable boundary. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.browsing_context + } +} + /// Port implemented by a reviewed browser adapter for disposable context lifecycle operations. /// -/// `create_disposable_context` must create a fresh context owned exclusively by the supplied -/// Browser Session. An implementation that merely returns an existing/shared context violates this -/// port contract. `destroy_disposable_context` must return success only after the adapter has proved -/// that the task-owned disposable boundary is gone; a command acknowledgement alone is insufficient. +/// `create_disposable_context` must create a fresh isolation boundary and context owned exclusively +/// by the supplied Browser Session. The returned [`DisposableIsolationId`] must be non-aliasing for +/// the lifetime of that boundary; for WebDriver BiDi this means a one-to-one mapping to the unique +/// user-context identifier returned by `browser.createUserContext`. An implementation that merely +/// returns an existing/shared context violates this port contract. +/// +/// `destroy_disposable_context` must destroy the exact isolation boundary carried by the supplied +/// handle and return success only after the adapter has proved that the task-owned boundary is gone. +/// Reconstructing cleanup authority from `(BrowserSessionId, BrowsingContextId)` is forbidden, and a +/// command acknowledgement alone is insufficient destruction evidence. pub trait DisposableContextPort { - /// Create one fresh disposable context for the Browser Session. + /// Create one fresh disposable isolation boundary and browsing context for the Browser Session. fn create_disposable_context( &mut self, browser_session: BrowserSessionId, - ) -> Result; + ) -> Result; - /// Destroy one context previously created through this port for the same Browser Session. + /// Destroy the exact disposable isolation boundary represented by this handle. fn destroy_disposable_context( &mut self, browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, + context: &DisposableContextHandle, ) -> Result<(), DisposableContextPortError>; } @@ -88,31 +171,40 @@ impl BrowserContextEpoch { /// Opaque proof that Browser Session currently owns presentation mutation for one context epoch. /// /// The fields are private and no public constructor exists. A caller can obtain this value only after -/// the Browser Session aggregate has successfully created a disposable context through its lifecycle -/// port, or after that already-owned context advances to a new epoch. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// the Browser Session aggregate has successfully created a disposable isolation boundary through its +/// lifecycle port, or after that already-owned context advances to a new epoch. The isolation identity +/// prevents two aggregate incarnations that reuse external session/context identifiers from aliasing +/// each other's mutation or destruction authority when their disposable boundaries are distinct. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct PresentationMutationAuthority { browser_session: BrowserSessionId, + isolation: DisposableIsolationId, browsing_context: BrowsingContextId, context_epoch: BrowserContextEpoch, } impl PresentationMutationAuthority { - /// Return the Browser Session that owns this authority. + /// Return the Browser Session transport identity associated with this authority. #[must_use] - pub const fn browser_session(self) -> BrowserSessionId { + pub const fn browser_session(&self) -> BrowserSessionId { self.browser_session } + /// Return the owned disposable isolation identity. + #[must_use] + pub fn isolation(&self) -> &DisposableIsolationId { + &self.isolation + } + /// Return the owned browsing-context identity. #[must_use] - pub const fn browsing_context(self) -> BrowsingContextId { + pub const fn browsing_context(&self) -> BrowsingContextId { self.browsing_context } /// Return the exact context epoch covered by this authority. #[must_use] - pub const fn context_epoch(self) -> BrowserContextEpoch { + pub const fn context_epoch(&self) -> BrowserContextEpoch { self.context_epoch } } @@ -124,8 +216,9 @@ enum OwnedContextState { Uncertain, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] struct OwnedContextRecord { + handle: DisposableContextHandle, epoch: BrowserContextEpoch, state: OwnedContextState, } @@ -144,7 +237,11 @@ pub struct BrowserSession { } impl BrowserSession { - /// Start an active Browser Session around an already validated session identity. + /// Start an active Browser Session around an already validated transport session identity. + /// + /// The transport identity may be reused by a later aggregate incarnation; it therefore does not + /// participate alone in disposable ownership. Per-context authority additionally carries the + /// adapter-proved non-aliasing isolation identity. #[must_use] pub fn start(id: BrowserSessionId) -> Self { Self { @@ -155,7 +252,7 @@ impl BrowserSession { } } - /// Return this aggregate's stable browser-session identity. + /// Return this aggregate's browser-session transport identity. #[must_use] pub const fn id(&self) -> BrowserSessionId { self.id @@ -171,27 +268,40 @@ impl BrowserSession { /// /// Epoch capacity is reserved before external creation so an exhausted aggregate never creates an /// untrackable context. Epoch identifiers may therefore have gaps after failed creation or rejected - /// duplicate adapter output. A duplicate identity is rejected without attempting cleanup because a - /// port that violates the fresh-context contract may have returned another owner's existing context. + /// duplicate adapter output. Duplicate browser or isolation identities are rejected without cleanup + /// because a port that violates the fresh-boundary contract may have returned another owner's state. pub fn create_disposable_context( &mut self, port: &mut P, ) -> Result { self.require_active()?; let epoch = self.reserve_epoch()?; - let browsing_context = port + let handle = port .create_disposable_context(self.id) .map_err(|_error| BrowserSessionError::ContextCreationFailed)?; - match self.contexts.entry(browsing_context) { - Entry::Vacant(entry) => { - entry.insert(OwnedContextRecord { - epoch, - state: OwnedContextState::Active, - }); - } - Entry::Occupied(_) => return Err(BrowserSessionError::DuplicateBrowsingContext), + + if self + .contexts + .values() + .any(|record| record.handle.isolation == handle.isolation) + { + return Err(BrowserSessionError::DuplicateDisposableIsolation); + } + if self.contexts.contains_key(&handle.browsing_context) { + return Err(BrowserSessionError::DuplicateBrowsingContext); } - Ok(self.authority_for(browsing_context, epoch)) + + let browsing_context = handle.browsing_context; + let authority = Self::authority_for(self.id, &handle, epoch); + self.contexts.insert( + browsing_context, + OwnedContextRecord { + handle, + epoch, + state: OwnedContextState::Active, + }, + ); + Ok(authority) } /// Return current presentation authority for an already-owned active context. @@ -208,7 +318,7 @@ impl BrowserSession { .get(&browsing_context) .filter(|record| record.state == OwnedContextState::Active) .ok_or(BrowserSessionError::ContextNotOwned)?; - Ok(self.authority_for(browsing_context, record.epoch)) + Ok(Self::authority_for(self.id, &record.handle, record.epoch)) } /// Advance one active owned context to a new authority epoch. @@ -228,35 +338,35 @@ impl BrowserSession { .filter(|record| record.state == OwnedContextState::Active) .ok_or(BrowserSessionError::ContextNotOwned)?; record.epoch = next; - Ok(self.authority_for(browsing_context, next)) + Ok(Self::authority_for(self.id, &record.handle, next)) } - /// Destroy the disposable context covered by the supplied exact-epoch authority. + /// Destroy the disposable isolation boundary covered by the supplied exact-epoch authority. /// - /// Failed or unproven destruction moves the context to an uncertain terminal state so its old - /// authority cannot be reused. OriginWeave does not interpret an adapter ACK as destruction proof. + /// Authority is validated before any adapter I/O. Failed or unproven destruction moves the + /// context to an uncertain terminal state so its old authority cannot be reused. OriginWeave does + /// not interpret an adapter ACK as destruction proof. pub fn destroy_disposable_context( &mut self, - authority: PresentationMutationAuthority, + authority: &PresentationMutationAuthority, port: &mut P, ) -> Result<(), BrowserSessionError> { - let record = self.take_context_for_authority(authority)?; - let result = port.destroy_disposable_context(self.id, authority.browsing_context); - let (state, outcome) = match result { - Ok(()) => (OwnedContextState::Destroyed, Ok(())), - Err(_error) => ( - OwnedContextState::Uncertain, - Err(BrowserSessionError::ContextDestructionFailed), - ), - }; - self.contexts.insert( - authority.browsing_context, - OwnedContextRecord { - epoch: record.epoch, - state, - }, - ); - outcome + let handle = self.context_for_authority(authority)?.handle.clone(); + let result = port.destroy_disposable_context(self.id, &handle); + let record = self + .contexts + .get_mut(&authority.browsing_context) + .ok_or(BrowserSessionError::ContextNotOwned)?; + match result { + Ok(()) => { + record.state = OwnedContextState::Destroyed; + Ok(()) + } + Err(_error) => { + record.state = OwnedContextState::Uncertain; + Err(BrowserSessionError::ContextDestructionFailed) + } + } } /// Record browser transport loss and invalidate all still-active context authority. @@ -307,34 +417,32 @@ impl BrowserSession { } fn authority_for( - &self, - browsing_context: BrowsingContextId, + browser_session: BrowserSessionId, + handle: &DisposableContextHandle, context_epoch: BrowserContextEpoch, ) -> PresentationMutationAuthority { PresentationMutationAuthority { - browser_session: self.id, - browsing_context, + browser_session, + isolation: handle.isolation.clone(), + browsing_context: handle.browsing_context, context_epoch, } } - fn take_context_for_authority( - &mut self, - authority: PresentationMutationAuthority, - ) -> Result { + fn context_for_authority( + &self, + authority: &PresentationMutationAuthority, + ) -> Result<&OwnedContextRecord, BrowserSessionError> { self.require_active()?; if authority.browser_session != self.id { return Err(BrowserSessionError::AuthorityMismatch); } - let Some(record) = self.contexts.remove(&authority.browsing_context) else { - return Err(BrowserSessionError::ContextNotOwned); - }; - if record.state != OwnedContextState::Active { - self.contexts.insert(authority.browsing_context, record); - return Err(BrowserSessionError::ContextNotOwned); - } - if record.epoch != authority.context_epoch { - self.contexts.insert(authority.browsing_context, record); + let record = self + .contexts + .get(&authority.browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + if record.epoch != authority.context_epoch || record.handle.isolation != authority.isolation { return Err(BrowserSessionError::AuthorityMismatch); } Ok(record) @@ -348,21 +456,26 @@ mod tests { #[derive(Debug)] struct TestPort { - next_context: BrowsingContextId, + next_handle: DisposableContextHandle, fail_create: bool, fail_destroy: bool, create_calls: usize, destroy_calls: usize, + destroyed_isolations: Vec, } impl TestPort { - fn new(next_context: u64) -> Self { + fn new(context: u64, isolation: &str) -> Self { Self { - next_context: BrowsingContextId::new(next_context).expect("valid context id"), + next_handle: DisposableContextHandle::new( + isolation_id(isolation), + context_id(context), + ), fail_create: false, fail_destroy: false, create_calls: 0, destroy_calls: 0, + destroyed_isolations: Vec::new(), } } } @@ -371,21 +484,22 @@ mod tests { fn create_disposable_context( &mut self, _browser_session: BrowserSessionId, - ) -> Result { + ) -> Result { self.create_calls += 1; if self.fail_create { Err(DisposableContextPortError::CreateFailed) } else { - Ok(self.next_context) + Ok(self.next_handle.clone()) } } fn destroy_disposable_context( &mut self, _browser_session: BrowserSessionId, - _browsing_context: BrowsingContextId, + context: &DisposableContextHandle, ) -> Result<(), DisposableContextPortError> { self.destroy_calls += 1; + self.destroyed_isolations.push(context.isolation.clone()); if self.fail_destroy { Err(DisposableContextPortError::DestroyFailed) } else { @@ -402,10 +516,36 @@ mod tests { BrowsingContextId::new(value).expect("valid context id") } + fn isolation_id(value: &str) -> DisposableIsolationId { + DisposableIsolationId::parse(value).expect("valid isolation id") + } + + #[test] + fn isolation_identity_validation_is_bounded() { + assert_eq!( + DisposableIsolationId::parse(""), + Err(DisposableIsolationIdError::Empty) + ); + assert_eq!( + DisposableIsolationId::parse(&"x".repeat(4097)), + Err(DisposableIsolationIdError::TooLong) + ); + assert_eq!( + DisposableIsolationId::parse(" user-context "), + Err(DisposableIsolationIdError::InvalidCharacter) + ); + assert_eq!( + DisposableIsolationId::parse("user\ncontext"), + Err(DisposableIsolationIdError::InvalidCharacter) + ); + let valid = isolation_id("webdriver-user-context-10"); + assert_eq!(valid.as_str(), "webdriver-user-context-10"); + } + #[test] fn disposable_creation_is_the_only_raw_context_entry_to_authority() { let mut session = BrowserSession::start(session_id(1)); - let mut port = TestPort::new(10); + let mut port = TestPort::new(10, "isolation-10"); assert_eq!(session.id(), session_id(1)); assert_eq!(session.state(), BrowserSessionState::Active); @@ -419,6 +559,7 @@ mod tests { .expect("owned disposable context"); assert_eq!(port.create_calls, 1); assert_eq!(authority.browser_session(), session_id(1)); + 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!( @@ -428,9 +569,9 @@ mod tests { } #[test] - fn creation_failure_duplicate_and_epoch_exhaustion_fail_closed() { + fn creation_failure_duplicate_ids_and_epoch_exhaustion_fail_closed() { let mut failed_session = BrowserSession::start(session_id(2)); - let mut failed_port = TestPort::new(20); + let mut failed_port = TestPort::new(20, "isolation-20"); failed_port.fail_create = true; assert_eq!( failed_session.create_disposable_context(&mut failed_port), @@ -438,18 +579,24 @@ mod tests { ); let mut duplicate_session = BrowserSession::start(session_id(3)); - let mut duplicate_port = TestPort::new(30); + let mut first_port = TestPort::new(30, "isolation-30-a"); duplicate_session - .create_disposable_context(&mut duplicate_port) + .create_disposable_context(&mut first_port) .expect("first owned context"); + let mut duplicate_context = TestPort::new(30, "isolation-30-b"); assert_eq!( - duplicate_session.create_disposable_context(&mut duplicate_port), + duplicate_session.create_disposable_context(&mut duplicate_context), Err(BrowserSessionError::DuplicateBrowsingContext) ); + let mut duplicate_isolation = TestPort::new(31, "isolation-30-a"); + assert_eq!( + duplicate_session.create_disposable_context(&mut duplicate_isolation), + Err(BrowserSessionError::DuplicateDisposableIsolation) + ); let mut exhausted_session = BrowserSession::start(session_id(4)); exhausted_session.next_epoch = u64::MAX; - let mut unused_port = TestPort::new(40); + let mut unused_port = TestPort::new(40, "isolation-40"); assert_eq!( exhausted_session.create_disposable_context(&mut unused_port), Err(BrowserSessionError::EpochExhausted) @@ -460,7 +607,7 @@ mod tests { #[test] fn epoch_advance_invalidates_old_and_cross_session_authority() { let mut session = BrowserSession::start(session_id(5)); - let mut port = TestPort::new(50); + let mut port = TestPort::new(50, "isolation-50"); let old = session .create_disposable_context(&mut port) .expect("owned context"); @@ -469,22 +616,22 @@ mod tests { .expect("advanced epoch"); assert_eq!(new.context_epoch().value(), 2); assert_eq!( - session.destroy_disposable_context(old, &mut port), + session.destroy_disposable_context(&old, &mut port), Err(BrowserSessionError::AuthorityMismatch) ); let mut foreign = BrowserSession::start(session_id(6)); - let mut foreign_port = TestPort::new(60); + let mut foreign_port = TestPort::new(60, "isolation-60"); foreign .create_disposable_context(&mut foreign_port) .expect("foreign context"); assert_eq!( - foreign.destroy_disposable_context(new, &mut foreign_port), + foreign.destroy_disposable_context(&new, &mut foreign_port), Err(BrowserSessionError::AuthorityMismatch) ); session - .destroy_disposable_context(new, &mut port) + .destroy_disposable_context(&new, &mut port) .expect("destroy current epoch"); assert_eq!(port.destroy_calls, 1); assert_eq!( @@ -496,24 +643,61 @@ mod tests { Err(BrowserSessionError::ContextNotOwned) ); assert_eq!( - session.destroy_disposable_context(new, &mut port), + session.destroy_disposable_context(&new, &mut port), Err(BrowserSessionError::ContextNotOwned) ); assert_eq!(port.destroy_calls, 1); } + #[test] + fn two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary() { + let shared_session = session_id(12); + let shared_context = context_id(120); + let mut session_a = BrowserSession::start(shared_session); + let mut session_b = BrowserSession::start(shared_session); + let mut port_a = TestPort::new(120, "user-context-a"); + let mut port_b = TestPort::new(120, "user-context-b"); + + let authority_a = session_a + .create_disposable_context(&mut port_a) + .expect("owner A context"); + let authority_b = session_b + .create_disposable_context(&mut port_b) + .expect("owner B context"); + assert_eq!(authority_a.browsing_context(), shared_context); + assert_eq!(authority_b.browsing_context(), shared_context); + assert_ne!(authority_a.isolation(), authority_b.isolation()); + + assert_eq!( + session_b.destroy_disposable_context(&authority_a, &mut port_b), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!(port_b.destroy_calls, 0); + + session_b + .destroy_disposable_context(&authority_b, &mut port_b) + .expect("B destroys only its isolation boundary"); + assert_eq!(port_b.destroy_calls, 1); + assert_eq!( + port_b.destroyed_isolations, + vec![isolation_id("user-context-b")] + ); + assert_ne!(&port_b.destroyed_isolations[0], authority_a.isolation()); + } + #[test] fn unknown_internal_authority_cannot_trigger_destroy_io() { let mut session = BrowserSession::start(session_id(11)); - let mut port = TestPort::new(110); + let mut port = TestPort::new(110, "isolation-110"); let unknown = PresentationMutationAuthority { browser_session: session_id(11), + isolation: isolation_id("isolation-111"), browsing_context: context_id(111), context_epoch: BrowserContextEpoch(1), }; assert_eq!( - session.destroy_disposable_context(unknown, &mut port), + session.destroy_disposable_context(&unknown, &mut port), Err(BrowserSessionError::ContextNotOwned) ); assert_eq!(port.destroy_calls, 0); @@ -522,13 +706,13 @@ mod tests { #[test] fn destroy_failure_quarantines_authority_and_transport_loss_is_idempotent() { let mut session = BrowserSession::start(session_id(7)); - let mut port = TestPort::new(70); + let mut port = TestPort::new(70, "isolation-70"); let authority = session .create_disposable_context(&mut port) .expect("owned context"); port.fail_destroy = true; assert_eq!( - session.destroy_disposable_context(authority, &mut port), + session.destroy_disposable_context(&authority, &mut port), Err(BrowserSessionError::ContextDestructionFailed) ); assert_eq!(port.destroy_calls, 1); @@ -547,13 +731,21 @@ mod tests { session.create_disposable_context(&mut port), Err(BrowserSessionError::SessionNotActive) ); + assert_eq!( + session.presentation_authority(context_id(70)), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!( + session.advance_context_epoch(context_id(70)), + Err(BrowserSessionError::SessionNotActive) + ); assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); } #[test] fn successful_destruction_is_required_before_normal_end() { let mut session = BrowserSession::start(session_id(8)); - let mut port = TestPort::new(80); + let mut port = TestPort::new(80, "isolation-80"); let authority = session .create_disposable_context(&mut port) .expect("owned context"); @@ -562,25 +754,34 @@ mod tests { Err(BrowserSessionError::ActiveContextRemains) ); session - .destroy_disposable_context(authority, &mut port) + .destroy_disposable_context(&authority, &mut port) .expect("proven destruction"); session.end().expect("all owned contexts destroyed"); assert_eq!(session.state(), BrowserSessionState::Ended); + assert_eq!( + session.presentation_authority(context_id(80)), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!( + session.advance_context_epoch(context_id(80)), + Err(BrowserSessionError::SessionNotActive) + ); assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); } #[test] fn transport_loss_invalidates_still_active_contexts() { let mut session = BrowserSession::start(session_id(9)); - let mut port = TestPort::new(90); + let mut port = TestPort::new(90, "isolation-90"); let authority = session .create_disposable_context(&mut port) .expect("owned context"); assert!(session.record_transport_loss()); assert_eq!( - session.destroy_disposable_context(authority, &mut port), + session.destroy_disposable_context(&authority, &mut port), Err(BrowserSessionError::SessionNotActive) ); + assert_eq!(port.destroy_calls, 0); } #[test] @@ -591,7 +792,7 @@ mod tests { Err(BrowserSessionError::ContextNotOwned) ); - let mut port = TestPort::new(101); + let mut port = TestPort::new(101, "isolation-101"); session .create_disposable_context(&mut port) .expect("owned context"); From be3568ae2f078cd40e85b9e2152d169a26952d20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 14:12:11 +0900 Subject: [PATCH 19/58] fix(docs): preserve TLS design links while indexing ADR --- docs/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index 70dcf389c..772ccead9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,8 +47,8 @@ The PRD/TRD/Architecture/ADR/UML/ERD/data-governance/traceability/security/opera - [Resolved-destination policy implementation plan](superpowers/plans/2026-08-06-resolved-destination-policy.md) - [Direct socket binding design](superpowers/specs/2026-08-06-direct-socket-binding-design.md) - [Direct socket binding implementation plan](superpowers/plans/2026-08-06-direct-socket-binding.md) -- [TLS service-identity design](superpowers/specs/2026-08-06-tls-service-identity-design.md) -- [TLS service-identity implementation plan](superpowers/plans/2026-08-06-tls-service-identity.md) +- [TLS service-identity design](superpowers/specs/2026-08-06-tls-server-identity-design.md) +- [TLS service-identity implementation plan](superpowers/plans/2026-08-06-tls-server-identity.md) ## Accepted protected-main architecture decisions From a98219a08c70a230e526d6d497f55da35d693499 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 14:12:35 +0900 Subject: [PATCH 20/58] test(browser-session): lock cross-aggregate isolation authority --- tests/test_browser_session_lifecycle_contract.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py index 45cbcf436..46a633e5d 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -28,15 +28,23 @@ def test_browser_session_is_an_independent_workspace_boundary(self) -> None: ) def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: - """A raw driver identifier must never become a caller-mintable authority token.""" + """Raw driver identifiers must never become caller-mintable authority tokens.""" source = (CRATE / "src/lib.rs").read_text(encoding="utf-8") self.assertIn("pub struct BrowserSession", source) self.assertIn("pub trait DisposableContextPort", source) + self.assertIn("pub struct DisposableIsolationId", source) + self.assertIn("pub struct DisposableContextHandle", source) self.assertIn("pub struct PresentationMutationAuthority", source) self.assertIn("create_disposable_context", source) self.assertIn("advance_context_epoch", source) self.assertIn("record_transport_loss", source) + self.assertIn("user-context identifier", source) + self.assertIn("Reconstructing cleanup authority", source) + self.assertIn( + "two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary", + source, + ) authority_impl = source.split("impl PresentationMutationAuthority", 1)[1].split( "enum OwnedContextState", 1 From e4f106558f650ffc07146dd0c4d93b0dfb287c70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 14:13:16 +0900 Subject: [PATCH 21/58] docs(adr): carry disposable isolation through destruction --- ...er-session-disposable-context-authority.md | 64 +++++++++++-------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index cc01fdcf0..31c964b4f 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -5,28 +5,32 @@ ## Context -OriginWeave's WebDriver BiDi presentation adapter now requires opaque ownership witnesses before it can plan viewport/device-pixel-ratio, timezone, or screen-area mutation. That closes a dangerous adapter-level gap: a caller that merely knows a browsing-context identifier cannot overwrite another owner's presentation state and later clear it to an implementation default. +OriginWeave's WebDriver BiDi presentation adapter requires opaque ownership witnesses before it can plan viewport/device-pixel-ratio, timezone, or screen-area mutation. That closes an adapter-level gap: a caller that merely knows a browsing-context identifier cannot overwrite another owner's presentation state and later clear it to an implementation default. -The remaining gap is upstream of the adapter. A production Browser Session must establish why a context is exclusively OriginWeave-owned before any presentation-mutation authority can be issued. Without that lifecycle, a hidden constructor or driver shortcut would simply reintroduce ambient authority under a different type name. +The remaining gap is upstream of the adapter. A production Browser Session must establish why a context is exclusively OriginWeave-owned before any presentation-mutation authority can be issued. The first Browser Session implementation bound authority to `(BrowserSessionId, BrowsingContextId, local epoch)`, but those values can be reused by separate aggregate incarnations. Two aggregates that receive the same external session/context identifiers and both start at epoch 1 can therefore alias unless the disposable lifecycle carries a separate non-aliasing isolation identity through mutation validation and destruction I/O. -The 9 September 2026 WebDriver BiDi Working Draft provides a suitable standards-aligned isolation mechanism. `browser.createUserContext` creates a new user context. `browsingContext.create` can create a browsing context inside a selected user context. `browser.removeUserContext` closes that user context and every navigable in it without running `beforeunload` handlers. These protocol operations are adapter capabilities; they do not themselves define OriginWeave's domain ownership or prove cleanup merely because a command was acknowledged. +The 9 September 2026 WebDriver BiDi Working Draft provides a standards-aligned isolation identity. A user context has a user-context id defined as a unique string set when the user context is created. `browser.createUserContext` creates a new user context, `browsingContext.create` can create a browsing context inside it, and `browser.removeUserContext` removes that user context after closing its navigables. These protocol operations are adapter capabilities; they do not themselves define OriginWeave policy authority, and a command acknowledgement alone is not cleanup proof. ## Decision drivers -- A remote-issued browsing-context identifier is addressability, not mutation authority. +- Remote-issued browser-session and browsing-context identifiers are addressability, not mutation authority. +- Reuse of external session/context identifiers across aggregate incarnations must not create authority aliasing. - Shared or attached human contexts must never acquire disposable-owner semantics by implication. - Presentation reset must not destroy a predecessor override owned by another task/session. +- Destruction I/O must be scoped by the exact disposable isolation boundary, not reconstructed from aliasable session/context identifiers. - Navigation, renderer replacement, crash, cleanup failure, and transport loss must invalidate stale authority. - The Browser Session domain must remain independent of WebDriver BiDi, CDP, MCP, and LLM policy decisions. - An adapter acknowledgement is not a successful cleanup post-condition. ## Assumptions and authority boundaries -`originweave-browser-session` owns Browser Session lifecycle state, owned-context membership, monotonic context epochs, and opaque presentation-mutation authority. It consumes validated `BrowserSessionId` and `BrowsingContextId` values from `originweave-core`. +`originweave-browser-session` owns Browser Session lifecycle state, owned-context membership, monotonic context epochs, validated disposable-isolation identity, and opaque presentation-mutation authority. It consumes validated `BrowserSessionId` and `BrowsingContextId` values from `originweave-core`. -A narrow `DisposableContextPort` is the anti-corruption boundary to a future browser adapter. The port may be implemented with WebDriver BiDi user contexts, a separately reviewed Chromium path, or another released adapter, but the adapter does not become the policy or ownership authority. +A narrow `DisposableContextPort` is the anti-corruption boundary to a future browser adapter. The port must return a `DisposableContextHandle` containing the browsing-context address and a live-lifetime non-aliasing `DisposableIsolationId`. For WebDriver BiDi, the adapter proof obligation is a one-to-one mapping from that isolation id to the specification-defined unique user-context id returned by fresh user-context creation. The same handle must scope destruction; reconstructing cleanup authority from `(BrowserSessionId, BrowsingContextId)` is forbidden. -The first implementation deliberately does not convert `PresentationMutationAuthority` into the WebDriver BiDi crate's private presentation/screen-area witnesses. That bridge belongs to a later integration slice after both sides' contracts are reviewed. It also does not claim real-Chromium cleanup evidence. +`DisposableIsolationId` is addressability and lifecycle identity, not policy or presentation authority. Callers can validate an identifier value, but they cannot mint `PresentationMutationAuthority`; only the Browser Session aggregate can bind a port-created isolation boundary to a context epoch and issue the opaque authority token. + +The implementation deliberately does not convert `PresentationMutationAuthority` into the WebDriver BiDi crate's private presentation/screen-area witnesses. That bridge belongs to a later integration slice after both sides' contracts are reviewed. It also does not claim real-Chromium cleanup evidence. ## Options considered @@ -34,48 +38,56 @@ The first implementation deliberately does not convert `PresentationMutationAuth Rejected. It recreates the original authority-confusion defect and allows one task to erase another task's predecessor state. -### B. Snapshot every predecessor presentation override and restore it exactly +### B. Add only an aggregate-local incarnation or epoch + +Rejected as insufficient. An incarnation field can prevent one aggregate from accepting another aggregate's token, but if adapter destruction is still addressed only by reused session/context identifiers, a valid token from aggregate B can still cause the adapter to destroy aggregate A's boundary. The non-aliasing identity therefore has to reach the port boundary itself. + +### C. Snapshot every predecessor presentation override and restore it exactly Deferred. Exact predecessor capture can support reusable/attached contexts later, but today OriginWeave does not have a complete standard protocol snapshot for every governed presentation surface. Partial restoration would be a false safety claim. -### C. Own a disposable isolated context lifecycle and issue opaque authority only after creation +### D. Own a disposable isolation lifecycle and issue opaque authority only after creation -Selected for the first production slice. Isolation gives the aggregate a tractable ownership invariant and a clear terminal action: destruction of the task-owned context boundary. A future WebDriver BiDi adapter should normally map this to a fresh user context plus a browsing context created inside it, then remove the user context during cleanup. +Selected. The Browser Session records a port-proved non-aliasing isolation identity together with its browsing context and epoch. A WebDriver BiDi adapter should map that identity one-to-one to a fresh user context and remove that exact user context during cleanup. This keeps raw driver identifiers as addresses while carrying lifecycle ownership to the destruction boundary. ## Decision Introduce `originweave-browser-session` as an independent Rust bounded context with these invariants: 1. `BrowserSession` is the aggregate root. It begins `Active` and may end normally only after every owned disposable context has proven destruction. -2. A context enters the aggregate's owned set only after `DisposableContextPort::create_disposable_context` succeeds. Supplying a raw `BrowsingContextId` never creates ownership. -3. Successful owned-context creation mints a non-caller-constructible `PresentationMutationAuthority` bound to the exact browser session, browsing context, and context epoch. -4. Advancing the context epoch invalidates previously issued authority. The adapter integration must use this transition at navigation/renderer lifecycle boundaries that invalidate the prior authority scope. -5. Destruction requires exact current authority. A stale, foreign-session, unknown, already-destroyed, or uncertain context fails closed. -6. If destruction cannot be proved, the context becomes `Uncertain` and its authority is invalidated. Normal session end is prohibited. -7. Transport loss moves the Browser Session to `TransportLost`, marks still-active owned contexts uncertain, and prevents further authority issuance. -8. Epoch sequence numbers are monotonic authority identities, not business counters; gaps are allowed after failed creation or rejected duplicate adapter output. +2. A context enters the aggregate's owned set only after `DisposableContextPort::create_disposable_context` succeeds with a `DisposableContextHandle`. Supplying a raw `BrowsingContextId` never creates ownership. +3. The handle contains both the browsing-context address and a `DisposableIsolationId` that the adapter contract requires to be non-aliasing for the live lifetime of the isolation boundary. A WebDriver BiDi adapter maps it one-to-one to the unique user-context id. +4. Successful owned-context creation mints a non-caller-constructible `PresentationMutationAuthority` bound to the exact browser session transport identity, disposable isolation identity, browsing context, and context epoch. +5. Two aggregates may reuse the same external `BrowserSessionId`, `BrowsingContextId`, and local epoch without sharing authority when their disposable isolation identities differ. Foreign isolation authority is rejected before adapter I/O. +6. Advancing the context epoch invalidates previously issued authority. Adapter integration must use this transition at navigation/renderer lifecycle boundaries that invalidate the prior authority scope. +7. Destruction requires exact current authority and passes the stored `DisposableContextHandle` back to the port. A stale, foreign-session, foreign-isolation, unknown, already-destroyed, or uncertain context fails closed before destruction I/O. +8. If destruction cannot be proved, the context becomes `Uncertain` and its authority is invalidated. Normal session end is prohibited. +9. Transport loss moves the Browser Session to `TransportLost`, marks still-active owned contexts uncertain, and prevents further authority issuance. +10. Epoch sequence numbers are monotonic authority identities, not business counters; gaps are allowed after failed creation or rejected duplicate adapter output. ## Consequences -Browser Session ownership becomes a domain fact rather than an adapter convention. This gives the future BiDi/Chromium bridge a legitimate place to mint presentation witnesses without making raw driver identifiers authoritative. +Browser Session ownership becomes a domain fact carried through the adapter lifecycle instead of a convention reconstructed from transport identifiers. This gives the future BiDi/Chromium bridge a legitimate place to mint presentation witnesses without making raw driver identifiers authoritative. + +The Browser Session domain relies on an explicit adapter proof obligation for global live-lifetime non-aliasing of `DisposableIsolationId`. For WebDriver BiDi that proof is the standard's unique user-context identifier plus adapter conformance tests that preserve the mapping and remove the exact user context. A generic random adapter token without a verified one-to-one browser lifecycle mapping is not sufficient. -The first slice remains intentionally incomplete for buyer acceptance. No real Chromium user-context adapter, presentation-witness bridge, observed cleanup receipt, crash-recovery reconciliation, or #299 full browser replay is claimed here. +The slice remains incomplete for buyer acceptance. No real Chromium user-context adapter, presentation-witness bridge, observed cleanup receipt, crash-recovery reconciliation, or #299 full browser replay is claimed here. ## Failure and degraded behavior -Creation failure produces no authority. A duplicate context returned by a supposedly fresh-context adapter is rejected and is not automatically destroyed, because destroying that identifier could target another owner's context. Destruction failure and transport loss quarantine the affected lifecycle rather than assuming cleanup. Once a Browser Session is `Ended` or `TransportLost`, creation, authority lookup, destruction, and normal end transitions that require an active session fail closed. +Creation failure produces no authority. Duplicate browsing-context or disposable-isolation identities returned inside one aggregate are rejected and are not automatically destroyed, because a port that violates the fresh-boundary contract may have returned another owner's state. Cross-aggregate aliasing is prevented by requiring authority and destruction to carry the distinct isolation identity. Destruction failure and transport loss quarantine the affected lifecycle rather than assuming cleanup. Once a Browser Session is `Ended` or `TransportLost`, creation, authority lookup, destruction, and normal end transitions that require an active session fail closed. ## Security / privacy / governance impact Disposable context ownership reduces cross-task presentation-state interference and is compatible with isolated Agent Task profiles. It is not a substitute for Chromium sandboxing, egress policy, origin capability policy, Keyverse secret handling, or evidence retention controls. Those remain with their canonical owners. -No page-controlled value, secret, provider/model choice, or LLM result can mint Browser Session authority. +No page-controlled value, secret, provider/model choice, LLM result, raw browser-session id, or raw browsing-context id can mint Browser Session presentation authority. ## Tests and acceptance evidence -The owning crate tests hostile raw-context lookup, creation failure, duplicate adapter output, epoch exhaustion, stale authority, cross-session authority, cleanup failure, transport loss, unknown context, epoch advancement, and successful destroy-before-end behavior. Repository contracts require the bounded context to be a workspace member. +The owning crate tests hostile raw-context lookup, creation failure, duplicate adapter output, epoch exhaustion, stale authority, cross-session authority, cleanup failure, transport loss, unknown context, epoch advancement, successful destroy-before-end behavior, and a two-aggregate alias case. In the hostile alias case, both aggregates deliberately reuse the same external session and browsing-context identifiers at the same local epoch but receive distinct disposable isolation identities; aggregate B must reject aggregate A's authority before adapter I/O, while B's own authority destroys only B's isolation handle. -Exact-head CI, Clippy, rustdoc, function/line/region/branch coverage and independent review remain required before integration. Real-browser acceptance is deferred to a later adapter slice and must prove creation, page-observed mutation, cleanup/destruction and post-cleanup isolation in pinned Chromium; command ACK alone is not success. +Repository contracts require the bounded context to be a workspace member, keep ADR 0114 indexed, and preserve the non-aliasing port contract. Exact-head CI, Clippy, rustdoc, function/line/region/branch coverage and independent review remain required before integration. Real-browser acceptance is deferred to a later adapter slice and must prove unique user-context creation, page-observed mutation, exact-boundary cleanup/destruction and post-cleanup isolation in pinned Chromium; command ACK alone is not success. ## Migration and rollback @@ -83,15 +95,15 @@ This is additive. Until a reviewed adapter bridge consumes the new authority, ex ## Open follow-ups -- Implement the WebDriver BiDi disposable-user-context adapter using the runtime-qualified protocol contract. +- Implement the WebDriver BiDi disposable-user-context adapter using the runtime-qualified protocol contract and prove the one-to-one `DisposableIsolationId` mapping. - Define the narrow conversion/ACL from `PresentationMutationAuthority` to BiDi presentation/screen-area ownership witnesses without exposing public constructors. -- Specify observed destruction/reconciliation after browser crash or transport loss. +- Specify observed user-context destruction/reconciliation after browser crash or transport loss. - Replay #299 with three complete real-Chromium trials after the canonical sandbox/runtime owner path is usable. - Evaluate exact predecessor capture/restore only if attached/reusable contexts become a buyer requirement. ## Supersession / reversal conditions -Supersede this ADR if WebDriver/Chromium gains a complete, queryable and exactly restorable predecessor-state contract for all governed presentation surfaces, or if OriginWeave adopts another isolation primitive with equivalent ownership and destruction evidence. Do not replace disposable ownership with raw context identity. +Supersede this ADR if WebDriver/Chromium gains a complete, queryable and exactly restorable predecessor-state contract for all governed presentation surfaces, or if OriginWeave adopts another isolation primitive with equivalent non-aliasing ownership and destruction evidence. Do not replace disposable ownership with raw context identity. ## References From e638111d3256edd59f62474033ef14f4e5329f7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 14:13:38 +0900 Subject: [PATCH 22/58] docs(trace): bind cleanup evidence to isolation identity --- .../browser-session-lifecycle-authority.md | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index aab178b5b..03a4b342e 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -8,29 +8,31 @@ ## Problem and invariant -A browsing-context identifier is an address. It is not evidence that the current Browser Session exclusively owns presentation mutation or cleanup for that context. +Browser-session and browsing-context identifiers are addresses. They are not evidence that the current Browser Session aggregate exclusively owns presentation mutation or cleanup. They may also be reused across separate aggregate incarnations, so an aggregate-local epoch does not by itself prevent cross-aggregate authority aliasing. -The active implementation establishes one fail-closed chain: +The active implementation establishes this fail-closed chain: ```text validated BrowserSessionId → BrowserSession::start -→ DisposableContextPort creates a fresh task-owned context -→ aggregate records owned context + monotonic context epoch -→ opaque PresentationMutationAuthority(session, context, epoch) -→ exact-authority destruction request -→ adapter proves disposable boundary destruction +→ DisposableContextPort creates a fresh task-owned isolation boundary + browsing context +→ adapter returns DisposableIsolationId + BrowsingContextId +→ aggregate records exact isolation handle + monotonic context epoch +→ opaque PresentationMutationAuthority(session, isolation, context, epoch) +→ exact-authority validation before adapter I/O +→ destruction receives the stored isolation handle, not reconstructed session/context authority +→ adapter proves exact disposable boundary destruction → context state Destroyed → normal BrowserSession::end is admitted ``` -A raw `BrowsingContextId`, stale epoch, foreign-session authority, unknown context, destruction failure, or lost transport cannot enter the successful chain. Destruction failure and transport loss invalidate active authority rather than treating a remote acknowledgement as cleanup evidence. +A raw `BrowsingContextId`, stale epoch, foreign session, foreign isolation, unknown context, destruction failure, or lost transport cannot enter the successful chain. Destruction failure and transport loss invalidate active authority rather than treating a remote acknowledgement as cleanup evidence. ## Standards trace -The latest published WebDriver BiDi Working Draft at the time of this decision is 9 September 2026. Its browser module defines `browser.createUserContext`, whose remote-end algorithm creates a new user context. Its browsing-context create command accepts a `userContext`, enabling navigables to be created inside that isolated user context. `browser.removeUserContext` closes the selected user context and all navigables in it without running `beforeunload` handlers. +The latest published WebDriver BiDi Working Draft at the time of this decision is 9 September 2026. A user context has a user-context id defined as a unique string set on creation. The browser module defines `browser.createUserContext`; `browsingContext.create` accepts a `userContext`; and `browser.removeUserContext` removes the selected user context after closing its navigables. -OriginWeave does not copy those protocol concepts into the core domain. A future `DisposableContextPort` adapter may map them into the domain lifecycle, but it must additionally prove the post-condition expected by the port. A successful command ACK is insufficient evidence that the disposable boundary is actually gone. +OriginWeave does not make the protocol identifier itself a policy authority. `DisposableIsolationId` is lifecycle addressability carried through the domain so cleanup cannot be reconstructed from aliasable session/context identifiers. A WebDriver BiDi implementation of `DisposableContextPort` must map the isolation id one-to-one to the specification-defined unique user-context id and must prove removal of that exact boundary. An unchecked random adapter token without that browser-lifecycle mapping does not satisfy the port contract. A successful command ACK is insufficient evidence that the disposable boundary is actually gone. The active `originweave-bidi` adapter remains runtime-qualified against its separately documented 3 September 2026 revision. Tracking the 9 September publication here does not silently repin that runtime contract. @@ -40,8 +42,10 @@ The active `originweave-bidi` adapter remains runtime-qualified against its sepa |---|---| | independent Browser Session bounded context | `crates/originweave-browser-session/`; `tests/test_browser_session_lifecycle_contract.py` | | raw context cannot mint authority | `BrowserSession::presentation_authority`; `disposable_creation_is_the_only_raw_context_entry_to_authority` | -| authority is session/context/epoch bound | `PresentationMutationAuthority`; `epoch_advance_invalidates_old_and_cross_session_authority` | -| adapter duplicate fails closed | `BrowserSession::create_disposable_context`; `creation_failure_duplicate_and_epoch_exhaustion_fail_closed` | +| authority is session/isolation/context/epoch bound | `PresentationMutationAuthority`; `epoch_advance_invalidates_old_and_cross_session_authority` | +| same external session/context/epoch cannot cross aggregate isolation | `BrowserSession::context_for_authority`; `two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary` | +| destruction is scoped by stored isolation handle | `DisposableContextPort::destroy_disposable_context`; `two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary` | +| adapter duplicate fails closed | `BrowserSession::create_disposable_context`; `creation_failure_duplicate_ids_and_epoch_exhaustion_fail_closed` | | cleanup failure invalidates authority | `BrowserSession::destroy_disposable_context`; `destroy_failure_quarantines_authority_and_transport_loss_is_idempotent` | | transport loss invalidates active contexts | `BrowserSession::record_transport_loss`; `transport_loss_invalidates_still_active_contexts` | | normal end requires proved destruction | `BrowserSession::end`; `successful_destruction_is_required_before_normal_end` | @@ -52,7 +56,8 @@ Exact-head CI/coverage is required before this dossier can be cited as verified This slice does not yet prove: -- actual WebDriver BiDi `browser.createUserContext`/`browsingContext.create` integration; +- actual WebDriver BiDi `browser.createUserContext`/`browsingContext.create` integration and one-to-one `DisposableIsolationId` mapping; +- observed `browser.removeUserContext` post-condition for the exact owned isolation boundary; - conversion of domain authority into the BiDi presentation/screen-area private witnesses; - pinned Chromium post-condition observation after presentation mutation; - browser crash/restart reconciliation of uncertain disposable contexts; From 91c0b82845978de202c59abc875233cd367a9788 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 14:14:03 +0900 Subject: [PATCH 23/58] docs(uml): show non-aliasing disposable boundary --- .../browser-session-lifecycle-authority.md | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/uml/browser-session-lifecycle-authority.md b/docs/uml/browser-session-lifecycle-authority.md index c1c095c77..279f08270 100644 --- a/docs/uml/browser-session-lifecycle-authority.md +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -1,6 +1,6 @@ # Browser Session lifecycle authority -This diagram describes the active-pr domain contract introduced for issue #312. It is not evidence that a WebDriver BiDi or Chromium adapter already implements the port. +This diagram describes the active-PR domain contract introduced for issue #312. It is not evidence that a WebDriver BiDi or Chromium adapter already implements the port. ```mermaid sequenceDiagram @@ -14,22 +14,22 @@ sequenceDiagram C->>S: create_disposable_context(port) S->>S: reserve monotonic context epoch S->>P: create_disposable_context(session_id) - P->>B: create isolated disposable boundary - B-->>P: fresh BrowsingContextId - P-->>S: BrowsingContextId - S->>S: register owned Active context - S-->>C: opaque PresentationMutationAuthority + P->>B: create fresh isolation boundary + browsing context + B-->>P: unique isolation id + BrowsingContextId + P-->>S: DisposableContextHandle + S->>S: register exact isolation handle + Active epoch + S-->>C: PresentationMutationAuthority(session, isolation, context, epoch) - Note over C,S: Raw BrowsingContextId alone cannot mint authority. + Note over C,S: Raw BrowserSessionId/BrowsingContextId cannot mint authority. C->>S: advance_context_epoch(context_id) S->>S: replace epoch; old authority becomes stale - S-->>C: new opaque authority + S-->>C: new opaque authority carrying same isolation C->>S: destroy_disposable_context(authority, port) - S->>S: validate exact session/context/epoch - S->>P: destroy_disposable_context(session_id, context_id) - P->>B: destroy isolated disposable boundary + S->>S: validate exact session/isolation/context/epoch before I/O + S->>P: destroy_disposable_context(session_id, stored handle) + P->>B: remove exact owned isolation boundary B-->>P: observed destruction post-condition P-->>S: success S->>S: context = Destroyed @@ -38,14 +38,18 @@ sequenceDiagram S-->>C: Ended ``` +Two aggregates may receive the same external `BrowserSessionId`, the same `BrowsingContextId`, and the same local epoch. Their authority must still differ because the adapter-created disposable isolation identity is non-aliasing for its live lifetime. Passing aggregate A's authority into aggregate B therefore fails before adapter I/O; aggregate B's own destroy call carries B's stored isolation handle instead of reconstructing cleanup authority from the shared transport identifiers. + +For a WebDriver BiDi adapter, the isolation identity is expected to map one-to-one to the specification-defined unique user-context id created by `browser.createUserContext`, and cleanup targets that exact user context. The protocol id remains lifecycle addressability, not OriginWeave policy authority. + ## Failure state machine ```mermaid stateDiagram-v2 [*] --> Active - Active --> Active: fresh context created / authority minted + Active --> Active: fresh isolation + context created / authority minted Active --> Active: context epoch advanced / prior authority stale - Active --> Active: owned context destruction proved + Active --> Active: exact owned isolation destruction proved Active --> Active: create rejected / no authority Active --> Active: destroy fails / context becomes Uncertain Active --> Ended: all owned contexts Destroyed + end From 6486e916dceb4ab5f33f7b390cd76fd4673d6007 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 14:23:18 +0900 Subject: [PATCH 24/58] docs(architecture): bind Browser Session authority to isolation identity --- ARCHITECTURE.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6a0381737..16158bbab 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -151,9 +151,11 @@ Owns the narrow WebDriver BiDi adapter contract that is expressible by one expli ### `originweave-browser-session` (active PR) -Owns the Browser Session aggregate boundary for disposable context lifecycle and presentation-mutation authority. A raw `BrowsingContextId` is addressability only. A context enters the owned set only after a narrow `DisposableContextPort` reports creation of a fresh task-owned disposable boundary. The aggregate then issues an opaque authority bound to the exact browser session, browsing context, and monotonic context epoch. Stale or foreign authority fails closed; failed destruction makes the context uncertain; browser transport loss invalidates active authority; and normal session end is rejected until every owned context has proven destruction. +Owns the Browser Session aggregate boundary for disposable context lifecycle and presentation-mutation authority. Raw `BrowserSessionId` and `BrowsingContextId` values are transport addressability only. A context enters the owned set only after the narrow `DisposableContextPort` reports a fresh disposable isolation boundary together with its browsing-context address. The aggregate stores that exact handle and issues a non-caller-constructible `PresentationMutationAuthority` bound to browser-session identity, disposable-isolation identity, browsing context, and monotonic context epoch. -This active slice deliberately stops before browser transport. WebDriver BiDi/CDP remain adapters and do not mint policy authority. The current proposal does not yet bridge domain authority into `originweave-bidi`'s private presentation/screen-area witnesses, implement `browser.createUserContext`/`browsingContext.create`/`browser.removeUserContext`, prove a cleanup post-condition in Chromium, or establish protected-main behavior. ADR 0114, the Browser Session traceability dossier, and the lifecycle UML record those remaining boundaries. +The isolation identity prevents distinct aggregate incarnations from aliasing authority when external session/context identifiers and local epoch values are reused. Destruction validates the full authority before adapter I/O and passes the stored isolation handle back to the port; cleanup authority is never reconstructed from `(BrowserSessionId, BrowsingContextId)`. For a WebDriver BiDi adapter, the port contract requires a one-to-one mapping from the domain's `DisposableIsolationId` to the specification-defined unique user-context id created for that live boundary. The protocol identifier is lifecycle addressability, not OriginWeave policy authority. Stale, foreign-session, foreign-isolation, unknown, destroyed, or uncertain authority fails closed; failed destruction makes the context uncertain; browser transport loss invalidates active authority; and normal session end is rejected until every owned boundary has proven destruction. + +This active slice deliberately stops before browser transport. WebDriver BiDi/CDP remain adapters and do not mint policy authority. The current proposal does not yet bridge domain authority into `originweave-bidi`'s private presentation/screen-area witnesses, implement the real `browser.createUserContext`/`browsingContext.create`/`browser.removeUserContext` adapter, prove exact-boundary cleanup post-conditions in Chromium, or establish protected-main behavior. ADR 0114, the Browser Session traceability dossier, and the lifecycle UML record those remaining boundaries. ## 6. Planned modules @@ -290,6 +292,7 @@ WARC stores source exchanges and resources; relational storage holds sessions, p - Proxy and PAC routing cannot be inherited ambiently by the direct-only or TLS kernels. - Redirects cannot inherit ambient origin or network authority. - TCP peer equality does not substitute for TLS server identity, and TLS identity does not substitute for HTTP safety. +- Disposable Browser Session mutation and destruction authority is bound to the exact owned isolation identity as well as session, context, and epoch; raw driver identifiers alone cannot cross that boundary. - Arbitrary script evaluation is absent from the standard action interface. - Crawler policy is not treated as access authorization. - High-risk actions fail closed when context, canonical intent, or approval evidence is incomplete. From 197d79df58721672b27790c775e2c64edc9c27b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 15:06:18 +0900 Subject: [PATCH 25/58] fix(browser-session): quarantine uncertain lifecycle outcomes --- crates/originweave-browser-session/src/lib.rs | 227 ++++++++++++++---- 1 file changed, 179 insertions(+), 48 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 7e7ef86bc..dc9e3a5c5 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -20,6 +20,8 @@ pub enum BrowserSessionState { Ended, /// The browser transport was lost; remaining contexts have uncertain cleanup state. TransportLost, + /// Browser lifecycle ownership became uncertain and requires external reconciliation. + RecoveryRequired, } /// Domain failure while changing Browser Session ownership state. @@ -29,8 +31,10 @@ pub enum BrowserSessionError { SessionNotActive, /// No unused context epoch remains, so no new authority can be issued safely. EpochExhausted, - /// The disposable-context port could not create the requested isolated context. + /// The disposable-context port proved that context creation failed without creating a boundary. ContextCreationFailed, + /// Context creation may have created browser state that the aggregate cannot safely own or destroy. + ContextCreationUncertain, /// The port returned a browsing-context identity already known to this aggregate. DuplicateBrowsingContext, /// The port returned an isolation identity already known to this aggregate. @@ -48,8 +52,10 @@ pub enum BrowserSessionError { /// Bounded failure reported by the adapter port used for disposable context lifecycle I/O. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DisposableContextPortError { - /// Creation of a fresh disposable context failed. - CreateFailed, + /// Creation failed and the adapter proved that no disposable boundary was created. + CreateFailedClean, + /// Creation failed after ownership may have changed, so browser cleanup state is uncertain. + CreateFailedUncertain, /// Destruction of an owned disposable context failed or could not be proven. DestroyFailed, } @@ -137,6 +143,10 @@ impl DisposableContextHandle { /// user-context identifier returned by `browser.createUserContext`. An implementation that merely /// returns an existing/shared context violates this port contract. /// +/// Creation failures are typed. `CreateFailedClean` is allowed only when the adapter can prove that +/// no disposable browser state was created. Any partial-create or uncertain post-condition must be +/// `CreateFailedUncertain`, which makes normal Browser Session completion ineligible until recovery. +/// /// `destroy_disposable_context` must destroy the exact isolation boundary carried by the supplied /// handle and return success only after the adapter has proved that the task-owned boundary is gone. /// Reconstructing cleanup authority from `(BrowserSessionId, BrowsingContextId)` is forbidden, and a @@ -267,27 +277,40 @@ impl BrowserSession { /// Create and register one disposable context, then mint authority for its first epoch. /// /// Epoch capacity is reserved before external creation so an exhausted aggregate never creates an - /// untrackable context. Epoch identifiers may therefore have gaps after failed creation or rejected - /// duplicate adapter output. Duplicate browser or isolation identities are rejected without cleanup - /// because a port that violates the fresh-boundary contract may have returned another owner's state. + /// untrackable context. A clean creation failure leaves the aggregate active. An uncertain creation + /// failure or duplicate adapter result enters `RecoveryRequired`, because the browser may contain an + /// untracked isolation boundary and normal completion must not hide that lifecycle uncertainty. pub fn create_disposable_context( &mut self, port: &mut P, ) -> Result { self.require_active()?; let epoch = self.reserve_epoch()?; - let handle = port - .create_disposable_context(self.id) - .map_err(|_error| BrowserSessionError::ContextCreationFailed)?; + let handle = match port.create_disposable_context(self.id) { + Ok(handle) => handle, + Err(DisposableContextPortError::CreateFailedClean) => { + return Err(BrowserSessionError::ContextCreationFailed); + } + Err(DisposableContextPortError::CreateFailedUncertain) => { + self.enter_recovery_required(); + return Err(BrowserSessionError::ContextCreationUncertain); + } + Err(DisposableContextPortError::DestroyFailed) => { + self.enter_recovery_required(); + return Err(BrowserSessionError::ContextCreationUncertain); + } + }; if self .contexts .values() .any(|record| record.handle.isolation == handle.isolation) { + self.enter_recovery_required(); return Err(BrowserSessionError::DuplicateDisposableIsolation); } if self.contexts.contains_key(&handle.browsing_context) { + self.enter_recovery_required(); return Err(BrowserSessionError::DuplicateBrowsingContext); } @@ -343,21 +366,18 @@ impl BrowserSession { /// Destroy the disposable isolation boundary covered by the supplied exact-epoch authority. /// - /// Authority is validated before any adapter I/O. Failed or unproven destruction moves the - /// context to an uncertain terminal state so its old authority cannot be reused. OriginWeave does - /// not interpret an adapter ACK as destruction proof. + /// Authority is validated before any adapter I/O. The same validated mutable record is retained + /// across the port call, so no structurally unreachable second lookup is required. Failed or + /// unproven destruction moves the context to an uncertain terminal state. pub fn destroy_disposable_context( &mut self, authority: &PresentationMutationAuthority, port: &mut P, ) -> Result<(), BrowserSessionError> { - let handle = self.context_for_authority(authority)?.handle.clone(); - let result = port.destroy_disposable_context(self.id, &handle); - let record = self - .contexts - .get_mut(&authority.browsing_context) - .ok_or(BrowserSessionError::ContextNotOwned)?; - match result { + let browser_session = self.id; + let record = self.context_for_authority_mut(authority)?; + let handle = record.handle.clone(); + match port.destroy_disposable_context(browser_session, &handle) { Ok(()) => { record.state = OwnedContextState::Destroyed; Ok(()) @@ -377,11 +397,7 @@ impl BrowserSession { return false; } self.state = BrowserSessionState::TransportLost; - for record in self.contexts.values_mut() { - if record.state == OwnedContextState::Active { - record.state = OwnedContextState::Uncertain; - } - } + self.mark_active_contexts_uncertain(); true } @@ -429,24 +445,38 @@ impl BrowserSession { } } - fn context_for_authority( - &self, + fn context_for_authority_mut( + &mut self, authority: &PresentationMutationAuthority, - ) -> Result<&OwnedContextRecord, BrowserSessionError> { + ) -> Result<&mut OwnedContextRecord, BrowserSessionError> { self.require_active()?; if authority.browser_session != self.id { return Err(BrowserSessionError::AuthorityMismatch); } let record = self .contexts - .get(&authority.browsing_context) + .get_mut(&authority.browsing_context) .filter(|record| record.state == OwnedContextState::Active) .ok_or(BrowserSessionError::ContextNotOwned)?; - if record.epoch != authority.context_epoch || record.handle.isolation != authority.isolation { + if record.epoch != authority.context_epoch || record.handle.isolation != authority.isolation + { return Err(BrowserSessionError::AuthorityMismatch); } Ok(record) } + + fn enter_recovery_required(&mut self) { + self.state = BrowserSessionState::RecoveryRequired; + self.mark_active_contexts_uncertain(); + } + + fn mark_active_contexts_uncertain(&mut self) { + for record in self.contexts.values_mut() { + if record.state == OwnedContextState::Active { + record.state = OwnedContextState::Uncertain; + } + } + } } #[cfg(test)] @@ -457,7 +487,7 @@ mod tests { #[derive(Debug)] struct TestPort { next_handle: DisposableContextHandle, - fail_create: bool, + create_error: Option, fail_destroy: bool, create_calls: usize, destroy_calls: usize, @@ -465,13 +495,14 @@ mod tests { } impl TestPort { + /// Build a deterministic lifecycle port for one context/isolation pair. fn new(context: u64, isolation: &str) -> Self { Self { next_handle: DisposableContextHandle::new( isolation_id(isolation), context_id(context), ), - fail_create: false, + create_error: None, fail_destroy: false, create_calls: 0, destroy_calls: 0, @@ -481,18 +512,19 @@ mod tests { } impl DisposableContextPort for TestPort { + /// Return the configured handle or bounded creation failure. fn create_disposable_context( &mut self, _browser_session: BrowserSessionId, ) -> Result { self.create_calls += 1; - if self.fail_create { - Err(DisposableContextPortError::CreateFailed) - } else { - Ok(self.next_handle.clone()) + match self.create_error { + Some(error) => Err(error), + None => Ok(self.next_handle.clone()), } } + /// Record exact isolation destruction before returning the configured result. fn destroy_disposable_context( &mut self, _browser_session: BrowserSessionId, @@ -508,18 +540,22 @@ mod tests { } } + /// Construct a validated Browser Session transport identifier. fn session_id(value: u64) -> BrowserSessionId { BrowserSessionId::new(value).expect("valid session id") } + /// Construct a validated browsing-context identifier. fn context_id(value: u64) -> BrowsingContextId { BrowsingContextId::new(value).expect("valid context id") } + /// Construct a validated disposable isolation identifier. fn isolation_id(value: &str) -> DisposableIsolationId { DisposableIsolationId::parse(value).expect("valid isolation id") } + /// Validate isolation identity bounds and accessor behavior. #[test] fn isolation_identity_validation_is_bounded() { assert_eq!( @@ -540,8 +576,13 @@ mod tests { ); let valid = isolation_id("webdriver-user-context-10"); assert_eq!(valid.as_str(), "webdriver-user-context-10"); + + let handle = DisposableContextHandle::new(valid.clone(), context_id(10)); + assert_eq!(handle.isolation(), &valid); + assert_eq!(handle.browsing_context(), context_id(10)); } + /// Prove that raw context addressability cannot mint presentation authority. #[test] fn disposable_creation_is_the_only_raw_context_entry_to_authority() { let mut session = BrowserSession::start(session_id(1)); @@ -568,32 +609,94 @@ mod tests { ); } + /// Distinguish proved-clean creation failure from uncertain partial creation. #[test] - fn creation_failure_duplicate_ids_and_epoch_exhaustion_fail_closed() { - let mut failed_session = BrowserSession::start(session_id(2)); - let mut failed_port = TestPort::new(20, "isolation-20"); - failed_port.fail_create = true; + fn creation_failure_is_typed_clean_or_recovery_required() { + let mut clean_session = BrowserSession::start(session_id(2)); + let mut clean_port = TestPort::new(20, "isolation-20"); + clean_port.create_error = Some(DisposableContextPortError::CreateFailedClean); assert_eq!( - failed_session.create_disposable_context(&mut failed_port), + clean_session.create_disposable_context(&mut clean_port), Err(BrowserSessionError::ContextCreationFailed) ); + assert_eq!(clean_session.state(), BrowserSessionState::Active); + clean_session.end().expect("proved-clean failure can end normally"); + + let mut uncertain_session = BrowserSession::start(session_id(21)); + let mut uncertain_port = TestPort::new(210, "isolation-210"); + uncertain_port.create_error = Some(DisposableContextPortError::CreateFailedUncertain); + assert_eq!( + uncertain_session.create_disposable_context(&mut uncertain_port), + Err(BrowserSessionError::ContextCreationUncertain) + ); + assert_eq!( + uncertain_session.state(), + BrowserSessionState::RecoveryRequired + ); + assert_eq!(uncertain_session.end(), Err(BrowserSessionError::SessionNotActive)); - let mut duplicate_session = BrowserSession::start(session_id(3)); - let mut first_port = TestPort::new(30, "isolation-30-a"); - duplicate_session - .create_disposable_context(&mut first_port) + let mut invalid_error_session = BrowserSession::start(session_id(22)); + let mut invalid_error_port = TestPort::new(220, "isolation-220"); + invalid_error_port.create_error = Some(DisposableContextPortError::DestroyFailed); + assert_eq!( + invalid_error_session.create_disposable_context(&mut invalid_error_port), + Err(BrowserSessionError::ContextCreationUncertain) + ); + assert_eq!( + invalid_error_session.state(), + BrowserSessionState::RecoveryRequired + ); + } + + /// Duplicate adapter output must prevent a false normal session completion. + #[test] + fn duplicate_adapter_output_requires_recovery() { + let mut duplicate_context_session = BrowserSession::start(session_id(3)); + let mut first_context_port = TestPort::new(30, "isolation-30-a"); + duplicate_context_session + .create_disposable_context(&mut first_context_port) .expect("first owned context"); - let mut duplicate_context = TestPort::new(30, "isolation-30-b"); + let mut duplicate_context_port = TestPort::new(30, "isolation-30-b"); assert_eq!( - duplicate_session.create_disposable_context(&mut duplicate_context), + duplicate_context_session.create_disposable_context(&mut duplicate_context_port), Err(BrowserSessionError::DuplicateBrowsingContext) ); - let mut duplicate_isolation = TestPort::new(31, "isolation-30-a"); assert_eq!( - duplicate_session.create_disposable_context(&mut duplicate_isolation), + duplicate_context_session.state(), + BrowserSessionState::RecoveryRequired + ); + assert_eq!( + duplicate_context_session.end(), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!( + duplicate_context_session.create_disposable_context(&mut duplicate_context_port), + Err(BrowserSessionError::SessionNotActive) + ); + + let mut duplicate_isolation_session = BrowserSession::start(session_id(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 mut duplicate_isolation_port = TestPort::new(311, "isolation-31"); + assert_eq!( + duplicate_isolation_session.create_disposable_context(&mut duplicate_isolation_port), Err(BrowserSessionError::DuplicateDisposableIsolation) ); + assert_eq!( + duplicate_isolation_session.state(), + BrowserSessionState::RecoveryRequired + ); + assert_eq!( + duplicate_isolation_session.presentation_authority(context_id(310)), + Err(BrowserSessionError::SessionNotActive) + ); + } + /// Reserve authority capacity before browser I/O so exhaustion cannot leak a context. + #[test] + fn epoch_exhaustion_prevents_creation_io() { let mut exhausted_session = BrowserSession::start(session_id(4)); exhausted_session.next_epoch = u64::MAX; let mut unused_port = TestPort::new(40, "isolation-40"); @@ -604,6 +707,7 @@ mod tests { assert_eq!(unused_port.create_calls, 0); } + /// Reject stale epoch and foreign-session authority before destruction I/O. #[test] fn epoch_advance_invalidates_old_and_cross_session_authority() { let mut session = BrowserSession::start(session_id(5)); @@ -649,6 +753,7 @@ mod tests { assert_eq!(port.destroy_calls, 1); } + /// Prove two aggregate incarnations cannot cross isolation ownership boundaries. #[test] fn two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary() { let shared_session = session_id(12); @@ -685,6 +790,7 @@ mod tests { assert_ne!(&port_b.destroyed_isolations[0], authority_a.isolation()); } + /// Reject an unknown context before any adapter destruction call. #[test] fn unknown_internal_authority_cannot_trigger_destroy_io() { let mut session = BrowserSession::start(session_id(11)); @@ -703,6 +809,28 @@ mod tests { assert_eq!(port.destroy_calls, 0); } + /// Reject same-context authority with a foreign isolation identity before I/O. + #[test] + fn foreign_isolation_authority_cannot_trigger_destroy_io() { + let mut session = BrowserSession::start(session_id(13)); + let mut port = TestPort::new(130, "isolation-130"); + let authority = session + .create_disposable_context(&mut port) + .expect("owned context"); + let forged = PresentationMutationAuthority { + browser_session: authority.browser_session(), + isolation: isolation_id("isolation-foreign"), + browsing_context: authority.browsing_context(), + context_epoch: authority.context_epoch(), + }; + assert_eq!( + session.destroy_disposable_context(&forged, &mut port), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!(port.destroy_calls, 0); + } + + /// Quarantine failed destruction and keep transport-loss transitions idempotent. #[test] fn destroy_failure_quarantines_authority_and_transport_loss_is_idempotent() { let mut session = BrowserSession::start(session_id(7)); @@ -742,6 +870,7 @@ mod tests { assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); } + /// Require proven context destruction before a normal session end. #[test] fn successful_destruction_is_required_before_normal_end() { let mut session = BrowserSession::start(session_id(8)); @@ -769,6 +898,7 @@ mod tests { assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); } + /// Invalidate still-active authority immediately after transport loss. #[test] fn transport_loss_invalidates_still_active_contexts() { let mut session = BrowserSession::start(session_id(9)); @@ -784,6 +914,7 @@ mod tests { assert_eq!(port.destroy_calls, 0); } + /// Reject epoch advancement for unknown and exhausted contexts. #[test] fn advance_context_epoch_rejects_unknown_and_exhausted_contexts() { let mut session = BrowserSession::start(session_id(10)); From e71df6a8af43bc5225bc7d6b7c3eed2019d45c7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 15:07:08 +0900 Subject: [PATCH 26/58] test(browser-session): lock recovery-required lifecycle contract --- tests/test_browser_session_lifecycle_contract.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py index 46a633e5d..cddb055c0 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -36,11 +36,15 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: self.assertIn("pub struct DisposableIsolationId", source) self.assertIn("pub struct DisposableContextHandle", source) self.assertIn("pub struct PresentationMutationAuthority", source) + self.assertIn("BrowserSessionState::RecoveryRequired", source) + self.assertIn("CreateFailedClean", source) + self.assertIn("CreateFailedUncertain", source) self.assertIn("create_disposable_context", source) self.assertIn("advance_context_epoch", source) self.assertIn("record_transport_loss", source) self.assertIn("user-context identifier", source) self.assertIn("Reconstructing cleanup authority", source) + self.assertIn("duplicate_adapter_output_requires_recovery", source) self.assertIn( "two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary", source, @@ -66,9 +70,14 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: ) self.assertIn("Status: Proposed", adr) self.assertIn("WD-webdriver-bidi-20260909", adr) + self.assertIn("RecoveryRequired", adr) + self.assertIn("CreateFailedClean", adr) + self.assertIn("CreateFailedUncertain", adr) self.assertIn("IMPLEMENTED_ON_ACTIVE_PR", trace) + self.assertIn("RecoveryRequired", trace) self.assertIn("command ACK", trace) self.assertIn("PresentationMutationAuthority", uml) + self.assertIn("RecoveryRequired", uml) self.assertNotIn("IMPLEMENTED_ON_PROTECTED_MAIN", trace) From 0d4592d50005d6f2dbc05ddcfd22d5148752ce21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 15:07:47 +0900 Subject: [PATCH 27/58] docs(adr): distinguish clean and uncertain browser creation --- ...er-session-disposable-context-authority.md | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index 31c964b4f..87beef7ca 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -5,9 +5,11 @@ ## Context -OriginWeave's WebDriver BiDi presentation adapter requires opaque ownership witnesses before it can plan viewport/device-pixel-ratio, timezone, or screen-area mutation. That closes an adapter-level gap: a caller that merely knows a browsing-context identifier cannot overwrite another owner's presentation state and later clear it to an implementation default. +OriginWeave's WebDriver BiDi presentation adapter requires opaque ownership witnesses before it can plan viewport/device-pixel-ratio, timezone, or screen-area mutation. A caller that merely knows a browsing-context identifier therefore cannot overwrite another owner's presentation state and later clear it to an implementation default. -The remaining gap is upstream of the adapter. A production Browser Session must establish why a context is exclusively OriginWeave-owned before any presentation-mutation authority can be issued. The first Browser Session implementation bound authority to `(BrowserSessionId, BrowsingContextId, local epoch)`, but those values can be reused by separate aggregate incarnations. Two aggregates that receive the same external session/context identifiers and both start at epoch 1 can therefore alias unless the disposable lifecycle carries a separate non-aliasing isolation identity through mutation validation and destruction I/O. +The Browser Session boundary must also establish why a context is exclusively OriginWeave-owned before any presentation-mutation authority is issued. External browser-session and browsing-context identifiers can be reused across aggregate incarnations, so ownership cannot be reconstructed from `(BrowserSessionId, BrowsingContextId, local epoch)`. The active implementation carries a separate non-aliasing disposable isolation identity through authority validation and destruction I/O. + +A second lifecycle gap appears when creation does not have a proved-clean outcome. An adapter can fail after browser state may already have been created, or can return a duplicate context/isolation identity. In either case OriginWeave cannot safely assume that no untracked boundary exists. Leaving the aggregate `Active` would allow a later normal `end()` to hide that uncertainty. Creation outcomes therefore need an explicit clean-versus-uncertain contract and a recovery-required terminal state. The 9 September 2026 WebDriver BiDi Working Draft provides a standards-aligned isolation identity. A user context has a user-context id defined as a unique string set when the user context is created. `browser.createUserContext` creates a new user context, `browsingContext.create` can create a browsing context inside it, and `browser.removeUserContext` removes that user context after closing its navigables. These protocol operations are adapter capabilities; they do not themselves define OriginWeave policy authority, and a command acknowledgement alone is not cleanup proof. @@ -18,16 +20,20 @@ The 9 September 2026 WebDriver BiDi Working Draft provides a standards-aligned i - Shared or attached human contexts must never acquire disposable-owner semantics by implication. - Presentation reset must not destroy a predecessor override owned by another task/session. - Destruction I/O must be scoped by the exact disposable isolation boundary, not reconstructed from aliasable session/context identifiers. +- Creation failure must distinguish proved-clean failure from an uncertain post-condition. +- Duplicate or partial-create outcomes must not permit false normal completion. - Navigation, renderer replacement, crash, cleanup failure, and transport loss must invalidate stale authority. - The Browser Session domain must remain independent of WebDriver BiDi, CDP, MCP, and LLM policy decisions. - An adapter acknowledgement is not a successful cleanup post-condition. ## Assumptions and authority boundaries -`originweave-browser-session` owns Browser Session lifecycle state, owned-context membership, monotonic context epochs, validated disposable-isolation identity, and opaque presentation-mutation authority. It consumes validated `BrowserSessionId` and `BrowsingContextId` values from `originweave-core`. +`originweave-browser-session` owns Browser Session lifecycle state, owned-context membership, monotonic context epochs, validated disposable-isolation identity, recovery-required state, and opaque presentation-mutation authority. It consumes validated `BrowserSessionId` and `BrowsingContextId` values from `originweave-core`. A narrow `DisposableContextPort` is the anti-corruption boundary to a future browser adapter. The port must return a `DisposableContextHandle` containing the browsing-context address and a live-lifetime non-aliasing `DisposableIsolationId`. For WebDriver BiDi, the adapter proof obligation is a one-to-one mapping from that isolation id to the specification-defined unique user-context id returned by fresh user-context creation. The same handle must scope destruction; reconstructing cleanup authority from `(BrowserSessionId, BrowsingContextId)` is forbidden. +Creation failure has two meanings. `CreateFailedClean` is valid only when the adapter can prove that no disposable browser state was created. `CreateFailedUncertain` is required after any partial-create or unknown post-condition. An uncertain outcome moves the aggregate to `RecoveryRequired`, invalidates active owned-context authority, blocks further creation/authority issuance, and prevents normal `end()` until a separate reconciliation design proves what happened remotely. A port that returns a destruction-only error from the creation method is also treated as uncertain rather than trusted as clean. + `DisposableIsolationId` is addressability and lifecycle identity, not policy or presentation authority. Callers can validate an identifier value, but they cannot mint `PresentationMutationAuthority`; only the Browser Session aggregate can bind a port-created isolation boundary to a context epoch and issue the opaque authority token. The implementation deliberately does not convert `PresentationMutationAuthority` into the WebDriver BiDi crate's private presentation/screen-area witnesses. That bridge belongs to a later integration slice after both sides' contracts are reviewed. It also does not claim real-Chromium cleanup evidence. @@ -42,13 +48,21 @@ Rejected. It recreates the original authority-confusion defect and allows one ta Rejected as insufficient. An incarnation field can prevent one aggregate from accepting another aggregate's token, but if adapter destruction is still addressed only by reused session/context identifiers, a valid token from aggregate B can still cause the adapter to destroy aggregate A's boundary. The non-aliasing identity therefore has to reach the port boundary itself. -### C. Snapshot every predecessor presentation override and restore it exactly +### C. Treat every creation failure as clean + +Rejected. A transport or adapter failure after `browser.createUserContext` may leave a remote boundary whose ownership was never recorded. Normal completion after such a failure would produce false cleanup evidence. + +### D. Treat every creation failure as transport loss + +Rejected as semantically imprecise. Browser transport may still be healthy while ownership of one create attempt is unknown. A distinct `RecoveryRequired` state preserves the causal distinction while remaining fail closed. + +### E. Snapshot every predecessor presentation override and restore it exactly Deferred. Exact predecessor capture can support reusable/attached contexts later, but today OriginWeave does not have a complete standard protocol snapshot for every governed presentation surface. Partial restoration would be a false safety claim. -### D. Own a disposable isolation lifecycle and issue opaque authority only after creation +### F. Own a disposable isolation lifecycle and issue opaque authority only after proved creation -Selected. The Browser Session records a port-proved non-aliasing isolation identity together with its browsing context and epoch. A WebDriver BiDi adapter should map that identity one-to-one to a fresh user context and remove that exact user context during cleanup. This keeps raw driver identifiers as addresses while carrying lifecycle ownership to the destruction boundary. +Selected. The Browser Session records a port-proved non-aliasing isolation identity together with its browsing context and epoch. A WebDriver BiDi adapter should map that identity one-to-one to a fresh user context and remove that exact user context during cleanup. Proved-clean create failure may leave the aggregate active; uncertain create failure or duplicate adapter output requires recovery. ## Decision @@ -59,35 +73,37 @@ Introduce `originweave-browser-session` as an independent Rust bounded context w 3. The handle contains both the browsing-context address and a `DisposableIsolationId` that the adapter contract requires to be non-aliasing for the live lifetime of the isolation boundary. A WebDriver BiDi adapter maps it one-to-one to the unique user-context id. 4. Successful owned-context creation mints a non-caller-constructible `PresentationMutationAuthority` bound to the exact browser session transport identity, disposable isolation identity, browsing context, and context epoch. 5. Two aggregates may reuse the same external `BrowserSessionId`, `BrowsingContextId`, and local epoch without sharing authority when their disposable isolation identities differ. Foreign isolation authority is rejected before adapter I/O. -6. Advancing the context epoch invalidates previously issued authority. Adapter integration must use this transition at navigation/renderer lifecycle boundaries that invalidate the prior authority scope. -7. Destruction requires exact current authority and passes the stored `DisposableContextHandle` back to the port. A stale, foreign-session, foreign-isolation, unknown, already-destroyed, or uncertain context fails closed before destruction I/O. -8. If destruction cannot be proved, the context becomes `Uncertain` and its authority is invalidated. Normal session end is prohibited. -9. Transport loss moves the Browser Session to `TransportLost`, marks still-active owned contexts uncertain, and prevents further authority issuance. -10. Epoch sequence numbers are monotonic authority identities, not business counters; gaps are allowed after failed creation or rejected duplicate adapter output. +6. `CreateFailedClean` means no remote boundary exists and leaves the aggregate active. `CreateFailedUncertain`, duplicate browsing-context output, duplicate isolation output, or a creation-time error with no proved-clean meaning moves the aggregate to `RecoveryRequired` and invalidates active authority. +7. Advancing the context epoch invalidates previously issued authority. Adapter integration must use this transition at navigation/renderer lifecycle boundaries that invalidate the prior authority scope. +8. Destruction requires exact current authority and passes the stored `DisposableContextHandle` back to the port. The aggregate retains that already-validated mutable record across the port call; it does not perform a second impossible lookup after I/O. +9. If destruction cannot be proved, the context becomes `Uncertain` and its authority is invalidated. Normal session end is prohibited. +10. Transport loss moves the Browser Session to `TransportLost`, marks still-active owned contexts uncertain, and prevents further authority issuance. +11. `RecoveryRequired`, `TransportLost`, and `Ended` reject all transitions that require an active session. Reconciliation is a later explicit design; none of these states silently reopens ownership. +12. Epoch sequence numbers are monotonic authority identities, not business counters; gaps are allowed after failed creation or rejected duplicate adapter output. ## Consequences -Browser Session ownership becomes a domain fact carried through the adapter lifecycle instead of a convention reconstructed from transport identifiers. This gives the future BiDi/Chromium bridge a legitimate place to mint presentation witnesses without making raw driver identifiers authoritative. +Browser Session ownership becomes a domain fact carried through the adapter lifecycle instead of a convention reconstructed from transport identifiers. Proved-clean and uncertain creation outcomes are no longer conflated, so normal completion cannot hide a potentially leaked browser boundary. The Browser Session domain relies on an explicit adapter proof obligation for global live-lifetime non-aliasing of `DisposableIsolationId`. For WebDriver BiDi that proof is the standard's unique user-context identifier plus adapter conformance tests that preserve the mapping and remove the exact user context. A generic random adapter token without a verified one-to-one browser lifecycle mapping is not sufficient. -The slice remains incomplete for buyer acceptance. No real Chromium user-context adapter, presentation-witness bridge, observed cleanup receipt, crash-recovery reconciliation, or #299 full browser replay is claimed here. +The slice remains incomplete for buyer acceptance. No real Chromium user-context adapter, presentation-witness bridge, observed cleanup receipt, crash/recovery reconciliation, or #299 full browser replay is claimed here. ## Failure and degraded behavior -Creation failure produces no authority. Duplicate browsing-context or disposable-isolation identities returned inside one aggregate are rejected and are not automatically destroyed, because a port that violates the fresh-boundary contract may have returned another owner's state. Cross-aggregate aliasing is prevented by requiring authority and destruction to carry the distinct isolation identity. Destruction failure and transport loss quarantine the affected lifecycle rather than assuming cleanup. Once a Browser Session is `Ended` or `TransportLost`, creation, authority lookup, destruction, and normal end transitions that require an active session fail closed. +`CreateFailedClean` produces no authority and permits continued active operation because the adapter has proved that no disposable boundary exists. `CreateFailedUncertain` and duplicate adapter output move the Browser Session to `RecoveryRequired`; existing active records become uncertain and normal completion is blocked. Duplicate output is never automatically destroyed because a contract-violating adapter may have returned another owner's state. Destruction failure marks the affected record uncertain. Transport loss uses the separate `TransportLost` state. Once a Browser Session is `Ended`, `TransportLost`, or `RecoveryRequired`, creation, authority lookup, destruction, epoch advancement, and normal end transitions that require an active session fail closed. ## Security / privacy / governance impact -Disposable context ownership reduces cross-task presentation-state interference and is compatible with isolated Agent Task profiles. It is not a substitute for Chromium sandboxing, egress policy, origin capability policy, Keyverse secret handling, or evidence retention controls. Those remain with their canonical owners. +Disposable context ownership reduces cross-task presentation-state interference and is compatible with isolated Agent Task profiles. Typed creation outcomes prevent a failed browser command from being misreported as a clean lifecycle. This is not a substitute for Chromium sandboxing, egress policy, origin capability policy, Keyverse secret handling, or evidence retention controls. Those remain with their canonical owners. No page-controlled value, secret, provider/model choice, LLM result, raw browser-session id, or raw browsing-context id can mint Browser Session presentation authority. ## Tests and acceptance evidence -The owning crate tests hostile raw-context lookup, creation failure, duplicate adapter output, epoch exhaustion, stale authority, cross-session authority, cleanup failure, transport loss, unknown context, epoch advancement, successful destroy-before-end behavior, and a two-aggregate alias case. In the hostile alias case, both aggregates deliberately reuse the same external session and browsing-context identifiers at the same local epoch but receive distinct disposable isolation identities; aggregate B must reject aggregate A's authority before adapter I/O, while B's own authority destroys only B's isolation handle. +The owning crate tests hostile raw-context lookup, bounded isolation identity parsing and handle accessors, proved-clean versus uncertain creation failure, duplicate adapter output, epoch exhaustion, stale authority, cross-session authority, foreign isolation authority, cleanup failure, transport loss, unknown context, epoch advancement, successful destroy-before-end behavior, and a two-aggregate alias case. Both duplicate branches assert `RecoveryRequired`, rejected normal end or authority access, and no false lifecycle completion. -Repository contracts require the bounded context to be a workspace member, keep ADR 0114 indexed, and preserve the non-aliasing port contract. Exact-head CI, Clippy, rustdoc, function/line/region/branch coverage and independent review remain required before integration. Real-browser acceptance is deferred to a later adapter slice and must prove unique user-context creation, page-observed mutation, exact-boundary cleanup/destruction and post-cleanup isolation in pinned Chromium; command ACK alone is not success. +Repository contracts require the bounded context to be a workspace member, keep ADR 0114 indexed, preserve the non-aliasing port contract, and retain `RecoveryRequired` plus the typed creation outcomes. Exact-head CI, Clippy, rustdoc, function/line/region/branch coverage and independent review remain required before integration. Real-browser acceptance is deferred to a later adapter slice and must prove unique user-context creation, page-observed mutation, exact-boundary cleanup/destruction and post-cleanup isolation in pinned Chromium; command ACK alone is not success. ## Migration and rollback @@ -97,7 +113,7 @@ This is additive. Until a reviewed adapter bridge consumes the new authority, ex - Implement the WebDriver BiDi disposable-user-context adapter using the runtime-qualified protocol contract and prove the one-to-one `DisposableIsolationId` mapping. - Define the narrow conversion/ACL from `PresentationMutationAuthority` to BiDi presentation/screen-area ownership witnesses without exposing public constructors. -- Specify observed user-context destruction/reconciliation after browser crash or transport loss. +- Specify observed user-context destruction/reconciliation after `RecoveryRequired`, browser crash, or transport loss. - Replay #299 with three complete real-Chromium trials after the canonical sandbox/runtime owner path is usable. - Evaluate exact predecessor capture/restore only if attached/reusable contexts become a buyer requirement. From 98117fb54ec5d429c0c402f21eac0f5cd4e4817d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 15:08:11 +0900 Subject: [PATCH 28/58] docs(traceability): record recovery-required causal evidence --- .../browser-session-lifecycle-authority.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index 03a4b342e..696981999 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -26,7 +26,9 @@ validated BrowserSessionId → normal BrowserSession::end is admitted ``` -A raw `BrowsingContextId`, stale epoch, foreign session, foreign isolation, unknown context, destruction failure, or lost transport cannot enter the successful chain. Destruction failure and transport loss invalidate active authority rather than treating a remote acknowledgement as cleanup evidence. +Creation failure is also causal evidence. `CreateFailedClean` is allowed only when the adapter proves that no disposable browser state was created. `CreateFailedUncertain`, duplicate browsing-context output, duplicate isolation output, or an invalid creation-time error enters `RecoveryRequired`, marks active owned contexts uncertain, and blocks all active-only transitions. This prevents a partial create from being followed by a false normal `end()`. + +A raw `BrowsingContextId`, stale epoch, foreign session, foreign isolation, unknown context, destruction failure, lost transport, or recovery-required session cannot enter the successful chain. Destruction failure and transport loss invalidate active authority rather than treating a remote acknowledgement as cleanup evidence. ## Standards trace @@ -43,14 +45,17 @@ The active `originweave-bidi` adapter remains runtime-qualified against its sepa | independent Browser Session bounded context | `crates/originweave-browser-session/`; `tests/test_browser_session_lifecycle_contract.py` | | raw context cannot mint authority | `BrowserSession::presentation_authority`; `disposable_creation_is_the_only_raw_context_entry_to_authority` | | authority is session/isolation/context/epoch bound | `PresentationMutationAuthority`; `epoch_advance_invalidates_old_and_cross_session_authority` | -| same external session/context/epoch cannot cross aggregate isolation | `BrowserSession::context_for_authority`; `two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary` | -| destruction is scoped by stored isolation handle | `DisposableContextPort::destroy_disposable_context`; `two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary` | -| adapter duplicate fails closed | `BrowserSession::create_disposable_context`; `creation_failure_duplicate_ids_and_epoch_exhaustion_fail_closed` | +| same external session/context/epoch cannot cross aggregate isolation | `BrowserSession::context_for_authority_mut`; `two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary` | +| destruction is scoped by the already-validated stored isolation handle | `BrowserSession::destroy_disposable_context`; `two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary` | +| proved-clean versus uncertain creation is typed | `DisposableContextPortError`; `creation_failure_is_typed_clean_or_recovery_required` | +| duplicate adapter output requires recovery | `BrowserSession::create_disposable_context`; `duplicate_adapter_output_requires_recovery` | | cleanup failure invalidates authority | `BrowserSession::destroy_disposable_context`; `destroy_failure_quarantines_authority_and_transport_loss_is_idempotent` | | transport loss invalidates active contexts | `BrowserSession::record_transport_loss`; `transport_loss_invalidates_still_active_contexts` | | normal end requires proved destruction | `BrowserSession::end`; `successful_destruction_is_required_before_normal_end` | -Exact-head CI/coverage is required before this dossier can be cited as verified active-PR implementation. Protected-main integration is required before any capability maturity is promoted beyond `IMPLEMENTED_ON_ACTIVE_PR`. +The 10 September 2026 exact-head RED on predecessor `6486e916dceb4ab5f33f7b390cd76fd4673d6007` is part of this trace: CI `34440868057` failed rustfmt and exact coverage. The coverage artifact `10138258867` (`sha256:dc38bd6a2a2cb307f6b3fd34332cac04a71aa47e4bae83173afa00a99a85adea`) isolated two unexecuted `DisposableContextHandle` accessors and a structurally unreachable second context lookup after authority validation. The repair exercises the accessors and retains one validated mutable record across destroy I/O instead of testing or excluding an impossible branch. + +Exact-head CI/coverage for the repair is required before this dossier can be cited as verified active-PR implementation. Protected-main integration is required before any capability maturity is promoted beyond `IMPLEMENTED_ON_ACTIVE_PR`. ## Buyer acceptance still open @@ -58,6 +63,7 @@ This slice does not yet prove: - actual WebDriver BiDi `browser.createUserContext`/`browsingContext.create` integration and one-to-one `DisposableIsolationId` mapping; - observed `browser.removeUserContext` post-condition for the exact owned isolation boundary; +- reconciliation of `RecoveryRequired` after a partial create or duplicate response; - conversion of domain authority into the BiDi presentation/screen-area private witnesses; - pinned Chromium post-condition observation after presentation mutation; - browser crash/restart reconciliation of uncertain disposable contexts; From 889d1964b8b4a63e4bec4a4dff08061efadc43b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 15:08:34 +0900 Subject: [PATCH 29/58] docs(uml): model browser lifecycle recovery-required state --- docs/uml/browser-session-lifecycle-authority.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/uml/browser-session-lifecycle-authority.md b/docs/uml/browser-session-lifecycle-authority.md index 279f08270..cef4d38ae 100644 --- a/docs/uml/browser-session-lifecycle-authority.md +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -50,17 +50,26 @@ stateDiagram-v2 Active --> Active: fresh isolation + context created / authority minted Active --> Active: context epoch advanced / prior authority stale Active --> Active: exact owned isolation destruction proved - Active --> Active: create rejected / no authority + Active --> Active: CreateFailedClean / no browser state exists + Active --> RecoveryRequired: CreateFailedUncertain + Active --> RecoveryRequired: duplicate context or isolation output Active --> Active: destroy fails / context becomes Uncertain Active --> Ended: all owned contexts Destroyed + end Active --> TransportLost: browser transport lost Ended --> [*] + RecoveryRequired --> [*] TransportLost --> [*] note right of Active Normal end is rejected while any Active or Uncertain context remains. end note + + note right of RecoveryRequired + Partial create or duplicate output may + have left untracked browser state. + Active-only transitions fail closed. + end note ``` -`TransportLost` is terminal for this aggregate. Recovery of an uncertain remote browser boundary requires a separate reconciliation design; reopening the same aggregate would allow stale authority to regain meaning and is therefore not part of this slice. +`RecoveryRequired` and `TransportLost` are terminal for this aggregate in the current slice. Recovery of uncertain remote browser state requires a separate reconciliation design; reopening the same aggregate would allow stale authority to regain meaning and is therefore not part of this implementation. From 09a733a8609b54abfb7a4f6509b9eb9c064b9b2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 15:34:26 +0900 Subject: [PATCH 30/58] style: apply canonical rustfmt to Browser Session recovery repair --- crates/originweave-browser-session/src/lib.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index dc9e3a5c5..7765fbf9f 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -620,7 +620,9 @@ mod tests { Err(BrowserSessionError::ContextCreationFailed) ); assert_eq!(clean_session.state(), BrowserSessionState::Active); - clean_session.end().expect("proved-clean failure can end normally"); + clean_session + .end() + .expect("proved-clean failure can end normally"); let mut uncertain_session = BrowserSession::start(session_id(21)); let mut uncertain_port = TestPort::new(210, "isolation-210"); @@ -633,7 +635,10 @@ mod tests { uncertain_session.state(), BrowserSessionState::RecoveryRequired ); - assert_eq!(uncertain_session.end(), Err(BrowserSessionError::SessionNotActive)); + assert_eq!( + uncertain_session.end(), + Err(BrowserSessionError::SessionNotActive) + ); let mut invalid_error_session = BrowserSession::start(session_id(22)); let mut invalid_error_port = TestPort::new(220, "isolation-220"); From 6da6015ba4cb2c9c8fa9fbc225ca9c2f5055f55d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 15:39:30 +0900 Subject: [PATCH 31/58] test: require session recovery after uncertain destroy --- .../destroy_failure_requires_recovery.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs diff --git a/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs new file mode 100644 index 000000000..c737556b3 --- /dev/null +++ b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs @@ -0,0 +1,79 @@ +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionState, DisposableContextHandle, + DisposableContextPort, DisposableContextPortError, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct FailingDestroyPort { + next_handle: DisposableContextHandle, + create_calls: usize, + destroy_calls: usize, +} + +impl FailingDestroyPort { + fn new(context: u64, isolation: &str) -> Self { + Self { + next_handle: DisposableContextHandle::new( + DisposableIsolationId::parse(isolation).expect("valid isolation id"), + BrowsingContextId::new(context).expect("valid context id"), + ), + create_calls: 0, + destroy_calls: 0, + } + } +} + +impl DisposableContextPort for FailingDestroyPort { + fn create_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + ) -> Result { + self.create_calls += 1; + Ok(self.next_handle.clone()) + } + + fn destroy_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + _context: &DisposableContextHandle, + ) -> Result<(), DisposableContextPortError> { + self.destroy_calls += 1; + Err(DisposableContextPortError::DestroyFailed) + } +} + +/// An unproven destroy must quarantine the whole aggregate before any later browser I/O. +#[test] +fn destroy_failure_requires_recovery_before_any_new_authority() { + let session_id = BrowserSessionId::new(501).expect("valid session id"); + let context_id = BrowsingContextId::new(5010).expect("valid context id"); + let mut session = BrowserSession::start(session_id); + let mut failing_port = FailingDestroyPort::new(5010, "user-context-501"); + + let authority = session + .create_disposable_context(&mut failing_port) + .expect("owned disposable context"); + assert_eq!( + session.destroy_disposable_context(&authority, &mut failing_port), + Err(BrowserSessionError::ContextDestructionFailed) + ); + assert_eq!(failing_port.destroy_calls, 1); + assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); + + let mut later_port = FailingDestroyPort::new(5011, "user-context-501-later"); + assert_eq!( + session.create_disposable_context(&mut later_port), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!(later_port.create_calls, 0); + assert_eq!( + session.presentation_authority(context_id), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!( + session.advance_context_epoch(context_id), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); +} From ac8b8bbdf5c0293e428c54287c94a685b3fce166 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 15:52:11 +0900 Subject: [PATCH 32/58] fix: quarantine Browser Session after uncertain destroy --- crates/originweave-browser-session/src/lib.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 7765fbf9f..deb938bd2 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -368,7 +368,8 @@ impl BrowserSession { /// /// Authority is validated before any adapter I/O. The same validated mutable record is retained /// across the port call, so no structurally unreachable second lookup is required. Failed or - /// unproven destruction moves the context to an uncertain terminal state. + /// unproven destruction makes ownership uncertain and places the whole aggregate in + /// `RecoveryRequired`, preventing later authority issuance until explicit reconciliation exists. pub fn destroy_disposable_context( &mut self, authority: &PresentationMutationAuthority, @@ -384,6 +385,7 @@ impl BrowserSession { } Err(_error) => { record.state = OwnedContextState::Uncertain; + self.enter_recovery_required(); Err(BrowserSessionError::ContextDestructionFailed) } } @@ -835,7 +837,7 @@ mod tests { assert_eq!(port.destroy_calls, 0); } - /// Quarantine failed destruction and keep transport-loss transitions idempotent. + /// Quarantine the aggregate after failed destruction and keep loss reports idempotent. #[test] fn destroy_failure_quarantines_authority_and_transport_loss_is_idempotent() { let mut session = BrowserSession::start(session_id(7)); @@ -849,17 +851,14 @@ mod tests { Err(BrowserSessionError::ContextDestructionFailed) ); assert_eq!(port.destroy_calls, 1); + assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); assert_eq!( session.presentation_authority(context_id(70)), - Err(BrowserSessionError::ContextNotOwned) - ); - assert_eq!( - session.end(), - Err(BrowserSessionError::ActiveContextRemains) + Err(BrowserSessionError::SessionNotActive) ); - assert!(session.record_transport_loss()); + assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); assert!(!session.record_transport_loss()); - assert_eq!(session.state(), BrowserSessionState::TransportLost); + assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); assert_eq!( session.create_disposable_context(&mut port), Err(BrowserSessionError::SessionNotActive) From 29e6c0ffa37075873b085858b8af23fff8c01655 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 15:52:31 +0900 Subject: [PATCH 33/58] docs: quarantine unproven Browser Session destruction --- docs/uml/browser-session-lifecycle-authority.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/uml/browser-session-lifecycle-authority.md b/docs/uml/browser-session-lifecycle-authority.md index cef4d38ae..381196fae 100644 --- a/docs/uml/browser-session-lifecycle-authority.md +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -53,7 +53,7 @@ stateDiagram-v2 Active --> Active: CreateFailedClean / no browser state exists Active --> RecoveryRequired: CreateFailedUncertain Active --> RecoveryRequired: duplicate context or isolation output - Active --> Active: destroy fails / context becomes Uncertain + Active --> RecoveryRequired: destroy fails / cleanup unproven Active --> Ended: all owned contexts Destroyed + end Active --> TransportLost: browser transport lost Ended --> [*] @@ -61,14 +61,14 @@ stateDiagram-v2 TransportLost --> [*] note right of Active - Normal end is rejected while any - Active or Uncertain context remains. + Normal end is admitted only after every + owned context has proven destruction. end note note right of RecoveryRequired - Partial create or duplicate output may - have left untracked browser state. - Active-only transitions fail closed. + Partial create, duplicate output, or an + unproven destroy leaves lifecycle state + uncertain. Active-only transitions fail closed. end note ``` From 3a7a4c630614632b9bb4a492b90318e9d5696a7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 15:52:53 +0900 Subject: [PATCH 34/58] docs: trace destroy-failure recovery invariant --- .../browser-session-lifecycle-authority.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index 696981999..d73bc59d0 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -28,7 +28,7 @@ validated BrowserSessionId Creation failure is also causal evidence. `CreateFailedClean` is allowed only when the adapter proves that no disposable browser state was created. `CreateFailedUncertain`, duplicate browsing-context output, duplicate isolation output, or an invalid creation-time error enters `RecoveryRequired`, marks active owned contexts uncertain, and blocks all active-only transitions. This prevents a partial create from being followed by a false normal `end()`. -A raw `BrowsingContextId`, stale epoch, foreign session, foreign isolation, unknown context, destruction failure, lost transport, or recovery-required session cannot enter the successful chain. Destruction failure and transport loss invalidate active authority rather than treating a remote acknowledgement as cleanup evidence. +Cleanup failure is treated with the same fail-closed ownership rule. If exact disposable-boundary destruction cannot be proved, the failed record becomes `Uncertain`, the whole Browser Session enters `RecoveryRequired`, every remaining active record becomes uncertain, and later context creation, authority issuance/advance, destruction, and normal end are rejected before adapter I/O. A raw `BrowsingContextId`, stale epoch, foreign session, foreign isolation, unknown context, lost transport, or recovery-required session likewise cannot enter the successful chain. ## Standards trace @@ -49,13 +49,15 @@ The active `originweave-bidi` adapter remains runtime-qualified against its sepa | destruction is scoped by the already-validated stored isolation handle | `BrowserSession::destroy_disposable_context`; `two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary` | | proved-clean versus uncertain creation is typed | `DisposableContextPortError`; `creation_failure_is_typed_clean_or_recovery_required` | | duplicate adapter output requires recovery | `BrowserSession::create_disposable_context`; `duplicate_adapter_output_requires_recovery` | -| cleanup failure invalidates authority | `BrowserSession::destroy_disposable_context`; `destroy_failure_quarantines_authority_and_transport_loss_is_idempotent` | +| unproven destruction quarantines the aggregate | `BrowserSession::destroy_disposable_context`; `destroy_failure_requires_recovery_before_any_new_authority` | | transport loss invalidates active contexts | `BrowserSession::record_transport_loss`; `transport_loss_invalidates_still_active_contexts` | | normal end requires proved destruction | `BrowserSession::end`; `successful_destruction_is_required_before_normal_end` | The 10 September 2026 exact-head RED on predecessor `6486e916dceb4ab5f33f7b390cd76fd4673d6007` is part of this trace: CI `34440868057` failed rustfmt and exact coverage. The coverage artifact `10138258867` (`sha256:dc38bd6a2a2cb307f6b3fd34332cac04a71aa47e4bae83173afa00a99a85adea`) isolated two unexecuted `DisposableContextHandle` accessors and a structurally unreachable second context lookup after authority validation. The repair exercises the accessors and retains one validated mutable record across destroy I/O instead of testing or excluding an impossible branch. -Exact-head CI/coverage for the repair is required before this dossier can be cited as verified active-PR implementation. Protected-main integration is required before any capability maturity is promoted beyond `IMPLEMENTED_ON_ACTIVE_PR`. +A later exact test-only head `6da6015ba4cb2c9c8fa9fbc225ca9c2f5055f55d` supplied a second causal RED in CI `34446199538`: repository contracts and canonical formatting passed, then the hostile destroy-failure test observed `BrowserSessionState::Active` where `RecoveryRequired` was required. The production repair routes that unproven cleanup outcome through the same aggregate recovery transition. A successor exact-head CI/coverage pass is still required before this dossier can be cited as verified repair evidence. + +Protected-main integration is required before any capability maturity is promoted beyond `IMPLEMENTED_ON_ACTIVE_PR`. ## Buyer acceptance still open @@ -63,7 +65,7 @@ This slice does not yet prove: - actual WebDriver BiDi `browser.createUserContext`/`browsingContext.create` integration and one-to-one `DisposableIsolationId` mapping; - observed `browser.removeUserContext` post-condition for the exact owned isolation boundary; -- reconciliation of `RecoveryRequired` after a partial create or duplicate response; +- reconciliation of `RecoveryRequired` after a partial create, duplicate response, or unproven destroy; - conversion of domain authority into the BiDi presentation/screen-area private witnesses; - pinned Chromium post-condition observation after presentation mutation; - browser crash/restart reconciliation of uncertain disposable contexts; From 1579be8f18812df45440909b400ba2018a5f6410 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 15:53:35 +0900 Subject: [PATCH 35/58] docs: require recovery after unproven Browser Session cleanup --- ...er-session-disposable-context-authority.md | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index 87beef7ca..18213eb1c 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -9,7 +9,7 @@ OriginWeave's WebDriver BiDi presentation adapter requires opaque ownership witn The Browser Session boundary must also establish why a context is exclusively OriginWeave-owned before any presentation-mutation authority is issued. External browser-session and browsing-context identifiers can be reused across aggregate incarnations, so ownership cannot be reconstructed from `(BrowserSessionId, BrowsingContextId, local epoch)`. The active implementation carries a separate non-aliasing disposable isolation identity through authority validation and destruction I/O. -A second lifecycle gap appears when creation does not have a proved-clean outcome. An adapter can fail after browser state may already have been created, or can return a duplicate context/isolation identity. In either case OriginWeave cannot safely assume that no untracked boundary exists. Leaving the aggregate `Active` would allow a later normal `end()` to hide that uncertainty. Creation outcomes therefore need an explicit clean-versus-uncertain contract and a recovery-required terminal state. +A second lifecycle gap appears whenever the adapter does not have a proved-clean post-condition. During creation, an adapter can fail after browser state may already have been created, or can return a duplicate context/isolation identity. During destruction, an adapter can fail after the cleanup command has been sent without proving that the exact owned boundary is gone. In either case OriginWeave cannot safely keep the aggregate `Active`: further authority issuance would continue operating beside unresolved browser state. Creation and destruction therefore require explicit clean-versus-uncertain handling and a recovery-required state. The 9 September 2026 WebDriver BiDi Working Draft provides a standards-aligned isolation identity. A user context has a user-context id defined as a unique string set when the user context is created. `browser.createUserContext` creates a new user context, `browsingContext.create` can create a browsing context inside it, and `browser.removeUserContext` removes that user context after closing its navigables. These protocol operations are adapter capabilities; they do not themselves define OriginWeave policy authority, and a command acknowledgement alone is not cleanup proof. @@ -22,6 +22,7 @@ The 9 September 2026 WebDriver BiDi Working Draft provides a standards-aligned i - Destruction I/O must be scoped by the exact disposable isolation boundary, not reconstructed from aliasable session/context identifiers. - Creation failure must distinguish proved-clean failure from an uncertain post-condition. - Duplicate or partial-create outcomes must not permit false normal completion. +- An unproven destroy must quarantine the aggregate before any later context creation or authority issuance. - Navigation, renderer replacement, crash, cleanup failure, and transport loss must invalidate stale authority. - The Browser Session domain must remain independent of WebDriver BiDi, CDP, MCP, and LLM policy decisions. - An adapter acknowledgement is not a successful cleanup post-condition. @@ -34,6 +35,8 @@ A narrow `DisposableContextPort` is the anti-corruption boundary to a future bro Creation failure has two meanings. `CreateFailedClean` is valid only when the adapter can prove that no disposable browser state was created. `CreateFailedUncertain` is required after any partial-create or unknown post-condition. An uncertain outcome moves the aggregate to `RecoveryRequired`, invalidates active owned-context authority, blocks further creation/authority issuance, and prevents normal `end()` until a separate reconciliation design proves what happened remotely. A port that returns a destruction-only error from the creation method is also treated as uncertain rather than trusted as clean. +Destruction likewise has a binary proof obligation. Success is returned only after the adapter proves that the exact stored isolation boundary is gone. Any failed or unproven destruction marks that record uncertain and moves the whole aggregate to `RecoveryRequired`; every remaining active context becomes uncertain and all active-only transitions fail before further adapter I/O. The current slice intentionally has no implicit retry or reopen transition because doing so would restore authority while remote ownership remains unresolved. + `DisposableIsolationId` is addressability and lifecycle identity, not policy or presentation authority. Callers can validate an identifier value, but they cannot mint `PresentationMutationAuthority`; only the Browser Session aggregate can bind a port-created isolation boundary to a context epoch and issue the opaque authority token. The implementation deliberately does not convert `PresentationMutationAuthority` into the WebDriver BiDi crate's private presentation/screen-area witnesses. That bridge belongs to a later integration slice after both sides' contracts are reviewed. It also does not claim real-Chromium cleanup evidence. @@ -52,17 +55,21 @@ Rejected as insufficient. An incarnation field can prevent one aggregate from ac Rejected. A transport or adapter failure after `browser.createUserContext` may leave a remote boundary whose ownership was never recorded. Normal completion after such a failure would produce false cleanup evidence. -### D. Treat every creation failure as transport loss +### D. Treat every uncertain lifecycle failure as transport loss + +Rejected as semantically imprecise. Browser transport may still be healthy while ownership of one create or destroy attempt is unknown. A distinct `RecoveryRequired` state preserves the causal distinction while remaining fail closed. + +### E. Keep the aggregate active after an unproven destroy -Rejected as semantically imprecise. Browser transport may still be healthy while ownership of one create attempt is unknown. A distinct `RecoveryRequired` state preserves the causal distinction while remaining fail closed. +Rejected. Marking only one record uncertain blocks normal `end()` but still allows new disposable contexts and unrelated authority to be created in an aggregate whose remote cleanup state is unresolved. That compounds uncertainty and weakens the ownership boundary. -### E. Snapshot every predecessor presentation override and restore it exactly +### F. Snapshot every predecessor presentation override and restore it exactly Deferred. Exact predecessor capture can support reusable/attached contexts later, but today OriginWeave does not have a complete standard protocol snapshot for every governed presentation surface. Partial restoration would be a false safety claim. -### F. Own a disposable isolation lifecycle and issue opaque authority only after proved creation +### G. Own a disposable isolation lifecycle and issue opaque authority only after proved creation -Selected. The Browser Session records a port-proved non-aliasing isolation identity together with its browsing context and epoch. A WebDriver BiDi adapter should map that identity one-to-one to a fresh user context and remove that exact user context during cleanup. Proved-clean create failure may leave the aggregate active; uncertain create failure or duplicate adapter output requires recovery. +Selected. The Browser Session records a port-proved non-aliasing isolation identity together with its browsing context and epoch. A WebDriver BiDi adapter should map that identity one-to-one to a fresh user context and remove that exact user context during cleanup. Proved-clean create failure may leave the aggregate active; uncertain create failure, duplicate adapter output, or unproven destruction requires recovery. ## Decision @@ -76,14 +83,14 @@ Introduce `originweave-browser-session` as an independent Rust bounded context w 6. `CreateFailedClean` means no remote boundary exists and leaves the aggregate active. `CreateFailedUncertain`, duplicate browsing-context output, duplicate isolation output, or a creation-time error with no proved-clean meaning moves the aggregate to `RecoveryRequired` and invalidates active authority. 7. Advancing the context epoch invalidates previously issued authority. Adapter integration must use this transition at navigation/renderer lifecycle boundaries that invalidate the prior authority scope. 8. Destruction requires exact current authority and passes the stored `DisposableContextHandle` back to the port. The aggregate retains that already-validated mutable record across the port call; it does not perform a second impossible lookup after I/O. -9. If destruction cannot be proved, the context becomes `Uncertain` and its authority is invalidated. Normal session end is prohibited. +9. If destruction cannot be proved, the failed record becomes `Uncertain`, the Browser Session moves to `RecoveryRequired`, every remaining active record becomes uncertain, and further creation, authority lookup/advance, destruction, and normal end are rejected until an explicit reconciliation design exists. 10. Transport loss moves the Browser Session to `TransportLost`, marks still-active owned contexts uncertain, and prevents further authority issuance. 11. `RecoveryRequired`, `TransportLost`, and `Ended` reject all transitions that require an active session. Reconciliation is a later explicit design; none of these states silently reopens ownership. 12. Epoch sequence numbers are monotonic authority identities, not business counters; gaps are allowed after failed creation or rejected duplicate adapter output. ## Consequences -Browser Session ownership becomes a domain fact carried through the adapter lifecycle instead of a convention reconstructed from transport identifiers. Proved-clean and uncertain creation outcomes are no longer conflated, so normal completion cannot hide a potentially leaked browser boundary. +Browser Session ownership becomes a domain fact carried through the adapter lifecycle instead of a convention reconstructed from transport identifiers. Proved-clean and uncertain outcomes are no longer conflated, so normal completion or continued mutation cannot hide a potentially leaked browser boundary. Once cleanup becomes uncertain, the aggregate stops issuing new authority rather than accumulating more browser state beside an unresolved boundary. The Browser Session domain relies on an explicit adapter proof obligation for global live-lifetime non-aliasing of `DisposableIsolationId`. For WebDriver BiDi that proof is the standard's unique user-context identifier plus adapter conformance tests that preserve the mapping and remove the exact user context. A generic random adapter token without a verified one-to-one browser lifecycle mapping is not sufficient. @@ -91,19 +98,19 @@ The slice remains incomplete for buyer acceptance. No real Chromium user-context ## Failure and degraded behavior -`CreateFailedClean` produces no authority and permits continued active operation because the adapter has proved that no disposable boundary exists. `CreateFailedUncertain` and duplicate adapter output move the Browser Session to `RecoveryRequired`; existing active records become uncertain and normal completion is blocked. Duplicate output is never automatically destroyed because a contract-violating adapter may have returned another owner's state. Destruction failure marks the affected record uncertain. Transport loss uses the separate `TransportLost` state. Once a Browser Session is `Ended`, `TransportLost`, or `RecoveryRequired`, creation, authority lookup, destruction, epoch advancement, and normal end transitions that require an active session fail closed. +`CreateFailedClean` produces no authority and permits continued active operation because the adapter has proved that no disposable boundary exists. `CreateFailedUncertain`, duplicate adapter output, and unproven destruction move the Browser Session to `RecoveryRequired`; existing active records become uncertain and all active-only transitions are blocked. Duplicate output is never automatically destroyed because a contract-violating adapter may have returned another owner's state. A failed destroy preserves the exact failed handle as uncertain evidence; it is not retried implicitly and no later adapter I/O is admitted from that aggregate. Transport loss uses the separate `TransportLost` state. Once a Browser Session is `Ended`, `TransportLost`, or `RecoveryRequired`, creation, authority lookup, destruction, epoch advancement, and normal end transitions that require an active session fail closed. ## Security / privacy / governance impact -Disposable context ownership reduces cross-task presentation-state interference and is compatible with isolated Agent Task profiles. Typed creation outcomes prevent a failed browser command from being misreported as a clean lifecycle. This is not a substitute for Chromium sandboxing, egress policy, origin capability policy, Keyverse secret handling, or evidence retention controls. Those remain with their canonical owners. +Disposable context ownership reduces cross-task presentation-state interference and is compatible with isolated Agent Task profiles. Typed lifecycle outcomes prevent a failed browser command from being misreported as a clean lifecycle or followed by fresh authority while cleanup is unresolved. This is not a substitute for Chromium sandboxing, egress policy, origin capability policy, Keyverse secret handling, or evidence retention controls. Those remain with their canonical owners. No page-controlled value, secret, provider/model choice, LLM result, raw browser-session id, or raw browsing-context id can mint Browser Session presentation authority. ## Tests and acceptance evidence -The owning crate tests hostile raw-context lookup, bounded isolation identity parsing and handle accessors, proved-clean versus uncertain creation failure, duplicate adapter output, epoch exhaustion, stale authority, cross-session authority, foreign isolation authority, cleanup failure, transport loss, unknown context, epoch advancement, successful destroy-before-end behavior, and a two-aggregate alias case. Both duplicate branches assert `RecoveryRequired`, rejected normal end or authority access, and no false lifecycle completion. +The owning crate tests hostile raw-context lookup, bounded isolation identity parsing and handle accessors, proved-clean versus uncertain creation failure, duplicate adapter output, epoch exhaustion, stale authority, cross-session authority, foreign isolation authority, cleanup failure, transport loss, unknown context, epoch advancement, successful destroy-before-end behavior, and a two-aggregate alias case. Duplicate and uncertain-create branches assert `RecoveryRequired`; the dedicated `destroy_failure_requires_recovery_before_any_new_authority` hostile test requires an unproven destroy to quarantine the whole aggregate and rejects later creation/authority/epoch/end before adapter I/O. -Repository contracts require the bounded context to be a workspace member, keep ADR 0114 indexed, preserve the non-aliasing port contract, and retain `RecoveryRequired` plus the typed creation outcomes. Exact-head CI, Clippy, rustdoc, function/line/region/branch coverage and independent review remain required before integration. Real-browser acceptance is deferred to a later adapter slice and must prove unique user-context creation, page-observed mutation, exact-boundary cleanup/destruction and post-cleanup isolation in pinned Chromium; command ACK alone is not success. +Repository contracts require the bounded context to be a workspace member, keep ADR 0114 indexed, preserve the non-aliasing port contract, and retain `RecoveryRequired` plus typed lifecycle outcomes. Exact-head CI, Clippy, rustdoc, function/line/region/branch coverage and independent review remain required before integration. Real-browser acceptance is deferred to a later adapter slice and must prove unique user-context creation, page-observed mutation, exact-boundary cleanup/destruction and post-cleanup isolation in pinned Chromium; command ACK alone is not success. ## Migration and rollback From 376e85c65b904d4ccb0aec55df59d7a779fa5395 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 15:53:56 +0900 Subject: [PATCH 36/58] test: bind uncertain cleanup recovery to repository contracts --- .../test_browser_session_lifecycle_contract.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py index cddb055c0..632b4a278 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -56,6 +56,21 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: self.assertNotIn("pub fn new", authority_impl) self.assertNotIn("pub const fn new", authority_impl) + def test_uncertain_destroy_is_an_aggregate_recovery_contract(self) -> None: + """Unproven cleanup must stop all later authority before browser I/O.""" + + source = (CRATE / "src/lib.rs").read_text(encoding="utf-8") + hostile = ( + CRATE / "tests/destroy_failure_requires_recovery.rs" + ).read_text(encoding="utf-8") + self.assertIn("self.enter_recovery_required();", source) + self.assertIn( + "destroy_failure_requires_recovery_before_any_new_authority", + hostile, + ) + self.assertIn("BrowserSessionState::RecoveryRequired", hostile) + self.assertIn("assert_eq!(later_port.create_calls, 0);", hostile) + def test_architecture_decision_and_traceability_are_explicit(self) -> None: """Disposable ownership must remain a Proposed, standards-traced active-PR claim.""" @@ -73,11 +88,14 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: self.assertIn("RecoveryRequired", adr) self.assertIn("CreateFailedClean", adr) self.assertIn("CreateFailedUncertain", adr) + self.assertIn("unproven destruction", adr) self.assertIn("IMPLEMENTED_ON_ACTIVE_PR", trace) self.assertIn("RecoveryRequired", trace) + self.assertIn("unproven destruction quarantines the aggregate", trace) self.assertIn("command ACK", trace) self.assertIn("PresentationMutationAuthority", uml) self.assertIn("RecoveryRequired", uml) + self.assertIn("destroy fails / cleanup unproven", uml) self.assertNotIn("IMPLEMENTED_ON_PROTECTED_MAIN", trace) From f5780fb3102c35f4c0239696ab2499060fc9a55b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:08:30 +0900 Subject: [PATCH 37/58] test: satisfy strict recovery clippy contract --- .../destroy_failure_requires_recovery.rs | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 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 c737556b3..316c14a1a 100644 --- a/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs +++ b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs @@ -12,15 +12,16 @@ struct FailingDestroyPort { } impl FailingDestroyPort { - fn new(context: u64, isolation: &str) -> Self { - Self { - next_handle: DisposableContextHandle::new( - DisposableIsolationId::parse(isolation).expect("valid isolation id"), - BrowsingContextId::new(context).expect("valid context id"), - ), + fn new(context: u64, isolation: &str) -> Result { + let isolation = DisposableIsolationId::parse(isolation) + .map_err(|_| "static fixture isolation id must be valid")?; + let browsing_context = BrowsingContextId::new(context) + .map_err(|_| "static fixture browsing context id must be valid")?; + Ok(Self { + next_handle: DisposableContextHandle::new(isolation, browsing_context), create_calls: 0, destroy_calls: 0, - } + }) } } @@ -45,15 +46,17 @@ impl DisposableContextPort for FailingDestroyPort { /// An unproven destroy must quarantine the whole aggregate before any later browser I/O. #[test] -fn destroy_failure_requires_recovery_before_any_new_authority() { - let session_id = BrowserSessionId::new(501).expect("valid session id"); - let context_id = BrowsingContextId::new(5010).expect("valid context id"); +fn destroy_failure_requires_recovery_before_any_new_authority() -> Result<(), &'static str> { + let session_id = BrowserSessionId::new(501) + .map_err(|_| "static fixture browser session id must be valid")?; + let context_id = BrowsingContextId::new(5010) + .map_err(|_| "static fixture browsing context id must be valid")?; let mut session = BrowserSession::start(session_id); - let mut failing_port = FailingDestroyPort::new(5010, "user-context-501"); + let mut failing_port = FailingDestroyPort::new(5010, "user-context-501")?; let authority = session .create_disposable_context(&mut failing_port) - .expect("owned disposable context"); + .map_err(|_| "fixture disposable context creation must succeed")?; assert_eq!( session.destroy_disposable_context(&authority, &mut failing_port), Err(BrowserSessionError::ContextDestructionFailed) @@ -61,7 +64,7 @@ fn destroy_failure_requires_recovery_before_any_new_authority() { assert_eq!(failing_port.destroy_calls, 1); assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); - let mut later_port = FailingDestroyPort::new(5011, "user-context-501-later"); + let mut later_port = FailingDestroyPort::new(5011, "user-context-501-later")?; assert_eq!( session.create_disposable_context(&mut later_port), Err(BrowserSessionError::SessionNotActive) @@ -76,4 +79,5 @@ fn destroy_failure_requires_recovery_before_any_new_authority() { Err(BrowserSessionError::SessionNotActive) ); assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); + Ok(()) } From 089341463fc9a7aa17fa5f64e4f51c3b36dd1e7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:15:41 +0900 Subject: [PATCH 38/58] docs: document Browser Session private invariants --- crates/originweave-browser-session/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index deb938bd2..f15b9c86f 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -417,6 +417,7 @@ impl BrowserSession { Ok(()) } + /// Reject active-only transitions once ownership has ended or become uncertain. fn require_active(&self) -> Result<(), BrowserSessionError> { if self.state == BrowserSessionState::Active { Ok(()) @@ -425,6 +426,7 @@ impl BrowserSession { } } + /// Reserve the next monotonic authority epoch before browser I/O can create remote state. fn reserve_epoch(&mut self) -> Result { let epoch = BrowserContextEpoch(self.next_epoch); self.next_epoch = self @@ -434,6 +436,7 @@ impl BrowserSession { Ok(epoch) } + /// Bind an already-owned disposable handle and epoch into an opaque mutation authority. fn authority_for( browser_session: BrowserSessionId, handle: &DisposableContextHandle, @@ -447,6 +450,7 @@ impl BrowserSession { } } + /// Validate exact session, context, isolation, and epoch ownership before mutable adapter I/O. fn context_for_authority_mut( &mut self, authority: &PresentationMutationAuthority, @@ -467,11 +471,13 @@ impl BrowserSession { Ok(record) } + /// Enter aggregate-wide recovery quarantine and invalidate every still-active context record. fn enter_recovery_required(&mut self) { self.state = BrowserSessionState::RecoveryRequired; self.mark_active_contexts_uncertain(); } + /// Mark active context records uncertain without rewriting already-proven destruction evidence. fn mark_active_contexts_uncertain(&mut self) { for record in self.contexts.values_mut() { if record.state == OwnedContextState::Active { From ab84a5419893182fc5d6b0b4ef32b46089de6fac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:17:10 +0900 Subject: [PATCH 39/58] test: require phase-specific browser lifecycle errors --- tests/test_browser_session_lifecycle_contract.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py index 632b4a278..f212313d1 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -37,6 +37,9 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: self.assertIn("pub struct DisposableContextHandle", source) self.assertIn("pub struct PresentationMutationAuthority", source) self.assertIn("BrowserSessionState::RecoveryRequired", source) + self.assertIn("pub enum DisposableContextCreateError", source) + self.assertIn("pub enum DisposableContextDestroyError", source) + self.assertNotIn("pub enum DisposableContextPortError", source) self.assertIn("CreateFailedClean", source) self.assertIn("CreateFailedUncertain", source) self.assertIn("create_disposable_context", source) @@ -86,6 +89,8 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: self.assertIn("Status: Proposed", adr) self.assertIn("WD-webdriver-bidi-20260909", adr) self.assertIn("RecoveryRequired", adr) + self.assertIn("DisposableContextCreateError", adr) + self.assertIn("DisposableContextDestroyError", adr) self.assertIn("CreateFailedClean", adr) self.assertIn("CreateFailedUncertain", adr) self.assertIn("unproven destruction", adr) From cd5e2b56377146d0ad75ec67880af7b2ed4885aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:23:49 +0900 Subject: [PATCH 40/58] fix: type browser lifecycle phase failures --- crates/originweave-browser-session/src/lib.rs | 54 ++++++++----------- 1 file changed, 22 insertions(+), 32 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index f15b9c86f..a3231861d 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -49,13 +49,18 @@ pub enum BrowserSessionError { ActiveContextRemains, } -/// Bounded failure reported by the adapter port used for disposable context lifecycle I/O. +/// Bounded failure from disposable-context creation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DisposableContextPortError { +pub enum DisposableContextCreateError { /// Creation failed and the adapter proved that no disposable boundary was created. CreateFailedClean, /// Creation failed after ownership may have changed, so browser cleanup state is uncertain. CreateFailedUncertain, +} + +/// Bounded failure from disposable-context destruction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisposableContextDestroyError { /// Destruction of an owned disposable context failed or could not be proven. DestroyFailed, } @@ -143,9 +148,10 @@ impl DisposableContextHandle { /// user-context identifier returned by `browser.createUserContext`. An implementation that merely /// returns an existing/shared context violates this port contract. /// -/// Creation failures are typed. `CreateFailedClean` is allowed only when the adapter can prove that -/// no disposable browser state was created. Any partial-create or uncertain post-condition must be -/// `CreateFailedUncertain`, which makes normal Browser Session completion ineligible until recovery. +/// Creation failures are typed. [`DisposableContextCreateError::CreateFailedClean`] is allowed only +/// when the adapter can prove that no disposable browser state was created. Any partial-create or +/// uncertain post-condition must be [`DisposableContextCreateError::CreateFailedUncertain`], which +/// makes normal Browser Session completion ineligible until recovery. /// /// `destroy_disposable_context` must destroy the exact isolation boundary carried by the supplied /// handle and return success only after the adapter has proved that the task-owned boundary is gone. @@ -156,14 +162,14 @@ pub trait DisposableContextPort { fn create_disposable_context( &mut self, browser_session: BrowserSessionId, - ) -> Result; + ) -> Result; /// Destroy the exact disposable isolation boundary represented by this handle. fn destroy_disposable_context( &mut self, browser_session: BrowserSessionId, context: &DisposableContextHandle, - ) -> Result<(), DisposableContextPortError>; + ) -> Result<(), DisposableContextDestroyError>; } /// Monotonic identity for one owned browsing-context authority epoch. @@ -288,14 +294,10 @@ impl BrowserSession { let epoch = self.reserve_epoch()?; let handle = match port.create_disposable_context(self.id) { Ok(handle) => handle, - Err(DisposableContextPortError::CreateFailedClean) => { + Err(DisposableContextCreateError::CreateFailedClean) => { return Err(BrowserSessionError::ContextCreationFailed); } - Err(DisposableContextPortError::CreateFailedUncertain) => { - self.enter_recovery_required(); - return Err(BrowserSessionError::ContextCreationUncertain); - } - Err(DisposableContextPortError::DestroyFailed) => { + Err(DisposableContextCreateError::CreateFailedUncertain) => { self.enter_recovery_required(); return Err(BrowserSessionError::ContextCreationUncertain); } @@ -383,7 +385,7 @@ impl BrowserSession { record.state = OwnedContextState::Destroyed; Ok(()) } - Err(_error) => { + Err(DisposableContextDestroyError::DestroyFailed) => { record.state = OwnedContextState::Uncertain; self.enter_recovery_required(); Err(BrowserSessionError::ContextDestructionFailed) @@ -495,7 +497,7 @@ mod tests { #[derive(Debug)] struct TestPort { next_handle: DisposableContextHandle, - create_error: Option, + create_error: Option, fail_destroy: bool, create_calls: usize, destroy_calls: usize, @@ -524,7 +526,7 @@ mod tests { fn create_disposable_context( &mut self, _browser_session: BrowserSessionId, - ) -> Result { + ) -> Result { self.create_calls += 1; match self.create_error { Some(error) => Err(error), @@ -537,11 +539,11 @@ mod tests { &mut self, _browser_session: BrowserSessionId, context: &DisposableContextHandle, - ) -> Result<(), DisposableContextPortError> { + ) -> Result<(), DisposableContextDestroyError> { self.destroy_calls += 1; self.destroyed_isolations.push(context.isolation.clone()); if self.fail_destroy { - Err(DisposableContextPortError::DestroyFailed) + Err(DisposableContextDestroyError::DestroyFailed) } else { Ok(()) } @@ -622,7 +624,7 @@ mod tests { fn creation_failure_is_typed_clean_or_recovery_required() { let mut clean_session = BrowserSession::start(session_id(2)); let mut clean_port = TestPort::new(20, "isolation-20"); - clean_port.create_error = Some(DisposableContextPortError::CreateFailedClean); + clean_port.create_error = Some(DisposableContextCreateError::CreateFailedClean); assert_eq!( clean_session.create_disposable_context(&mut clean_port), Err(BrowserSessionError::ContextCreationFailed) @@ -634,7 +636,7 @@ mod tests { let mut uncertain_session = BrowserSession::start(session_id(21)); let mut uncertain_port = TestPort::new(210, "isolation-210"); - uncertain_port.create_error = Some(DisposableContextPortError::CreateFailedUncertain); + uncertain_port.create_error = Some(DisposableContextCreateError::CreateFailedUncertain); assert_eq!( uncertain_session.create_disposable_context(&mut uncertain_port), Err(BrowserSessionError::ContextCreationUncertain) @@ -647,18 +649,6 @@ mod tests { uncertain_session.end(), Err(BrowserSessionError::SessionNotActive) ); - - let mut invalid_error_session = BrowserSession::start(session_id(22)); - let mut invalid_error_port = TestPort::new(220, "isolation-220"); - invalid_error_port.create_error = Some(DisposableContextPortError::DestroyFailed); - assert_eq!( - invalid_error_session.create_disposable_context(&mut invalid_error_port), - Err(BrowserSessionError::ContextCreationUncertain) - ); - assert_eq!( - invalid_error_session.state(), - BrowserSessionState::RecoveryRequired - ); } /// Duplicate adapter output must prevent a false normal session completion. From 843cb4038b0b3e9591890f1467bd4e5671d6392e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:24:13 +0900 Subject: [PATCH 41/58] test: use phase-specific lifecycle failures --- .../tests/destroy_failure_requires_recovery.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 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 316c14a1a..0e79ec304 100644 --- a/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs +++ b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs @@ -1,6 +1,7 @@ use originweave_browser_session::{ - BrowserSession, BrowserSessionError, BrowserSessionState, DisposableContextHandle, - DisposableContextPort, DisposableContextPortError, DisposableIsolationId, + BrowserSession, BrowserSessionError, BrowserSessionState, DisposableContextCreateError, + DisposableContextDestroyError, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; @@ -29,7 +30,7 @@ impl DisposableContextPort for FailingDestroyPort { fn create_disposable_context( &mut self, _browser_session: BrowserSessionId, - ) -> Result { + ) -> Result { self.create_calls += 1; Ok(self.next_handle.clone()) } @@ -38,9 +39,9 @@ impl DisposableContextPort for FailingDestroyPort { &mut self, _browser_session: BrowserSessionId, _context: &DisposableContextHandle, - ) -> Result<(), DisposableContextPortError> { + ) -> Result<(), DisposableContextDestroyError> { self.destroy_calls += 1; - Err(DisposableContextPortError::DestroyFailed) + Err(DisposableContextDestroyError::DestroyFailed) } } From d9ec4aab7585ad95b217fb767bda3445a7e7a6fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:25:05 +0900 Subject: [PATCH 42/58] docs: make lifecycle failure phases explicit --- ...er-session-disposable-context-authority.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index 18213eb1c..e984b63aa 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -21,6 +21,7 @@ The 9 September 2026 WebDriver BiDi Working Draft provides a standards-aligned i - Presentation reset must not destroy a predecessor override owned by another task/session. - Destruction I/O must be scoped by the exact disposable isolation boundary, not reconstructed from aliasable session/context identifiers. - Creation failure must distinguish proved-clean failure from an uncertain post-condition. +- Creation-only and destruction-only adapter failures must be different types so phase-invalid outcomes are not representable. - Duplicate or partial-create outcomes must not permit false normal completion. - An unproven destroy must quarantine the aggregate before any later context creation or authority issuance. - Navigation, renderer replacement, crash, cleanup failure, and transport loss must invalidate stale authority. @@ -33,9 +34,9 @@ The 9 September 2026 WebDriver BiDi Working Draft provides a standards-aligned i A narrow `DisposableContextPort` is the anti-corruption boundary to a future browser adapter. The port must return a `DisposableContextHandle` containing the browsing-context address and a live-lifetime non-aliasing `DisposableIsolationId`. For WebDriver BiDi, the adapter proof obligation is a one-to-one mapping from that isolation id to the specification-defined unique user-context id returned by fresh user-context creation. The same handle must scope destruction; reconstructing cleanup authority from `(BrowserSessionId, BrowsingContextId)` is forbidden. -Creation failure has two meanings. `CreateFailedClean` is valid only when the adapter can prove that no disposable browser state was created. `CreateFailedUncertain` is required after any partial-create or unknown post-condition. An uncertain outcome moves the aggregate to `RecoveryRequired`, invalidates active owned-context authority, blocks further creation/authority issuance, and prevents normal `end()` until a separate reconciliation design proves what happened remotely. A port that returns a destruction-only error from the creation method is also treated as uncertain rather than trusted as clean. +Creation and destruction expose separate failure types. `DisposableContextCreateError::CreateFailedClean` is valid only when the adapter can prove that no disposable browser state was created. `DisposableContextCreateError::CreateFailedUncertain` is required after any partial-create or unknown post-condition. An uncertain outcome moves the aggregate to `RecoveryRequired`, invalidates active owned-context authority, blocks further creation/authority issuance, and prevents normal `end()` until a separate reconciliation design proves what happened remotely. A destruction-only failure is not representable from the creation method. -Destruction likewise has a binary proof obligation. Success is returned only after the adapter proves that the exact stored isolation boundary is gone. Any failed or unproven destruction marks that record uncertain and moves the whole aggregate to `RecoveryRequired`; every remaining active context becomes uncertain and all active-only transitions fail before further adapter I/O. The current slice intentionally has no implicit retry or reopen transition because doing so would restore authority while remote ownership remains unresolved. +Destruction returns `DisposableContextDestroyError`. Its failure means destruction could not be proved: the exact record becomes uncertain and the whole aggregate moves to `RecoveryRequired`; every remaining active context becomes uncertain and all active-only transitions fail before further adapter I/O. Creation-only failures are not representable from the destruction method. The current slice intentionally has no implicit retry or reopen transition because doing so would restore authority while remote ownership remains unresolved. `DisposableIsolationId` is addressability and lifecycle identity, not policy or presentation authority. Callers can validate an identifier value, but they cannot mint `PresentationMutationAuthority`; only the Browser Session aggregate can bind a port-created isolation boundary to a context epoch and issue the opaque authority token. @@ -69,7 +70,7 @@ Deferred. Exact predecessor capture can support reusable/attached contexts later ### G. Own a disposable isolation lifecycle and issue opaque authority only after proved creation -Selected. The Browser Session records a port-proved non-aliasing isolation identity together with its browsing context and epoch. A WebDriver BiDi adapter should map that identity one-to-one to a fresh user context and remove that exact user context during cleanup. Proved-clean create failure may leave the aggregate active; uncertain create failure, duplicate adapter output, or unproven destruction requires recovery. +Selected. The Browser Session records a port-proved non-aliasing isolation identity together with its browsing context and epoch. A WebDriver BiDi adapter should map that identity one-to-one to a fresh user context and remove that exact user context during cleanup. Proved-clean create failure may leave the aggregate active; uncertain create failure, duplicate adapter output, or unproven destruction requires recovery. Creation and destruction errors remain method-specific so the ACL cannot express a failure from the wrong lifecycle phase. ## Decision @@ -80,9 +81,9 @@ Introduce `originweave-browser-session` as an independent Rust bounded context w 3. The handle contains both the browsing-context address and a `DisposableIsolationId` that the adapter contract requires to be non-aliasing for the live lifetime of the isolation boundary. A WebDriver BiDi adapter maps it one-to-one to the unique user-context id. 4. Successful owned-context creation mints a non-caller-constructible `PresentationMutationAuthority` bound to the exact browser session transport identity, disposable isolation identity, browsing context, and context epoch. 5. Two aggregates may reuse the same external `BrowserSessionId`, `BrowsingContextId`, and local epoch without sharing authority when their disposable isolation identities differ. Foreign isolation authority is rejected before adapter I/O. -6. `CreateFailedClean` means no remote boundary exists and leaves the aggregate active. `CreateFailedUncertain`, duplicate browsing-context output, duplicate isolation output, or a creation-time error with no proved-clean meaning moves the aggregate to `RecoveryRequired` and invalidates active authority. +6. `DisposableContextCreateError::CreateFailedClean` means no remote boundary exists and leaves the aggregate active. `DisposableContextCreateError::CreateFailedUncertain`, duplicate browsing-context output, or duplicate isolation output moves the aggregate to `RecoveryRequired` and invalidates active authority. Destruction-only failures cannot appear on this method boundary. 7. Advancing the context epoch invalidates previously issued authority. Adapter integration must use this transition at navigation/renderer lifecycle boundaries that invalidate the prior authority scope. -8. Destruction requires exact current authority and passes the stored `DisposableContextHandle` back to the port. The aggregate retains that already-validated mutable record across the port call; it does not perform a second impossible lookup after I/O. +8. Destruction requires exact current authority and passes the stored `DisposableContextHandle` back to the port. The aggregate retains that already-validated mutable record across the port call; it does not perform a second impossible lookup after I/O. The method returns only `DisposableContextDestroyError`, so creation-only outcomes cannot cross into cleanup semantics. 9. If destruction cannot be proved, the failed record becomes `Uncertain`, the Browser Session moves to `RecoveryRequired`, every remaining active record becomes uncertain, and further creation, authority lookup/advance, destruction, and normal end are rejected until an explicit reconciliation design exists. 10. Transport loss moves the Browser Session to `TransportLost`, marks still-active owned contexts uncertain, and prevents further authority issuance. 11. `RecoveryRequired`, `TransportLost`, and `Ended` reject all transitions that require an active session. Reconciliation is a later explicit design; none of these states silently reopens ownership. @@ -92,17 +93,19 @@ Introduce `originweave-browser-session` as an independent Rust bounded context w Browser Session ownership becomes a domain fact carried through the adapter lifecycle instead of a convention reconstructed from transport identifiers. Proved-clean and uncertain outcomes are no longer conflated, so normal completion or continued mutation cannot hide a potentially leaked browser boundary. Once cleanup becomes uncertain, the aggregate stops issuing new authority rather than accumulating more browser state beside an unresolved boundary. +Method-specific port errors also remove a class of defensive branches that had no valid domain meaning. An adapter cannot report destruction failure from creation or creation failure from destruction, so the aggregate no longer has to interpret an impossible phase transition as a degraded case. + The Browser Session domain relies on an explicit adapter proof obligation for global live-lifetime non-aliasing of `DisposableIsolationId`. For WebDriver BiDi that proof is the standard's unique user-context identifier plus adapter conformance tests that preserve the mapping and remove the exact user context. A generic random adapter token without a verified one-to-one browser lifecycle mapping is not sufficient. The slice remains incomplete for buyer acceptance. No real Chromium user-context adapter, presentation-witness bridge, observed cleanup receipt, crash/recovery reconciliation, or #299 full browser replay is claimed here. ## Failure and degraded behavior -`CreateFailedClean` produces no authority and permits continued active operation because the adapter has proved that no disposable boundary exists. `CreateFailedUncertain`, duplicate adapter output, and unproven destruction move the Browser Session to `RecoveryRequired`; existing active records become uncertain and all active-only transitions are blocked. Duplicate output is never automatically destroyed because a contract-violating adapter may have returned another owner's state. A failed destroy preserves the exact failed handle as uncertain evidence; it is not retried implicitly and no later adapter I/O is admitted from that aggregate. Transport loss uses the separate `TransportLost` state. Once a Browser Session is `Ended`, `TransportLost`, or `RecoveryRequired`, creation, authority lookup, destruction, epoch advancement, and normal end transitions that require an active session fail closed. +`DisposableContextCreateError::CreateFailedClean` produces no authority and permits continued active operation because the adapter has proved that no disposable boundary exists. `DisposableContextCreateError::CreateFailedUncertain`, duplicate adapter output, and any `DisposableContextDestroyError` move the Browser Session to `RecoveryRequired`; existing active records become uncertain and all active-only transitions are blocked. Duplicate output is never automatically destroyed because a contract-violating adapter may have returned another owner's state. A failed destroy preserves the exact failed handle as uncertain evidence; it is not retried implicitly and no later adapter I/O is admitted from that aggregate. Transport loss uses the separate `TransportLost` state. Once a Browser Session is `Ended`, `TransportLost`, or `RecoveryRequired`, creation, authority lookup, destruction, epoch advancement, and normal end transitions that require an active session fail closed. ## Security / privacy / governance impact -Disposable context ownership reduces cross-task presentation-state interference and is compatible with isolated Agent Task profiles. Typed lifecycle outcomes prevent a failed browser command from being misreported as a clean lifecycle or followed by fresh authority while cleanup is unresolved. This is not a substitute for Chromium sandboxing, egress policy, origin capability policy, Keyverse secret handling, or evidence retention controls. Those remain with their canonical owners. +Disposable context ownership reduces cross-task presentation-state interference and is compatible with isolated Agent Task profiles. Typed lifecycle outcomes prevent a failed browser command from being misreported as a clean lifecycle or followed by fresh authority while cleanup is unresolved. Method-specific failure types also prevent invalid lifecycle-phase semantics from crossing the Browser Session anti-corruption boundary. This is not a substitute for Chromium sandboxing, egress policy, origin capability policy, Keyverse secret handling, or evidence retention controls. Those remain with their canonical owners. No page-controlled value, secret, provider/model choice, LLM result, raw browser-session id, or raw browsing-context id can mint Browser Session presentation authority. @@ -110,7 +113,7 @@ No page-controlled value, secret, provider/model choice, LLM result, raw browser The owning crate tests hostile raw-context lookup, bounded isolation identity parsing and handle accessors, proved-clean versus uncertain creation failure, duplicate adapter output, epoch exhaustion, stale authority, cross-session authority, foreign isolation authority, cleanup failure, transport loss, unknown context, epoch advancement, successful destroy-before-end behavior, and a two-aggregate alias case. Duplicate and uncertain-create branches assert `RecoveryRequired`; the dedicated `destroy_failure_requires_recovery_before_any_new_authority` hostile test requires an unproven destroy to quarantine the whole aggregate and rejects later creation/authority/epoch/end before adapter I/O. -Repository contracts require the bounded context to be a workspace member, keep ADR 0114 indexed, preserve the non-aliasing port contract, and retain `RecoveryRequired` plus typed lifecycle outcomes. Exact-head CI, Clippy, rustdoc, function/line/region/branch coverage and independent review remain required before integration. Real-browser acceptance is deferred to a later adapter slice and must prove unique user-context creation, page-observed mutation, exact-boundary cleanup/destruction and post-cleanup isolation in pinned Chromium; command ACK alone is not success. +Repository contracts require the bounded context to be a workspace member, keep ADR 0114 indexed, preserve the non-aliasing port contract, retain `RecoveryRequired`, and require distinct `DisposableContextCreateError` and `DisposableContextDestroyError` types. Exact-head CI, Clippy, rustdoc, function/line/region/branch coverage and independent review remain required before integration. Real-browser acceptance is deferred to a later adapter slice and must prove unique user-context creation, page-observed mutation, exact-boundary cleanup/destruction and post-cleanup isolation in pinned Chromium; command ACK alone is not success. ## Migration and rollback From 843100839acd5f5f5b9304c46478a330deaa5f32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:25:31 +0900 Subject: [PATCH 43/58] docs: trace phase-specific lifecycle failures --- .../browser-session-lifecycle-authority.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index d73bc59d0..61cfbe4e9 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -26,13 +26,13 @@ validated BrowserSessionId → normal BrowserSession::end is admitted ``` -Creation failure is also causal evidence. `CreateFailedClean` is allowed only when the adapter proves that no disposable browser state was created. `CreateFailedUncertain`, duplicate browsing-context output, duplicate isolation output, or an invalid creation-time error enters `RecoveryRequired`, marks active owned contexts uncertain, and blocks all active-only transitions. This prevents a partial create from being followed by a false normal `end()`. +Creation failure is causal evidence with its own bounded type. `DisposableContextCreateError::CreateFailedClean` is allowed only when the adapter proves that no disposable browser state was created. `DisposableContextCreateError::CreateFailedUncertain`, duplicate browsing-context output, or duplicate isolation output enters `RecoveryRequired`, marks active owned contexts uncertain, and blocks all active-only transitions. This prevents a partial create from being followed by a false normal `end()`. -Cleanup failure is treated with the same fail-closed ownership rule. If exact disposable-boundary destruction cannot be proved, the failed record becomes `Uncertain`, the whole Browser Session enters `RecoveryRequired`, every remaining active record becomes uncertain, and later context creation, authority issuance/advance, destruction, and normal end are rejected before adapter I/O. A raw `BrowsingContextId`, stale epoch, foreign session, foreign isolation, unknown context, lost transport, or recovery-required session likewise cannot enter the successful chain. +Destruction has a separate `DisposableContextDestroyError`; creation-only failures cannot be returned from the destroy boundary, and destruction-only failures cannot be returned from create. If exact disposable-boundary destruction cannot be proved, the failed record becomes `Uncertain`, the whole Browser Session enters `RecoveryRequired`, every remaining active record becomes uncertain, and later context creation, authority issuance/advance, destruction, and normal end are rejected before adapter I/O. A raw `BrowsingContextId`, stale epoch, foreign session, foreign isolation, unknown context, lost transport, or recovery-required session likewise cannot enter the successful chain. ## Standards trace -The latest published WebDriver BiDi Working Draft at the time of this decision is 9 September 2026. A user context has a user-context id defined as a unique string set on creation. The browser module defines `browser.createUserContext`; `browsingContext.create` accepts a `userContext`; and `browser.removeUserContext` removes the selected user context after closing its navigables. +The design dossier references the 9 September 2026 WebDriver BiDi Working Draft. A user context has a user-context id defined as a unique string set on creation. The browser module defines `browser.createUserContext`; `browsingContext.create` accepts a `userContext`; and `browser.removeUserContext` removes the selected user context after closing its navigables. OriginWeave does not make the protocol identifier itself a policy authority. `DisposableIsolationId` is lifecycle addressability carried through the domain so cleanup cannot be reconstructed from aliasable session/context identifiers. A WebDriver BiDi implementation of `DisposableContextPort` must map the isolation id one-to-one to the specification-defined unique user-context id and must prove removal of that exact boundary. An unchecked random adapter token without that browser-lifecycle mapping does not satisfy the port contract. A successful command ACK is insufficient evidence that the disposable boundary is actually gone. @@ -47,7 +47,8 @@ The active `originweave-bidi` adapter remains runtime-qualified against its sepa | authority is session/isolation/context/epoch bound | `PresentationMutationAuthority`; `epoch_advance_invalidates_old_and_cross_session_authority` | | same external session/context/epoch cannot cross aggregate isolation | `BrowserSession::context_for_authority_mut`; `two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary` | | destruction is scoped by the already-validated stored isolation handle | `BrowserSession::destroy_disposable_context`; `two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary` | -| proved-clean versus uncertain creation is typed | `DisposableContextPortError`; `creation_failure_is_typed_clean_or_recovery_required` | +| proved-clean versus uncertain creation is typed | `DisposableContextCreateError`; `creation_failure_is_typed_clean_or_recovery_required` | +| destruction failure is phase-specific | `DisposableContextDestroyError`; `destroy_failure_requires_recovery_before_any_new_authority` | | duplicate adapter output requires recovery | `BrowserSession::create_disposable_context`; `duplicate_adapter_output_requires_recovery` | | unproven destruction quarantines the aggregate | `BrowserSession::destroy_disposable_context`; `destroy_failure_requires_recovery_before_any_new_authority` | | transport loss invalidates active contexts | `BrowserSession::record_transport_loss`; `transport_loss_invalidates_still_active_contexts` | @@ -55,7 +56,9 @@ The active `originweave-bidi` adapter remains runtime-qualified against its sepa The 10 September 2026 exact-head RED on predecessor `6486e916dceb4ab5f33f7b390cd76fd4673d6007` is part of this trace: CI `34440868057` failed rustfmt and exact coverage. The coverage artifact `10138258867` (`sha256:dc38bd6a2a2cb307f6b3fd34332cac04a71aa47e4bae83173afa00a99a85adea`) isolated two unexecuted `DisposableContextHandle` accessors and a structurally unreachable second context lookup after authority validation. The repair exercises the accessors and retains one validated mutable record across destroy I/O instead of testing or excluding an impossible branch. -A later exact test-only head `6da6015ba4cb2c9c8fa9fbc225ca9c2f5055f55d` supplied a second causal RED in CI `34446199538`: repository contracts and canonical formatting passed, then the hostile destroy-failure test observed `BrowserSessionState::Active` where `RecoveryRequired` was required. The production repair routes that unproven cleanup outcome through the same aggregate recovery transition. A successor exact-head CI/coverage pass is still required before this dossier can be cited as verified repair evidence. +A later exact test-only head `6da6015ba4cb2c9c8fa9fbc225ca9c2f5055f55d` supplied a second causal RED in CI `34446199538`: repository contracts and canonical formatting passed, then the hostile destroy-failure test observed `BrowserSessionState::Active` where `RecoveryRequired` was required. Exact `f5780fb3102c35f4c0239696ab2499060fc9a55b` subsequently proved repository contracts, formatting, locked tests, strict Clippy, rustdoc, and exact production coverage GREEN in CI `34448496423` before the method-specific failure-type repair was introduced. + +The method-specific failure-type contract was then added test-first on `ab84a5419893182fc5d6b0b4ef32b46089de6fac`: the contract requires `DisposableContextCreateError` and `DisposableContextDestroyError` and rejects the earlier cross-phase `DisposableContextPortError`. Production and documentation successors must earn their own exact-head GREEN; no predecessor result transfers. Protected-main integration is required before any capability maturity is promoted beyond `IMPLEMENTED_ON_ACTIVE_PR`. From 98a28db0ac95972419906f229badf35c4f5fa0d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:26:34 +0900 Subject: [PATCH 44/58] docs: show phase-specific lifecycle failures --- docs/uml/browser-session-lifecycle-authority.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/uml/browser-session-lifecycle-authority.md b/docs/uml/browser-session-lifecycle-authority.md index 381196fae..1a1356f05 100644 --- a/docs/uml/browser-session-lifecycle-authority.md +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -15,7 +15,7 @@ sequenceDiagram S->>S: reserve monotonic context epoch S->>P: create_disposable_context(session_id) P->>B: create fresh isolation boundary + browsing context - B-->>P: unique isolation id + BrowsingContextId + B-->>P: unique isolation id + BrowsingContextId or DisposableContextCreateError P-->>S: DisposableContextHandle S->>S: register exact isolation handle + Active epoch S-->>C: PresentationMutationAuthority(session, isolation, context, epoch) @@ -30,7 +30,7 @@ sequenceDiagram S->>S: validate exact session/isolation/context/epoch before I/O S->>P: destroy_disposable_context(session_id, stored handle) P->>B: remove exact owned isolation boundary - B-->>P: observed destruction post-condition + B-->>P: observed destruction post-condition or DisposableContextDestroyError P-->>S: success S->>S: context = Destroyed C->>S: end() @@ -40,7 +40,7 @@ sequenceDiagram Two aggregates may receive the same external `BrowserSessionId`, the same `BrowsingContextId`, and the same local epoch. Their authority must still differ because the adapter-created disposable isolation identity is non-aliasing for its live lifetime. Passing aggregate A's authority into aggregate B therefore fails before adapter I/O; aggregate B's own destroy call carries B's stored isolation handle instead of reconstructing cleanup authority from the shared transport identifiers. -For a WebDriver BiDi adapter, the isolation identity is expected to map one-to-one to the specification-defined unique user-context id created by `browser.createUserContext`, and cleanup targets that exact user context. The protocol id remains lifecycle addressability, not OriginWeave policy authority. +For a WebDriver BiDi adapter, the isolation identity is expected to map one-to-one to the specification-defined unique user-context id created by `browser.createUserContext`, and cleanup targets that exact user context. The protocol id remains lifecycle addressability, not OriginWeave policy authority. Creation and destruction expose distinct error types, so an adapter cannot express a destruction-only outcome during creation or a creation-only outcome during cleanup. ## Failure state machine @@ -50,10 +50,10 @@ stateDiagram-v2 Active --> Active: fresh isolation + context created / authority minted Active --> Active: context epoch advanced / prior authority stale Active --> Active: exact owned isolation destruction proved - Active --> Active: CreateFailedClean / no browser state exists - Active --> RecoveryRequired: CreateFailedUncertain + Active --> Active: DisposableContextCreateError::CreateFailedClean + Active --> RecoveryRequired: DisposableContextCreateError::CreateFailedUncertain Active --> RecoveryRequired: duplicate context or isolation output - Active --> RecoveryRequired: destroy fails / cleanup unproven + Active --> RecoveryRequired: DisposableContextDestroyError / cleanup unproven Active --> Ended: all owned contexts Destroyed + end Active --> TransportLost: browser transport lost Ended --> [*] From ab04f9522e97e1ecd6d914c48cb6f77f087eac3b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 19:02:08 +0900 Subject: [PATCH 45/58] test: align Browser Session lifecycle contract --- 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 f212313d1..ba86342bb 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -100,7 +100,7 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: self.assertIn("command ACK", trace) self.assertIn("PresentationMutationAuthority", uml) self.assertIn("RecoveryRequired", uml) - self.assertIn("destroy fails / cleanup unproven", uml) + self.assertIn("DisposableContextDestroyError / cleanup unproven", uml) self.assertNotIn("IMPLEMENTED_ON_PROTECTED_MAIN", trace) From ec145963ad8fe19c9416f2b3856b94660082dbf7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:06:31 +0900 Subject: [PATCH 46/58] test: expose sequential Browser Session authority reuse --- .../tests/sequential_incarnation_reuse.rs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs diff --git a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs new file mode 100644 index 000000000..d89335372 --- /dev/null +++ b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs @@ -0,0 +1,80 @@ +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, DisposableContextCreateError, + DisposableContextDestroyError, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct ReusingPort { + handle: DisposableContextHandle, + destroy_calls: usize, +} + +impl ReusingPort { + fn new(context: u64, isolation: &str) -> Result { + let isolation = DisposableIsolationId::parse(isolation) + .map_err(|_| "static fixture isolation id must be valid")?; + let browsing_context = BrowsingContextId::new(context) + .map_err(|_| "static fixture browsing context id must be valid")?; + Ok(Self { + handle: DisposableContextHandle::new(isolation, browsing_context), + destroy_calls: 0, + }) + } +} + +impl DisposableContextPort for ReusingPort { + fn create_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + ) -> Result { + Ok(self.handle.clone()) + } + + fn destroy_disposable_context( + &mut self, + _browser_session: BrowserSessionId, + _context: &DisposableContextHandle, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls += 1; + Ok(()) + } +} + +/// A retained authority from a completed aggregate must not become valid again after identifier reuse. +#[test] +fn stale_authority_cannot_cross_sequential_session_incarnations() -> Result<(), &'static str> { + let session_id = BrowserSessionId::new(701) + .map_err(|_| "static fixture browser session id must be valid")?; + + let mut port_a = ReusingPort::new(7010, "user-context-reused")?; + let mut session_a = BrowserSession::start(session_id); + let authority_a = session_a + .create_disposable_context(&mut port_a) + .map_err(|_| "first disposable context creation must succeed")?; + session_a + .destroy_disposable_context(&authority_a, &mut port_a) + .map_err(|_| "first disposable context destruction must succeed")?; + session_a + .end() + .map_err(|_| "first browser session must end normally")?; + + let mut port_b = ReusingPort::new(7010, "user-context-reused")?; + let mut session_b = BrowserSession::start(session_id); + let authority_b = session_b + .create_disposable_context(&mut port_b) + .map_err(|_| "second disposable context creation must succeed")?; + + assert_eq!( + session_b.destroy_disposable_context(&authority_a, &mut port_b), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!(port_b.destroy_calls, 0); + + session_b + .destroy_disposable_context(&authority_b, &mut port_b) + .map_err(|_| "current incarnation authority must remain valid")?; + assert_eq!(port_b.destroy_calls, 1); + Ok(()) +} From e37a35aea87fb77a29fd21cb251db0dade6d1656 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:09:57 +0900 Subject: [PATCH 47/58] fix: bind Browser Session recovery to incarnation evidence --- crates/originweave-browser-session/src/lib.rs | 565 ++++++++++-------- 1 file changed, 304 insertions(+), 261 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index a3231861d..75f2707cf 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -8,9 +8,12 @@ #![deny(missing_docs)] use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; use originweave_core::{BrowserSessionId, BrowsingContextId}; +static NEXT_BROWSER_SESSION_INCARNATION: AtomicU64 = AtomicU64::new(1); + /// Current lifecycle state of one Browser Session aggregate. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserSessionState { @@ -18,7 +21,7 @@ pub enum BrowserSessionState { Active, /// Every owned context was destroyed and the session was ended normally. Ended, - /// The browser transport was lost; remaining contexts have uncertain cleanup state. + /// The browser transport was lost while no ownership-recovery condition preceded it. TransportLost, /// Browser lifecycle ownership became uncertain and requires external reconciliation. RecoveryRequired, @@ -29,6 +32,8 @@ pub enum BrowserSessionState { pub enum BrowserSessionError { /// The requested transition requires an active Browser Session. SessionNotActive, + /// No unused session-incarnation identity remains in this process. + IncarnationExhausted, /// No unused context epoch remains, so no new authority can be issued safely. EpochExhausted, /// The disposable-context port proved that context creation failed without creating a boundary. @@ -41,7 +46,7 @@ pub enum BrowserSessionError { DuplicateDisposableIsolation, /// The requested context is not currently owned and active in this session. ContextNotOwned, - /// The supplied authority belongs to another isolation boundary, session, context, or epoch. + /// The supplied authority belongs to another incarnation, isolation boundary, session, context, or epoch. AuthorityMismatch, /// The disposable-context port could not prove destruction of the owned isolation boundary. ContextDestructionFailed, @@ -50,12 +55,13 @@ pub enum BrowserSessionError { } /// Bounded failure from disposable-context creation. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum DisposableContextCreateError { /// Creation failed and the adapter proved that no disposable boundary was created. CreateFailedClean, - /// Creation failed after ownership may have changed, so browser cleanup state is uncertain. - CreateFailedUncertain, + /// Creation failed after ownership may have changed. The optional identity is the exact + /// browser-issued isolation identity already known at the failure boundary, when available. + CreateFailedUncertain(Option), } /// Bounded failure from disposable-context destruction. @@ -106,6 +112,24 @@ impl DisposableIsolationId { } } +/// Process-local, non-reused identity for one Browser Session aggregate incarnation. +/// +/// Presentation authority is intentionally non-serializable. A process restart therefore destroys +/// every outstanding authority value. Within one process this monotonic identity prevents a later +/// aggregate from revalidating an authority retained from an earlier aggregate that reused the same +/// transport/session and browser-issued context identifiers. The identity is also passed through the +/// lifecycle port so an adapter must scope its remote ownership mapping to the same incarnation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowserSessionIncarnation(u64); + +impl BrowserSessionIncarnation { + /// Return the monotonic process-local incarnation value. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + /// Adapter result for one newly created disposable browser context. /// /// The isolation identity scopes the lifecycle boundary used for destruction; the browsing-context @@ -140,34 +164,49 @@ impl DisposableContextHandle { } } +/// Lossless evidence retained when browser lifecycle ownership is no longer proven. +/// +/// These values authorize no browser command. They exist only so a separately reviewed recovery +/// path can later reconcile exact remote identities instead of guessing from raw session/context ids. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BrowserSessionRecoveryEvidence { + /// A partial creation exposed a browser-issued isolation identity before completion became uncertain. + PartialCreationIsolation(DisposableIsolationId), + /// A create call returned a complete handle that aliased an already-owned context or isolation. + DuplicateAdapterHandle(DisposableContextHandle), + /// Destruction of this exact owned handle failed or could not be proven. + UnprovenDestruction(DisposableContextHandle), +} + /// Port implemented by a reviewed browser adapter for disposable context lifecycle operations. /// -/// `create_disposable_context` must create a fresh isolation boundary and context owned exclusively -/// by the supplied Browser Session. The returned [`DisposableIsolationId`] must be non-aliasing for -/// the lifetime of that boundary; for WebDriver BiDi this means a one-to-one mapping to the unique -/// user-context identifier returned by `browser.createUserContext`. An implementation that merely -/// returns an existing/shared context violates this port contract. +/// `incarnation` is domain-issued and must participate in the adapter's lifecycle mapping; ignoring it +/// would reintroduce sequential ABA aliasing. `create_disposable_context` must create a fresh isolation +/// boundary and context owned exclusively by the supplied Browser Session incarnation. For WebDriver +/// BiDi the isolation identity maps one-to-one to the user-context identifier returned by +/// `browser.createUserContext`. /// -/// Creation failures are typed. [`DisposableContextCreateError::CreateFailedClean`] is allowed only -/// when the adapter can prove that no disposable browser state was created. Any partial-create or -/// uncertain post-condition must be [`DisposableContextCreateError::CreateFailedUncertain`], which -/// makes normal Browser Session completion ineligible until recovery. +/// [`DisposableContextCreateError::CreateFailedClean`] is allowed only when the adapter proves that no +/// disposable state was created. If a user-context identity is already known when later creation or +/// verification becomes uncertain, the adapter must return it inside +/// [`DisposableContextCreateError::CreateFailedUncertain`]. /// -/// `destroy_disposable_context` must destroy the exact isolation boundary carried by the supplied -/// handle and return success only after the adapter has proved that the task-owned boundary is gone. -/// Reconstructing cleanup authority from `(BrowserSessionId, BrowsingContextId)` is forbidden, and a -/// command acknowledgement alone is insufficient destruction evidence. +/// `destroy_disposable_context` must destroy the exact boundary carried by the supplied handle and +/// return success only after destruction is proven. Reconstructing cleanup authority from raw driver +/// identifiers is forbidden, and a command acknowledgement alone is insufficient evidence. pub trait DisposableContextPort { - /// Create one fresh disposable isolation boundary and browsing context for the Browser Session. + /// Create one fresh disposable isolation boundary and browsing context for this incarnation. fn create_disposable_context( &mut self, browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, ) -> Result; - /// Destroy the exact disposable isolation boundary represented by this handle. + /// Destroy the exact disposable isolation boundary represented by this handle and incarnation. fn destroy_disposable_context( &mut self, browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, context: &DisposableContextHandle, ) -> Result<(), DisposableContextDestroyError>; } @@ -186,14 +225,13 @@ impl BrowserContextEpoch { /// Opaque proof that Browser Session currently owns presentation mutation for one context epoch. /// -/// The fields are private and no public constructor exists. A caller can obtain this value only after -/// the Browser Session aggregate has successfully created a disposable isolation boundary through its -/// lifecycle port, or after that already-owned context advances to a new epoch. The isolation identity -/// prevents two aggregate incarnations that reuse external session/context identifiers from aliasing -/// each other's mutation or destruction authority when their disposable boundaries are distinct. +/// The fields are private and no public constructor exists. A caller obtains this value only after +/// Browser Session has created a disposable boundary through its lifecycle port. Session incarnation, +/// isolation identity, context identity, and epoch must all still match before adapter I/O is allowed. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PresentationMutationAuthority { browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, isolation: DisposableIsolationId, browsing_context: BrowsingContextId, context_epoch: BrowserContextEpoch, @@ -206,6 +244,12 @@ impl PresentationMutationAuthority { self.browser_session } + /// Return the Browser Session incarnation that minted this authority. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + /// Return the owned disposable isolation identity. #[must_use] pub fn isolation(&self) -> &DisposableIsolationId { @@ -240,32 +284,33 @@ struct OwnedContextRecord { } /// Aggregate root for disposable browser-context lifecycle and presentation mutation authority. -/// -/// The aggregate never accepts a remote/WebDriver context string as authority. A context enters the -/// owned set only through [`BrowserSession::create_disposable_context`], which invokes the lifecycle -/// port before minting an opaque [`PresentationMutationAuthority`]. #[derive(Debug)] pub struct BrowserSession { id: BrowserSessionId, + incarnation: BrowserSessionIncarnation, state: BrowserSessionState, + transport_lost: bool, next_epoch: u64, contexts: BTreeMap, + recovery_evidence: Vec, } impl BrowserSession { /// Start an active Browser Session around an already validated transport session identity. /// - /// The transport identity may be reused by a later aggregate incarnation; it therefore does not - /// participate alone in disposable ownership. Per-context authority additionally carries the - /// adapter-proved non-aliasing isolation identity. - #[must_use] - pub fn start(id: BrowserSessionId) -> Self { - Self { + /// A fresh process-local incarnation is allocated before any browser I/O. Exhaustion fails closed + /// rather than wrapping and making an older authority structurally valid again. + pub fn start(id: BrowserSessionId) -> Result { + let incarnation = allocate_incarnation(&NEXT_BROWSER_SESSION_INCARNATION)?; + Ok(Self { id, + incarnation, state: BrowserSessionState::Active, + transport_lost: false, next_epoch: 1, contexts: BTreeMap::new(), - } + recovery_evidence: Vec::new(), + }) } /// Return this aggregate's browser-session transport identity. @@ -274,30 +319,48 @@ impl BrowserSession { self.id } + /// Return this aggregate's non-reused process-local incarnation. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + /// Return the current aggregate lifecycle state. #[must_use] pub const fn state(&self) -> BrowserSessionState { self.state } + /// Report whether browser transport loss has been observed for this aggregate. + #[must_use] + pub const fn transport_is_lost(&self) -> bool { + self.transport_lost + } + + /// Return immutable recovery evidence retained after uncertain browser lifecycle outcomes. + #[must_use] + pub fn recovery_evidence(&self) -> &[BrowserSessionRecoveryEvidence] { + &self.recovery_evidence + } + /// Create and register one disposable context, then mint authority for its first epoch. - /// - /// Epoch capacity is reserved before external creation so an exhausted aggregate never creates an - /// untrackable context. A clean creation failure leaves the aggregate active. An uncertain creation - /// failure or duplicate adapter result enters `RecoveryRequired`, because the browser may contain an - /// untracked isolation boundary and normal completion must not hide that lifecycle uncertainty. pub fn create_disposable_context( &mut self, port: &mut P, ) -> Result { self.require_active()?; let epoch = self.reserve_epoch()?; - let handle = match port.create_disposable_context(self.id) { + let handle = match port.create_disposable_context(self.id, self.incarnation) { Ok(handle) => handle, Err(DisposableContextCreateError::CreateFailedClean) => { return Err(BrowserSessionError::ContextCreationFailed); } - Err(DisposableContextCreateError::CreateFailedUncertain) => { + Err(DisposableContextCreateError::CreateFailedUncertain(isolation)) => { + if let Some(isolation) = isolation { + self.recovery_evidence.push( + BrowserSessionRecoveryEvidence::PartialCreationIsolation(isolation), + ); + } self.enter_recovery_required(); return Err(BrowserSessionError::ContextCreationUncertain); } @@ -308,16 +371,20 @@ impl BrowserSession { .values() .any(|record| record.handle.isolation == handle.isolation) { + self.recovery_evidence + .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(handle)); self.enter_recovery_required(); return Err(BrowserSessionError::DuplicateDisposableIsolation); } if self.contexts.contains_key(&handle.browsing_context) { + self.recovery_evidence + .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(handle)); self.enter_recovery_required(); return Err(BrowserSessionError::DuplicateBrowsingContext); } let browsing_context = handle.browsing_context; - let authority = Self::authority_for(self.id, &handle, epoch); + let authority = Self::authority_for(self.id, self.incarnation, &handle, epoch); self.contexts.insert( browsing_context, OwnedContextRecord { @@ -330,9 +397,6 @@ impl BrowserSession { } /// Return current presentation authority for an already-owned active context. - /// - /// A raw context identity that was not created through this aggregate cannot enter the authority - /// path and fails closed with [`BrowserSessionError::ContextNotOwned`]. pub fn presentation_authority( &self, browsing_context: BrowsingContextId, @@ -343,14 +407,15 @@ impl BrowserSession { .get(&browsing_context) .filter(|record| record.state == OwnedContextState::Active) .ok_or(BrowserSessionError::ContextNotOwned)?; - Ok(Self::authority_for(self.id, &record.handle, record.epoch)) + Ok(Self::authority_for( + self.id, + self.incarnation, + &record.handle, + record.epoch, + )) } /// Advance one active owned context to a new authority epoch. - /// - /// Navigation, renderer replacement, or another lifecycle boundary can call this transition to - /// invalidate every previously issued token while preserving disposable-context ownership. Epoch - /// identifiers are monotonic authority identities rather than gap-free business counters. pub fn advance_context_epoch( &mut self, browsing_context: BrowsingContextId, @@ -363,45 +428,52 @@ impl BrowserSession { .filter(|record| record.state == OwnedContextState::Active) .ok_or(BrowserSessionError::ContextNotOwned)?; record.epoch = next; - Ok(Self::authority_for(self.id, &record.handle, next)) + Ok(Self::authority_for( + self.id, + self.incarnation, + &record.handle, + next, + )) } /// Destroy the disposable isolation boundary covered by the supplied exact-epoch authority. - /// - /// Authority is validated before any adapter I/O. The same validated mutable record is retained - /// across the port call, so no structurally unreachable second lookup is required. Failed or - /// unproven destruction makes ownership uncertain and places the whole aggregate in - /// `RecoveryRequired`, preventing later authority issuance until explicit reconciliation exists. pub fn destroy_disposable_context( &mut self, authority: &PresentationMutationAuthority, port: &mut P, ) -> Result<(), BrowserSessionError> { let browser_session = self.id; + let incarnation = self.incarnation; let record = self.context_for_authority_mut(authority)?; let handle = record.handle.clone(); - match port.destroy_disposable_context(browser_session, &handle) { + match port.destroy_disposable_context(browser_session, incarnation, &handle) { Ok(()) => { record.state = OwnedContextState::Destroyed; Ok(()) } Err(DisposableContextDestroyError::DestroyFailed) => { record.state = OwnedContextState::Uncertain; + self.recovery_evidence + .push(BrowserSessionRecoveryEvidence::UnprovenDestruction(handle)); self.enter_recovery_required(); Err(BrowserSessionError::ContextDestructionFailed) } } } - /// Record browser transport loss and invalidate all still-active context authority. + /// Record browser transport loss independently from ownership-recovery state. /// - /// Returns `true` only for the first transition to `TransportLost`; repeated reports are idempotent. + /// Returns `true` only for the first observed transport loss. If ownership was already uncertain, + /// `RecoveryRequired` remains the lifecycle state while the transport-loss fact is retained. pub fn record_transport_loss(&mut self) -> bool { - if self.state != BrowserSessionState::Active { + if self.transport_lost || self.state == BrowserSessionState::Ended { return false; } - self.state = BrowserSessionState::TransportLost; - self.mark_active_contexts_uncertain(); + self.transport_lost = true; + if self.state == BrowserSessionState::Active { + self.state = BrowserSessionState::TransportLost; + self.mark_active_contexts_uncertain(); + } true } @@ -419,7 +491,6 @@ impl BrowserSession { Ok(()) } - /// Reject active-only transitions once ownership has ended or become uncertain. fn require_active(&self) -> Result<(), BrowserSessionError> { if self.state == BrowserSessionState::Active { Ok(()) @@ -428,7 +499,6 @@ impl BrowserSession { } } - /// Reserve the next monotonic authority epoch before browser I/O can create remote state. fn reserve_epoch(&mut self) -> Result { let epoch = BrowserContextEpoch(self.next_epoch); self.next_epoch = self @@ -438,27 +508,27 @@ impl BrowserSession { Ok(epoch) } - /// Bind an already-owned disposable handle and epoch into an opaque mutation authority. fn authority_for( browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, handle: &DisposableContextHandle, context_epoch: BrowserContextEpoch, ) -> PresentationMutationAuthority { PresentationMutationAuthority { browser_session, + incarnation, isolation: handle.isolation.clone(), browsing_context: handle.browsing_context, context_epoch, } } - /// Validate exact session, context, isolation, and epoch ownership before mutable adapter I/O. fn context_for_authority_mut( &mut self, authority: &PresentationMutationAuthority, ) -> Result<&mut OwnedContextRecord, BrowserSessionError> { self.require_active()?; - if authority.browser_session != self.id { + if authority.browser_session != self.id || authority.incarnation != self.incarnation { return Err(BrowserSessionError::AuthorityMismatch); } let record = self @@ -473,13 +543,11 @@ impl BrowserSession { Ok(record) } - /// Enter aggregate-wide recovery quarantine and invalidate every still-active context record. fn enter_recovery_required(&mut self) { self.state = BrowserSessionState::RecoveryRequired; self.mark_active_contexts_uncertain(); } - /// Mark active context records uncertain without rewriting already-proven destruction evidence. fn mark_active_contexts_uncertain(&mut self) { for record in self.contexts.values_mut() { if record.state == OwnedContextState::Active { @@ -489,6 +557,17 @@ impl BrowserSession { } } +fn allocate_incarnation( + counter: &AtomicU64, +) -> Result { + let value = counter + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { + current.checked_add(1) + }) + .map_err(|_| BrowserSessionError::IncarnationExhausted)?; + Ok(BrowserSessionIncarnation(value)) +} + #[cfg(test)] #[allow(clippy::expect_used)] mod tests { @@ -501,11 +580,12 @@ mod tests { fail_destroy: bool, create_calls: usize, destroy_calls: usize, + create_incarnations: Vec, + destroy_incarnations: Vec, destroyed_isolations: Vec, } impl TestPort { - /// Build a deterministic lifecycle port for one context/isolation pair. fn new(context: u64, isolation: &str) -> Self { Self { next_handle: DisposableContextHandle::new( @@ -516,31 +596,35 @@ mod tests { fail_destroy: false, create_calls: 0, destroy_calls: 0, + create_incarnations: Vec::new(), + destroy_incarnations: Vec::new(), destroyed_isolations: Vec::new(), } } } impl DisposableContextPort for TestPort { - /// Return the configured handle or bounded creation failure. fn create_disposable_context( &mut self, _browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, ) -> Result { self.create_calls += 1; - match self.create_error { + self.create_incarnations.push(incarnation); + match self.create_error.clone() { Some(error) => Err(error), None => Ok(self.next_handle.clone()), } } - /// Record exact isolation destruction before returning the configured result. fn destroy_disposable_context( &mut self, _browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, context: &DisposableContextHandle, ) -> Result<(), DisposableContextDestroyError> { self.destroy_calls += 1; + self.destroy_incarnations.push(incarnation); self.destroyed_isolations.push(context.isolation.clone()); if self.fail_destroy { Err(DisposableContextDestroyError::DestroyFailed) @@ -550,22 +634,22 @@ mod tests { } } - /// Construct a validated Browser Session transport identifier. fn session_id(value: u64) -> BrowserSessionId { BrowserSessionId::new(value).expect("valid session id") } - /// Construct a validated browsing-context identifier. fn context_id(value: u64) -> BrowsingContextId { BrowsingContextId::new(value).expect("valid context id") } - /// Construct a validated disposable isolation identifier. fn isolation_id(value: &str) -> DisposableIsolationId { DisposableIsolationId::parse(value).expect("valid isolation id") } - /// Validate isolation identity bounds and accessor behavior. + fn session(value: u64) -> BrowserSession { + BrowserSession::start(session_id(value)).expect("incarnation capacity") + } + #[test] fn isolation_identity_validation_is_bounded() { assert_eq!( @@ -586,43 +670,38 @@ mod tests { ); let valid = isolation_id("webdriver-user-context-10"); assert_eq!(valid.as_str(), "webdriver-user-context-10"); - let handle = DisposableContextHandle::new(valid.clone(), context_id(10)); assert_eq!(handle.isolation(), &valid); assert_eq!(handle.browsing_context(), context_id(10)); } - /// Prove that raw context addressability cannot mint presentation authority. #[test] fn disposable_creation_is_the_only_raw_context_entry_to_authority() { - let mut session = BrowserSession::start(session_id(1)); + let mut session = session(1); let mut port = TestPort::new(10, "isolation-10"); - assert_eq!(session.id(), session_id(1)); - assert_eq!(session.state(), BrowserSessionState::Active); + assert_ne!(session.incarnation().value(), 0); + assert!(!session.transport_is_lost()); + assert!(session.recovery_evidence().is_empty()); assert_eq!( session.presentation_authority(context_id(10)), Err(BrowserSessionError::ContextNotOwned) ); - let authority = session .create_disposable_context(&mut port) .expect("owned disposable context"); - assert_eq!(port.create_calls, 1); + assert_eq!(port.create_incarnations, vec![session.incarnation()]); assert_eq!(authority.browser_session(), session_id(1)); + assert_eq!(authority.incarnation(), session.incarnation()); assert_eq!(authority.isolation().as_str(), "isolation-10"); assert_eq!(authority.browsing_context(), context_id(10)); assert_eq!(authority.context_epoch().value(), 1); - assert_eq!( - session.presentation_authority(context_id(10)), - Ok(authority) - ); + assert_eq!(session.presentation_authority(context_id(10)), Ok(authority)); } - /// Distinguish proved-clean creation failure from uncertain partial creation. #[test] - fn creation_failure_is_typed_clean_or_recovery_required() { - let mut clean_session = BrowserSession::start(session_id(2)); + fn creation_failure_preserves_known_recovery_identity() { + let mut clean_session = session(2); let mut clean_port = TestPort::new(20, "isolation-20"); clean_port.create_error = Some(DisposableContextCreateError::CreateFailedClean); assert_eq!( @@ -630,77 +709,82 @@ mod tests { Err(BrowserSessionError::ContextCreationFailed) ); assert_eq!(clean_session.state(), BrowserSessionState::Active); - clean_session - .end() - .expect("proved-clean failure can end normally"); + clean_session.end().expect("clean failure can end"); - let mut uncertain_session = BrowserSession::start(session_id(21)); - let mut uncertain_port = TestPort::new(210, "isolation-210"); - uncertain_port.create_error = Some(DisposableContextCreateError::CreateFailedUncertain); + let mut unknown_session = session(21); + let mut unknown_port = TestPort::new(210, "isolation-210"); + unknown_port.create_error = Some(DisposableContextCreateError::CreateFailedUncertain(None)); assert_eq!( - uncertain_session.create_disposable_context(&mut uncertain_port), + unknown_session.create_disposable_context(&mut unknown_port), Err(BrowserSessionError::ContextCreationUncertain) ); + assert!(unknown_session.recovery_evidence().is_empty()); + + let known = isolation_id("partial-user-context-211"); + let mut known_session = session(22); + let mut known_port = TestPort::new(211, "unused"); + known_port.create_error = Some(DisposableContextCreateError::CreateFailedUncertain(Some( + known.clone(), + ))); assert_eq!( - uncertain_session.state(), - BrowserSessionState::RecoveryRequired + known_session.create_disposable_context(&mut known_port), + Err(BrowserSessionError::ContextCreationUncertain) ); assert_eq!( - uncertain_session.end(), - Err(BrowserSessionError::SessionNotActive) + known_session.recovery_evidence(), + &[BrowserSessionRecoveryEvidence::PartialCreationIsolation(known)] ); + assert_eq!(known_session.end(), Err(BrowserSessionError::SessionNotActive)); } - /// Duplicate adapter output must prevent a false normal session completion. #[test] - fn duplicate_adapter_output_requires_recovery() { - let mut duplicate_context_session = BrowserSession::start(session_id(3)); + fn duplicate_adapter_output_preserves_offending_handle() { + let mut duplicate_context_session = session(3); let mut first_context_port = TestPort::new(30, "isolation-30-a"); duplicate_context_session .create_disposable_context(&mut first_context_port) .expect("first owned context"); + let duplicate_context_handle = DisposableContextHandle::new( + isolation_id("isolation-30-b"), + context_id(30), + ); let mut duplicate_context_port = TestPort::new(30, "isolation-30-b"); assert_eq!( duplicate_context_session.create_disposable_context(&mut duplicate_context_port), Err(BrowserSessionError::DuplicateBrowsingContext) ); assert_eq!( - duplicate_context_session.state(), - BrowserSessionState::RecoveryRequired - ); - assert_eq!( - duplicate_context_session.end(), - Err(BrowserSessionError::SessionNotActive) - ); - assert_eq!( - duplicate_context_session.create_disposable_context(&mut duplicate_context_port), - Err(BrowserSessionError::SessionNotActive) + duplicate_context_session.recovery_evidence(), + &[BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( + duplicate_context_handle + )] ); - let mut duplicate_isolation_session = BrowserSession::start(session_id(31)); + let mut duplicate_isolation_session = session(31); let mut first_isolation_port = TestPort::new(310, "isolation-31"); duplicate_isolation_session .create_disposable_context(&mut first_isolation_port) .expect("first owned isolation"); + let duplicate_isolation_handle = DisposableContextHandle::new( + isolation_id("isolation-31"), + context_id(311), + ); let mut duplicate_isolation_port = TestPort::new(311, "isolation-31"); assert_eq!( duplicate_isolation_session.create_disposable_context(&mut duplicate_isolation_port), Err(BrowserSessionError::DuplicateDisposableIsolation) ); assert_eq!( - duplicate_isolation_session.state(), - BrowserSessionState::RecoveryRequired - ); - assert_eq!( - duplicate_isolation_session.presentation_authority(context_id(310)), - Err(BrowserSessionError::SessionNotActive) + duplicate_isolation_session.recovery_evidence(), + &[BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( + duplicate_isolation_handle + )] ); } - /// Reserve authority capacity before browser I/O so exhaustion cannot leak a context. #[test] fn epoch_exhaustion_prevents_creation_io() { - let mut exhausted_session = BrowserSession::start(session_id(4)); + let mut exhausted_session = session(4); exhausted_session.next_epoch = u64::MAX; let mut unused_port = TestPort::new(40, "isolation-40"); assert_eq!( @@ -710,14 +794,17 @@ mod tests { assert_eq!(unused_port.create_calls, 0); } - /// Reject stale epoch and foreign-session authority before destruction I/O. #[test] - fn epoch_advance_invalidates_old_and_cross_session_authority() { - let mut session = BrowserSession::start(session_id(5)); + fn epoch_advance_invalidates_old_and_unknown_authority() { + let mut session = session(5); let mut port = TestPort::new(50, "isolation-50"); let old = session .create_disposable_context(&mut port) .expect("owned context"); + assert_eq!( + session.advance_context_epoch(context_id(51)), + Err(BrowserSessionError::ContextNotOwned) + ); let new = session .advance_context_epoch(context_id(50)) .expect("advanced epoch"); @@ -726,211 +813,167 @@ mod tests { session.destroy_disposable_context(&old, &mut port), Err(BrowserSessionError::AuthorityMismatch) ); - - let mut foreign = BrowserSession::start(session_id(6)); - let mut foreign_port = TestPort::new(60, "isolation-60"); - foreign - .create_disposable_context(&mut foreign_port) - .expect("foreign context"); - assert_eq!( - foreign.destroy_disposable_context(&new, &mut foreign_port), - Err(BrowserSessionError::AuthorityMismatch) - ); - session .destroy_disposable_context(&new, &mut port) .expect("destroy current epoch"); - assert_eq!(port.destroy_calls, 1); + assert_eq!(port.destroy_incarnations, vec![session.incarnation()]); assert_eq!( session.presentation_authority(context_id(50)), Err(BrowserSessionError::ContextNotOwned) ); - assert_eq!( - session.advance_context_epoch(context_id(50)), - Err(BrowserSessionError::ContextNotOwned) - ); assert_eq!( session.destroy_disposable_context(&new, &mut port), Err(BrowserSessionError::ContextNotOwned) ); - assert_eq!(port.destroy_calls, 1); } - /// Prove two aggregate incarnations cannot cross isolation ownership boundaries. #[test] - fn two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary() { - let shared_session = session_id(12); - let shared_context = context_id(120); - let mut session_a = BrowserSession::start(shared_session); - let mut session_b = BrowserSession::start(shared_session); - let mut port_a = TestPort::new(120, "user-context-a"); - let mut port_b = TestPort::new(120, "user-context-b"); + fn cross_session_and_foreign_isolation_authority_fail_before_io() { + let mut owner = session(6); + let mut owner_port = TestPort::new(60, "isolation-60"); + let authority = owner + .create_disposable_context(&mut owner_port) + .expect("owner context"); + + let mut foreign = session(7); + let mut foreign_port = TestPort::new(60, "isolation-60"); + foreign + .create_disposable_context(&mut foreign_port) + .expect("foreign context"); + assert_eq!( + foreign.destroy_disposable_context(&authority, &mut foreign_port), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!(foreign_port.destroy_calls, 0); + let forged = PresentationMutationAuthority { + browser_session: owner.id(), + incarnation: owner.incarnation(), + isolation: isolation_id("foreign-isolation"), + browsing_context: authority.browsing_context(), + context_epoch: authority.context_epoch(), + }; + assert_eq!( + owner.destroy_disposable_context(&forged, &mut owner_port), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!(owner_port.destroy_calls, 0); + } + + #[test] + fn sequential_incarnation_reuse_rejects_stale_authority() { + let shared_id = session_id(8); + let mut session_a = BrowserSession::start(shared_id).expect("A incarnation"); + let mut port_a = TestPort::new(80, "reused-user-context"); let authority_a = session_a .create_disposable_context(&mut port_a) - .expect("owner A context"); + .expect("A context"); + session_a + .destroy_disposable_context(&authority_a, &mut port_a) + .expect("A destroy"); + session_a.end().expect("A end"); + + let mut session_b = BrowserSession::start(shared_id).expect("B incarnation"); + let mut port_b = TestPort::new(80, "reused-user-context"); let authority_b = session_b .create_disposable_context(&mut port_b) - .expect("owner B context"); - assert_eq!(authority_a.browsing_context(), shared_context); - assert_eq!(authority_b.browsing_context(), shared_context); - assert_ne!(authority_a.isolation(), authority_b.isolation()); - + .expect("B context"); + assert_ne!(session_a.incarnation(), session_b.incarnation()); assert_eq!( session_b.destroy_disposable_context(&authority_a, &mut port_b), Err(BrowserSessionError::AuthorityMismatch) ); assert_eq!(port_b.destroy_calls, 0); - session_b .destroy_disposable_context(&authority_b, &mut port_b) - .expect("B destroys only its isolation boundary"); + .expect("B destroy"); assert_eq!(port_b.destroy_calls, 1); - assert_eq!( - port_b.destroyed_isolations, - vec![isolation_id("user-context-b")] - ); - assert_ne!(&port_b.destroyed_isolations[0], authority_a.isolation()); } - /// Reject an unknown context before any adapter destruction call. #[test] - fn unknown_internal_authority_cannot_trigger_destroy_io() { - let mut session = BrowserSession::start(session_id(11)); - let mut port = TestPort::new(110, "isolation-110"); - let unknown = PresentationMutationAuthority { - browser_session: session_id(11), - isolation: isolation_id("isolation-111"), - browsing_context: context_id(111), - context_epoch: BrowserContextEpoch(1), - }; - - assert_eq!( - session.destroy_disposable_context(&unknown, &mut port), - Err(BrowserSessionError::ContextNotOwned) - ); - assert_eq!(port.destroy_calls, 0); - } - - /// Reject same-context authority with a foreign isolation identity before I/O. - #[test] - fn foreign_isolation_authority_cannot_trigger_destroy_io() { - let mut session = BrowserSession::start(session_id(13)); - let mut port = TestPort::new(130, "isolation-130"); + 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 forged = PresentationMutationAuthority { - browser_session: authority.browser_session(), - isolation: isolation_id("isolation-foreign"), - browsing_context: authority.browsing_context(), - context_epoch: authority.context_epoch(), - }; - assert_eq!( - session.destroy_disposable_context(&forged, &mut port), - Err(BrowserSessionError::AuthorityMismatch) + let expected_handle = DisposableContextHandle::new( + isolation_id("isolation-90"), + context_id(90), ); - assert_eq!(port.destroy_calls, 0); - } - - /// Quarantine the aggregate after failed destruction and keep loss reports idempotent. - #[test] - fn destroy_failure_quarantines_authority_and_transport_loss_is_idempotent() { - let mut session = BrowserSession::start(session_id(7)); - let mut port = TestPort::new(70, "isolation-70"); - let authority = session - .create_disposable_context(&mut port) - .expect("owned context"); port.fail_destroy = true; assert_eq!( session.destroy_disposable_context(&authority, &mut port), Err(BrowserSessionError::ContextDestructionFailed) ); - assert_eq!(port.destroy_calls, 1); assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); assert_eq!( - session.presentation_authority(context_id(70)), - Err(BrowserSessionError::SessionNotActive) + session.recovery_evidence(), + &[BrowserSessionRecoveryEvidence::UnprovenDestruction( + expected_handle + )] ); - assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); - assert!(!session.record_transport_loss()); + assert!(!session.transport_is_lost()); + assert!(session.record_transport_loss()); + assert!(session.transport_is_lost()); assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); + assert!(!session.record_transport_loss()); assert_eq!( session.create_disposable_context(&mut port), Err(BrowserSessionError::SessionNotActive) ); assert_eq!( - session.presentation_authority(context_id(70)), + session.presentation_authority(context_id(90)), Err(BrowserSessionError::SessionNotActive) ); assert_eq!( - session.advance_context_epoch(context_id(70)), + session.advance_context_epoch(context_id(90)), Err(BrowserSessionError::SessionNotActive) ); assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); } - /// Require proven context destruction before a normal session end. #[test] - fn successful_destruction_is_required_before_normal_end() { - let mut session = BrowserSession::start(session_id(8)); - let mut port = TestPort::new(80, "isolation-80"); + fn transport_loss_invalidates_active_contexts_and_is_idempotent() { + let mut session = session(10); + let mut port = TestPort::new(100, "isolation-100"); let authority = session .create_disposable_context(&mut port) .expect("owned context"); + assert!(session.record_transport_loss()); + assert_eq!(session.state(), BrowserSessionState::TransportLost); + assert!(session.transport_is_lost()); + assert!(!session.record_transport_loss()); assert_eq!( - session.end(), - Err(BrowserSessionError::ActiveContextRemains) - ); - session - .destroy_disposable_context(&authority, &mut port) - .expect("proven destruction"); - session.end().expect("all owned contexts destroyed"); - assert_eq!(session.state(), BrowserSessionState::Ended); - assert_eq!( - session.presentation_authority(context_id(80)), - Err(BrowserSessionError::SessionNotActive) - ); - assert_eq!( - session.advance_context_epoch(context_id(80)), + session.destroy_disposable_context(&authority, &mut port), Err(BrowserSessionError::SessionNotActive) ); - assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); + assert_eq!(port.destroy_calls, 0); } - /// Invalidate still-active authority immediately after transport loss. #[test] - fn transport_loss_invalidates_still_active_contexts() { - let mut session = BrowserSession::start(session_id(9)); - let mut port = TestPort::new(90, "isolation-90"); + fn normal_end_requires_proven_destruction_and_ignores_late_transport_report() { + let mut session = session(11); + let mut port = TestPort::new(110, "isolation-110"); let authority = session .create_disposable_context(&mut port) .expect("owned context"); - assert!(session.record_transport_loss()); - assert_eq!( - session.destroy_disposable_context(&authority, &mut port), - Err(BrowserSessionError::SessionNotActive) - ); - assert_eq!(port.destroy_calls, 0); + assert_eq!(session.end(), Err(BrowserSessionError::ActiveContextRemains)); + session + .destroy_disposable_context(&authority, &mut port) + .expect("proven destruction"); + session.end().expect("normal end"); + assert_eq!(session.state(), BrowserSessionState::Ended); + assert!(!session.record_transport_loss()); + assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); } - /// Reject epoch advancement for unknown and exhausted contexts. #[test] - fn advance_context_epoch_rejects_unknown_and_exhausted_contexts() { - let mut session = BrowserSession::start(session_id(10)); - assert_eq!( - session.advance_context_epoch(context_id(100)), - Err(BrowserSessionError::ContextNotOwned) - ); - - let mut port = TestPort::new(101, "isolation-101"); - session - .create_disposable_context(&mut port) - .expect("owned context"); - session.next_epoch = u64::MAX; + fn incarnation_allocator_fails_closed_before_wrap() { + let counter = AtomicU64::new(u64::MAX); assert_eq!( - session.advance_context_epoch(context_id(101)), - Err(BrowserSessionError::EpochExhausted) + allocate_incarnation(&counter), + Err(BrowserSessionError::IncarnationExhausted) ); } } From 2bd47344ddfce32a81491d6e5d647aa5376e9da8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:11:39 +0900 Subject: [PATCH 48/58] test: preserve recovery evidence across transport loss --- .../destroy_failure_requires_recovery.rs | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 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 0e79ec304..669f0723c 100644 --- a/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs +++ b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs @@ -1,7 +1,7 @@ use originweave_browser_session::{ - BrowserSession, BrowserSessionError, BrowserSessionState, DisposableContextCreateError, - DisposableContextDestroyError, DisposableContextHandle, DisposableContextPort, - DisposableIsolationId, + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, BrowserSessionRecoveryEvidence, + BrowserSessionState, DisposableContextCreateError, DisposableContextDestroyError, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; @@ -30,6 +30,7 @@ impl DisposableContextPort for FailingDestroyPort { fn create_disposable_context( &mut self, _browser_session: BrowserSessionId, + _incarnation: BrowserSessionIncarnation, ) -> Result { self.create_calls += 1; Ok(self.next_handle.clone()) @@ -38,6 +39,7 @@ impl DisposableContextPort for FailingDestroyPort { fn destroy_disposable_context( &mut self, _browser_session: BrowserSessionId, + _incarnation: BrowserSessionIncarnation, _context: &DisposableContextHandle, ) -> Result<(), DisposableContextDestroyError> { self.destroy_calls += 1; @@ -45,14 +47,18 @@ impl DisposableContextPort for FailingDestroyPort { } } -/// An unproven destroy must quarantine the whole aggregate before any later browser I/O. +/// An unproven destroy must retain exact recovery evidence and reject later normal authority. #[test] fn destroy_failure_requires_recovery_before_any_new_authority() -> Result<(), &'static str> { let session_id = BrowserSessionId::new(501) .map_err(|_| "static fixture browser session id must be valid")?; let context_id = BrowsingContextId::new(5010) .map_err(|_| "static fixture browsing context id must be valid")?; - let mut session = BrowserSession::start(session_id); + let expected_isolation = DisposableIsolationId::parse("user-context-501") + .map_err(|_| "static fixture recovery isolation id must be valid")?; + let expected_handle = DisposableContextHandle::new(expected_isolation, context_id); + let mut session = BrowserSession::start(session_id) + .map_err(|_| "browser session incarnation must be available")?; let mut failing_port = FailingDestroyPort::new(5010, "user-context-501")?; let authority = session @@ -64,6 +70,18 @@ fn destroy_failure_requires_recovery_before_any_new_authority() -> Result<(), &' ); assert_eq!(failing_port.destroy_calls, 1); assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); + assert_eq!( + session.recovery_evidence(), + &[BrowserSessionRecoveryEvidence::UnprovenDestruction( + expected_handle + )] + ); + assert!(!session.transport_is_lost()); + + assert!(session.record_transport_loss()); + assert!(session.transport_is_lost()); + assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); + assert!(!session.record_transport_loss()); let mut later_port = FailingDestroyPort::new(5011, "user-context-501-later")?; assert_eq!( From fa048fb7d8c54143a2514ee1034dd13e2daba6ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:11:58 +0900 Subject: [PATCH 49/58] test: require port-scoped session incarnation --- .../tests/sequential_incarnation_reuse.rs | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs index d89335372..355201280 100644 --- a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs +++ b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs @@ -1,5 +1,5 @@ use originweave_browser_session::{ - BrowserSession, BrowserSessionError, DisposableContextCreateError, + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, DisposableContextCreateError, DisposableContextDestroyError, DisposableContextHandle, DisposableContextPort, DisposableIsolationId, }; @@ -8,7 +8,8 @@ use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct ReusingPort { handle: DisposableContextHandle, - destroy_calls: usize, + create_incarnations: Vec, + destroy_incarnations: Vec, } impl ReusingPort { @@ -19,7 +20,8 @@ impl ReusingPort { .map_err(|_| "static fixture browsing context id must be valid")?; Ok(Self { handle: DisposableContextHandle::new(isolation, browsing_context), - destroy_calls: 0, + create_incarnations: Vec::new(), + destroy_incarnations: Vec::new(), }) } } @@ -28,16 +30,19 @@ impl DisposableContextPort for ReusingPort { fn create_disposable_context( &mut self, _browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, ) -> Result { + self.create_incarnations.push(incarnation); Ok(self.handle.clone()) } fn destroy_disposable_context( &mut self, _browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, _context: &DisposableContextHandle, ) -> Result<(), DisposableContextDestroyError> { - self.destroy_calls += 1; + self.destroy_incarnations.push(incarnation); Ok(()) } } @@ -49,7 +54,8 @@ fn stale_authority_cannot_cross_sequential_session_incarnations() -> Result<(), .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 mut session_a = BrowserSession::start(session_id) + .map_err(|_| "first browser session incarnation must be available")?; let authority_a = session_a .create_disposable_context(&mut port_a) .map_err(|_| "first disposable context creation must succeed")?; @@ -61,20 +67,24 @@ fn stale_authority_cannot_cross_sequential_session_incarnations() -> Result<(), .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 mut session_b = BrowserSession::start(session_id) + .map_err(|_| "second browser session incarnation must be available")?; let authority_b = session_b .create_disposable_context(&mut port_b) .map_err(|_| "second disposable context creation must succeed")?; + assert_ne!(session_a.incarnation(), session_b.incarnation()); + assert_eq!(port_a.create_incarnations, vec![session_a.incarnation()]); + assert_eq!(port_b.create_incarnations, vec![session_b.incarnation()]); assert_eq!( session_b.destroy_disposable_context(&authority_a, &mut port_b), Err(BrowserSessionError::AuthorityMismatch) ); - assert_eq!(port_b.destroy_calls, 0); + assert!(port_b.destroy_incarnations.is_empty()); session_b .destroy_disposable_context(&authority_b, &mut port_b) .map_err(|_| "current incarnation authority must remain valid")?; - assert_eq!(port_b.destroy_calls, 1); + assert_eq!(port_b.destroy_incarnations, vec![session_b.incarnation()]); Ok(()) } From 0e2e75484578bb11141d712012e10a9b88d1893d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:12:22 +0900 Subject: [PATCH 50/58] test: bind lifecycle contracts to recovery and incarnation evidence --- ...test_browser_session_lifecycle_contract.py | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py index ba86342bb..6e4487988 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -35,23 +35,26 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: self.assertIn("pub trait DisposableContextPort", source) self.assertIn("pub struct DisposableIsolationId", source) self.assertIn("pub struct DisposableContextHandle", source) + self.assertIn("pub struct BrowserSessionIncarnation", source) self.assertIn("pub struct PresentationMutationAuthority", source) + self.assertIn("pub enum BrowserSessionRecoveryEvidence", source) self.assertIn("BrowserSessionState::RecoveryRequired", source) self.assertIn("pub enum DisposableContextCreateError", source) self.assertIn("pub enum DisposableContextDestroyError", source) self.assertNotIn("pub enum DisposableContextPortError", source) self.assertIn("CreateFailedClean", source) self.assertIn("CreateFailedUncertain", source) + self.assertIn("PartialCreationIsolation", source) + self.assertIn("DuplicateAdapterHandle", source) + self.assertIn("UnprovenDestruction", source) self.assertIn("create_disposable_context", source) self.assertIn("advance_context_epoch", source) self.assertIn("record_transport_loss", source) + self.assertIn("transport_is_lost", source) + self.assertIn("recovery_evidence", source) self.assertIn("user-context identifier", source) self.assertIn("Reconstructing cleanup authority", source) - self.assertIn("duplicate_adapter_output_requires_recovery", source) - self.assertIn( - "two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary", - source, - ) + self.assertIn("sequential_incarnation_reuse_rejects_stale_authority", source) authority_impl = source.split("impl PresentationMutationAuthority", 1)[1].split( "enum OwnedContextState", 1 @@ -59,20 +62,28 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: self.assertNotIn("pub fn new", authority_impl) self.assertNotIn("pub const fn new", authority_impl) - def test_uncertain_destroy_is_an_aggregate_recovery_contract(self) -> None: - """Unproven cleanup must stop all later authority before browser I/O.""" + def test_hostile_recovery_and_reincarnation_fixtures_remain_external(self) -> None: + """Recovery and sequential reuse invariants must be executable outside crate internals.""" - source = (CRATE / "src/lib.rs").read_text(encoding="utf-8") - hostile = ( + destroy_hostile = ( CRATE / "tests/destroy_failure_requires_recovery.rs" ).read_text(encoding="utf-8") - self.assertIn("self.enter_recovery_required();", source) + reincarnation_hostile = ( + CRATE / "tests/sequential_incarnation_reuse.rs" + ).read_text(encoding="utf-8") self.assertIn( "destroy_failure_requires_recovery_before_any_new_authority", - hostile, + destroy_hostile, + ) + self.assertIn("BrowserSessionRecoveryEvidence::UnprovenDestruction", destroy_hostile) + self.assertIn("assert!(session.record_transport_loss());", destroy_hostile) + self.assertIn("assert!(!session.record_transport_loss());", destroy_hostile) + self.assertIn( + "stale_authority_cannot_cross_sequential_session_incarnations", + reincarnation_hostile, ) - self.assertIn("BrowserSessionState::RecoveryRequired", hostile) - self.assertIn("assert_eq!(later_port.create_calls, 0);", hostile) + self.assertIn("assert_ne!(session_a.incarnation(), session_b.incarnation());", reincarnation_hostile) + self.assertIn("assert!(port_b.destroy_incarnations.is_empty());", reincarnation_hostile) def test_architecture_decision_and_traceability_are_explicit(self) -> None: """Disposable ownership must remain a Proposed, standards-traced active-PR claim.""" @@ -89,17 +100,26 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: self.assertIn("Status: Proposed", adr) self.assertIn("WD-webdriver-bidi-20260909", adr) self.assertIn("RecoveryRequired", adr) + self.assertIn("BrowserSessionIncarnation", adr) + self.assertIn("BrowserSessionRecoveryEvidence", adr) self.assertIn("DisposableContextCreateError", adr) self.assertIn("DisposableContextDestroyError", adr) self.assertIn("CreateFailedClean", adr) self.assertIn("CreateFailedUncertain", adr) + self.assertIn("transport liveness", adr) + self.assertIn("sequential", adr) self.assertIn("unproven destruction", adr) self.assertIn("IMPLEMENTED_ON_ACTIVE_PR", trace) self.assertIn("RecoveryRequired", trace) - self.assertIn("unproven destruction quarantines the aggregate", trace) + self.assertIn("BrowserSessionIncarnation", trace) + self.assertIn("lossless recovery evidence", trace) + self.assertIn("transport liveness", trace) + self.assertIn("sequential ABA", trace) self.assertIn("command ACK", trace) self.assertIn("PresentationMutationAuthority", uml) + self.assertIn("BrowserSessionIncarnation", uml) self.assertIn("RecoveryRequired", uml) + self.assertIn("transport_lost", uml) self.assertIn("DisposableContextDestroyError / cleanup unproven", uml) self.assertNotIn("IMPLEMENTED_ON_PROTECTED_MAIN", trace) From 518d6c80e10c8da9e2f20931ff7f95af5112e4ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:13:57 +0900 Subject: [PATCH 51/58] docs: define Browser Session incarnation and recovery evidence --- ...er-session-disposable-context-authority.md | 142 ++++++++---------- 1 file changed, 66 insertions(+), 76 deletions(-) diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index e984b63aa..345071fd6 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -5,131 +5,121 @@ ## Context -OriginWeave's WebDriver BiDi presentation adapter requires opaque ownership witnesses before it can plan viewport/device-pixel-ratio, timezone, or screen-area mutation. A caller that merely knows a browsing-context identifier therefore cannot overwrite another owner's presentation state and later clear it to an implementation default. +OriginWeave's WebDriver BiDi presentation adapter requires opaque ownership witnesses before viewport/device-pixel-ratio, timezone, or screen-area mutation can be planned. A caller that merely knows a browser-session or browsing-context identifier therefore cannot overwrite another owner's presentation state and later clear it to an implementation default. -The Browser Session boundary must also establish why a context is exclusively OriginWeave-owned before any presentation-mutation authority is issued. External browser-session and browsing-context identifiers can be reused across aggregate incarnations, so ownership cannot be reconstructed from `(BrowserSessionId, BrowsingContextId, local epoch)`. The active implementation carries a separate non-aliasing disposable isolation identity through authority validation and destruction I/O. +The Browser Session boundary must establish why a context is exclusively OriginWeave-owned before presentation authority exists. External browser-session, user-context/isolation, and browsing-context identifiers are protocol addressability. They may be reused after a prior lifecycle ends, so `(BrowserSessionId, DisposableIsolationId, BrowsingContextId, local epoch)` is not by itself a durable capability generation. -A second lifecycle gap appears whenever the adapter does not have a proved-clean post-condition. During creation, an adapter can fail after browser state may already have been created, or can return a duplicate context/isolation identity. During destruction, an adapter can fail after the cleanup command has been sent without proving that the exact owned boundary is gone. In either case OriginWeave cannot safely keep the aggregate `Active`: further authority issuance would continue operating beside unresolved browser state. Creation and destruction therefore require explicit clean-versus-uncertain handling and a recovery-required state. +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. -The 9 September 2026 WebDriver BiDi Working Draft provides a standards-aligned isolation identity. A user context has a user-context id defined as a unique string set when the user context is created. `browser.createUserContext` creates a new user context, `browsingContext.create` can create a browsing context inside it, and `browser.removeUserContext` removes that user context after closing its navigables. These protocol operations are adapter capabilities; they do not themselves define OriginWeave policy authority, and a command acknowledgement alone is not cleanup proof. +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. -## Decision drivers - -- Remote-issued browser-session and browsing-context identifiers are addressability, not mutation authority. -- Reuse of external session/context identifiers across aggregate incarnations must not create authority aliasing. -- Shared or attached human contexts must never acquire disposable-owner semantics by implication. -- Presentation reset must not destroy a predecessor override owned by another task/session. -- Destruction I/O must be scoped by the exact disposable isolation boundary, not reconstructed from aliasable session/context identifiers. -- Creation failure must distinguish proved-clean failure from an uncertain post-condition. -- Creation-only and destruction-only adapter failures must be different types so phase-invalid outcomes are not representable. -- Duplicate or partial-create outcomes must not permit false normal completion. -- An unproven destroy must quarantine the aggregate before any later context creation or authority issuance. -- Navigation, renderer replacement, crash, cleanup failure, and transport loss must invalidate stale authority. -- The Browser Session domain must remain independent of WebDriver BiDi, CDP, MCP, and LLM policy decisions. -- An adapter acknowledgement is not a successful cleanup post-condition. +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. -## Assumptions and authority boundaries - -`originweave-browser-session` owns Browser Session lifecycle state, owned-context membership, monotonic context epochs, validated disposable-isolation identity, recovery-required state, and opaque presentation-mutation authority. It consumes validated `BrowserSessionId` and `BrowsingContextId` values from `originweave-core`. +## Decision drivers -A narrow `DisposableContextPort` is the anti-corruption boundary to a future browser adapter. The port must return a `DisposableContextHandle` containing the browsing-context address and a live-lifetime non-aliasing `DisposableIsolationId`. For WebDriver BiDi, the adapter proof obligation is a one-to-one mapping from that isolation id to the specification-defined unique user-context id returned by fresh user-context creation. The same handle must scope destruction; reconstructing cleanup authority from `(BrowserSessionId, BrowsingContextId)` is forbidden. +- Raw WebDriver/BiDi identifiers are addressability, not mutation or cleanup authority. +- Sequential aggregate recreation must not make a retained stale authority valid again. +- The lifecycle adapter must receive the same non-reused session incarnation used by authority validation; an aggregate-only nonce is insufficient. +- Known remote identities from partial creation, duplicate output, or unproven destruction must be retained as recovery evidence without becoming command authority. +- Ownership recovery and transport liveness must remain orthogonal. +- Duplicate or uncertain outcomes fail closed and must not permit false normal completion. +- Destruction I/O must use the exact stored handle and session incarnation rather than reconstructing authority from raw identifiers. +- Browser Session remains the domain authority; WebDriver BiDi, CDP, MCP, and LLMs remain adapters or consumers. -Creation and destruction expose separate failure types. `DisposableContextCreateError::CreateFailedClean` is valid only when the adapter can prove that no disposable browser state was created. `DisposableContextCreateError::CreateFailedUncertain` is required after any partial-create or unknown post-condition. An uncertain outcome moves the aggregate to `RecoveryRequired`, invalidates active owned-context authority, blocks further creation/authority issuance, and prevents normal `end()` until a separate reconciliation design proves what happened remotely. A destruction-only failure is not representable from the creation method. +## Decision -Destruction returns `DisposableContextDestroyError`. Its failure means destruction could not be proved: the exact record becomes uncertain and the whole aggregate moves to `RecoveryRequired`; every remaining active context becomes uncertain and all active-only transitions fail before further adapter I/O. Creation-only failures are not representable from the destruction method. The current slice intentionally has no implicit retry or reopen transition because doing so would restore authority while remote ownership remains unresolved. +Introduce `originweave-browser-session` as an independent Rust bounded context and retain ADR status `Proposed` until protected-main and real-browser acceptance exist. -`DisposableIsolationId` is addressability and lifecycle identity, not policy or presentation authority. Callers can validate an identifier value, but they cannot mint `PresentationMutationAuthority`; only the Browser Session aggregate can bind a port-created isolation boundary to a context epoch and issue the opaque authority token. +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. -The implementation deliberately does not convert `PresentationMutationAuthority` into the WebDriver BiDi crate's private presentation/screen-area witnesses. That bridge belongs to a later integration slice after both sides' contracts are reviewed. It also does not claim real-Chromium cleanup evidence. +## Alternatives considered -## Options considered +### Treat any known context as owned -### A. Treat any known browsing context as owned +Rejected. It restores the authority-confusion defect and allows one task to clear another task's state. -Rejected. It recreates the original authority-confusion defect and allows one task to erase another task's predecessor state. +### Depend only on browser-issued isolation identity -### B. Add only an aggregate-local incarnation or epoch +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 as insufficient. An incarnation field can prevent one aggregate from accepting another aggregate's token, but if adapter destruction is still addressed only by reused session/context identifiers, a valid token from aggregate B can still cause the adapter to destroy aggregate A's boundary. The non-aliasing identity therefore has to reach the port boundary itself. +### Add an aggregate-only random or monotonic nonce -### C. Treat every creation failure as clean +Rejected if it does not reach the lifecycle adapter. It would stop one aggregate from accepting another aggregate's token while still allowing a valid current token to address a remote boundary through aliasable adapter keys. The selected `BrowserSessionIncarnation` participates in both authority validation and port calls. -Rejected. A transport or adapter failure after `browser.createUserContext` may leave a remote boundary whose ownership was never recorded. Normal completion after such a failure would produce false cleanup evidence. +### Persist authority generations globally -### D. Treat every uncertain lifecycle failure as transport loss +Deferred and unnecessary for the current in-process authority model. Presentation authority is not durable across process restart; recovery across restart belongs to evidence/reconciliation design, not silent authority resurrection. -Rejected as semantically imprecise. Browser transport may still be healthy while ownership of one create or destroy attempt is unknown. A distinct `RecoveryRequired` state preserves the causal distinction while remaining fail closed. +### Treat every uncertain lifecycle failure as transport loss -### E. Keep the aggregate active after an unproven destroy +Rejected. Ownership uncertainty and transport liveness answer different operational questions. Collapsing them loses information needed for safe reconciliation. -Rejected. Marking only one record uncertain blocks normal `end()` but still allows new disposable contexts and unrelated authority to be created in an aggregate whose remote cleanup state is unresolved. That compounds uncertainty and weakens the ownership boundary. +### Automatically clean duplicate or partial state -### F. Snapshot every predecessor presentation override and restore it exactly +Rejected. When ownership is ambiguous, cleanup itself can become a cross-owner destructive action. Exact recovery evidence is retained while normal authority stays blocked. -Deferred. Exact predecessor capture can support reusable/attached contexts later, but today OriginWeave does not have a complete standard protocol snapshot for every governed presentation surface. Partial restoration would be a false safety claim. +### Snapshot and restore every predecessor presentation override -### G. Own a disposable isolation lifecycle and issue opaque authority only after proved creation +Deferred. OriginWeave does not yet have a complete queryable predecessor-state contract for every governed presentation surface. Disposable ownership remains the stronger first implementation. -Selected. The Browser Session records a port-proved non-aliasing isolation identity together with its browsing context and epoch. A WebDriver BiDi adapter should map that identity one-to-one to a fresh user context and remove that exact user context during cleanup. Proved-clean create failure may leave the aggregate active; uncertain create failure, duplicate adapter output, or unproven destruction requires recovery. Creation and destruction errors remain method-specific so the ACL cannot express a failure from the wrong lifecycle phase. +## Consequences -## Decision +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. -Introduce `originweave-browser-session` as an independent Rust bounded context with these invariants: - -1. `BrowserSession` is the aggregate root. It begins `Active` and may end normally only after every owned disposable context has proven destruction. -2. A context enters the aggregate's owned set only after `DisposableContextPort::create_disposable_context` succeeds with a `DisposableContextHandle`. Supplying a raw `BrowsingContextId` never creates ownership. -3. The handle contains both the browsing-context address and a `DisposableIsolationId` that the adapter contract requires to be non-aliasing for the live lifetime of the isolation boundary. A WebDriver BiDi adapter maps it one-to-one to the unique user-context id. -4. Successful owned-context creation mints a non-caller-constructible `PresentationMutationAuthority` bound to the exact browser session transport identity, disposable isolation identity, browsing context, and context epoch. -5. Two aggregates may reuse the same external `BrowserSessionId`, `BrowsingContextId`, and local epoch without sharing authority when their disposable isolation identities differ. Foreign isolation authority is rejected before adapter I/O. -6. `DisposableContextCreateError::CreateFailedClean` means no remote boundary exists and leaves the aggregate active. `DisposableContextCreateError::CreateFailedUncertain`, duplicate browsing-context output, or duplicate isolation output moves the aggregate to `RecoveryRequired` and invalidates active authority. Destruction-only failures cannot appear on this method boundary. -7. Advancing the context epoch invalidates previously issued authority. Adapter integration must use this transition at navigation/renderer lifecycle boundaries that invalidate the prior authority scope. -8. Destruction requires exact current authority and passes the stored `DisposableContextHandle` back to the port. The aggregate retains that already-validated mutable record across the port call; it does not perform a second impossible lookup after I/O. The method returns only `DisposableContextDestroyError`, so creation-only outcomes cannot cross into cleanup semantics. -9. If destruction cannot be proved, the failed record becomes `Uncertain`, the Browser Session moves to `RecoveryRequired`, every remaining active record becomes uncertain, and further creation, authority lookup/advance, destruction, and normal end are rejected until an explicit reconciliation design exists. -10. Transport loss moves the Browser Session to `TransportLost`, marks still-active owned contexts uncertain, and prevents further authority issuance. -11. `RecoveryRequired`, `TransportLost`, and `Ended` reject all transitions that require an active session. Reconciliation is a later explicit design; none of these states silently reopens ownership. -12. Epoch sequence numbers are monotonic authority identities, not business counters; gaps are allowed after failed creation or rejected duplicate adapter output. +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. -## Consequences +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.” -Browser Session ownership becomes a domain fact carried through the adapter lifecycle instead of a convention reconstructed from transport identifiers. Proved-clean and uncertain outcomes are no longer conflated, so normal completion or continued mutation cannot hide a potentially leaked browser boundary. Once cleanup becomes uncertain, the aggregate stops issuing new authority rather than accumulating more browser state beside an unresolved boundary. +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. -Method-specific port errors also remove a class of defensive branches that had no valid domain meaning. An adapter cannot report destruction failure from creation or creation failure from destruction, so the aggregate no longer has to interpret an impossible phase transition as a degraded case. +## Security and governance impact -The Browser Session domain relies on an explicit adapter proof obligation for global live-lifetime non-aliasing of `DisposableIsolationId`. For WebDriver BiDi that proof is the standard's unique user-context identifier plus adapter conformance tests that preserve the mapping and remove the exact user context. A generic random adapter token without a verified one-to-one browser lifecycle mapping is not sufficient. +No page-controlled value, raw browser-session id, raw browsing-context id, user-context string, provider/model decision, or LLM output can mint presentation authority. The adapter receives domain-issued incarnation information only as a lifecycle-scoping input and cannot manufacture Browser Session policy authority. -The slice remains incomplete for buyer acceptance. No real Chromium user-context adapter, presentation-witness bridge, observed cleanup receipt, crash/recovery reconciliation, or #299 full browser replay is claimed here. +Unknown or duplicate remote state is quarantined rather than destroyed speculatively. This reduces the risk that recovery logic removes another owner's user context. It does not replace Chromium sandboxing, egress policy, Keyverse secret handling, Wardnet controls, or central workflow security. -## Failure and degraded behavior +## Tests and exact evidence -`DisposableContextCreateError::CreateFailedClean` produces no authority and permits continued active operation because the adapter has proved that no disposable boundary exists. `DisposableContextCreateError::CreateFailedUncertain`, duplicate adapter output, and any `DisposableContextDestroyError` move the Browser Session to `RecoveryRequired`; existing active records become uncertain and all active-only transitions are blocked. Duplicate output is never automatically destroyed because a contract-violating adapter may have returned another owner's state. A failed destroy preserves the exact failed handle as uncertain evidence; it is not retried implicitly and no later adapter I/O is admitted from that aggregate. Transport loss uses the separate `TransportLost` state. Once a Browser Session is `Ended`, `TransportLost`, or `RecoveryRequired`, creation, authority lookup, destruction, epoch advancement, and normal end transitions that require an active session fail closed. +The test suite covers raw-context rejection, bounded isolation identity parsing, typed clean/uncertain creation, retained partial identity, duplicate-handle evidence, epoch exhaustion, stale epoch rejection, foreign-session/isolation rejection, destruction failure, transport loss, normal end, and incarnation-allocation exhaustion. -## Security / privacy / governance impact +A dedicated hostile test, `stale_authority_cannot_cross_sequential_session_incarnations`, creates aggregate A, destroys and ends it, creates aggregate B with the same external session/user-context/browsing-context values and local epoch, and requires A's retained authority to fail before B adapter I/O while B's current authority succeeds. The port records incarnation values so the test also proves that the lifecycle mapping receives the new generation. -Disposable context ownership reduces cross-task presentation-state interference and is compatible with isolated Agent Task profiles. Typed lifecycle outcomes prevent a failed browser command from being misreported as a clean lifecycle or followed by fresh authority while cleanup is unresolved. Method-specific failure types also prevent invalid lifecycle-phase semantics from crossing the Browser Session anti-corruption boundary. This is not a substitute for Chromium sandboxing, egress policy, origin capability policy, Keyverse secret handling, or evidence retention controls. Those remain with their canonical owners. +`destroy_failure_requires_recovery_before_any_new_authority` requires an unproven destruction to retain the exact failed handle, enter `RecoveryRequired`, then record a later real transport loss without erasing ownership evidence; repeated loss reports are idempotent. -No page-controlled value, secret, provider/model choice, LLM result, raw browser-session id, or raw browsing-context id can mint Browser Session presentation authority. +The RED for the sequential ABA defect was captured on exact `ec145963ad8fe19c9416f2b3856b94660082dbf7` in CI `34469580144`: repository contracts and formatting passed, and Rust `Run tests` failed at the new hostile test before Clippy/rustdoc. The production fix and subsequent documentation/test updates must earn a new exact-head GREEN; predecessor evidence does not transfer. -## Tests and acceptance evidence +Repository contracts, canonical formatting, locked Rust tests, strict Clippy, rustdoc/API docs, exact function/line/region/branch coverage, independent review, and applicable central checks remain required before ordinary adoption into #313. -The owning crate tests hostile raw-context lookup, bounded isolation identity parsing and handle accessors, proved-clean versus uncertain creation failure, duplicate adapter output, epoch exhaustion, stale authority, cross-session authority, foreign isolation authority, cleanup failure, transport loss, unknown context, epoch advancement, successful destroy-before-end behavior, and a two-aggregate alias case. Duplicate and uncertain-create branches assert `RecoveryRequired`; the dedicated `destroy_failure_requires_recovery_before_any_new_authority` hostile test requires an unproven destroy to quarantine the whole aggregate and rejects later creation/authority/epoch/end before adapter I/O. +## Buyer acceptance still open -Repository contracts require the bounded context to be a workspace member, keep ADR 0114 indexed, preserve the non-aliasing port contract, retain `RecoveryRequired`, and require distinct `DisposableContextCreateError` and `DisposableContextDestroyError` types. Exact-head CI, Clippy, rustdoc, function/line/region/branch coverage and independent review remain required before integration. Real-browser acceptance is deferred to a later adapter slice and must prove unique user-context creation, page-observed mutation, exact-boundary cleanup/destruction and post-cleanup isolation in pinned Chromium; command ACK alone is not success. +This slice does not yet prove real WebDriver BiDi `browser.createUserContext`/`browsingContext.create`/`browser.removeUserContext` integration, browser-observed destruction, recovery reconciliation, Browser Session→BiDi private-witness conversion, pinned Chromium presentation post-conditions, crash/restart cleanup, #299 3/3 Agent Task replay, or protected-main release/SBOM/provenance/reproducibility/rollback. ## Migration and rollback -This is additive. Until a reviewed adapter bridge consumes the new authority, existing presentation code remains fail closed behind its private ownership witnesses. Rollback removes the new crate, workspace/lockfile entries, tests and Proposed ADR without changing protected Chromium or central workflow policy. +The change remains additive on the active stacked branch. Consumers must adopt the new `BrowserSession::start` result and incarnation-aware `DisposableContextPort` contract. Until a reviewed adapter bridge exists, presentation mutation remains fail closed behind private ownership witnesses. Rollback removes this active-PR bounded-context slice without weakening protected Chromium or central security policy. ## Open follow-ups -- Implement the WebDriver BiDi disposable-user-context adapter using the runtime-qualified protocol contract and prove the one-to-one `DisposableIsolationId` mapping. -- Define the narrow conversion/ACL from `PresentationMutationAuthority` to BiDi presentation/screen-area ownership witnesses without exposing public constructors. -- Specify observed user-context destruction/reconciliation after `RecoveryRequired`, browser crash, or transport loss. -- Replay #299 with three complete real-Chromium trials after the canonical sandbox/runtime owner path is usable. -- Evaluate exact predecessor capture/restore only if attached/reusable contexts become a buyer requirement. +- Implement the WebDriver BiDi disposable-user-context adapter with incarnation-scoped mapping and observed destruction post-condition. +- Define the Browser Session→BiDi ACL without exposing public ownership constructors. +- Design separately authorized reconciliation for `BrowserSessionRecoveryEvidence`, including browser/process restart. +- Replay #299 historical pinned Chromium evidence after the canonical sandbox/runtime repair, then run a separate current-Stable qualification. +- Revisit predecessor capture/restore only if reusable attached contexts become a buyer requirement. ## Supersession / reversal conditions -Supersede this ADR if WebDriver/Chromium gains a complete, queryable and exactly restorable predecessor-state contract for all governed presentation surfaces, or if OriginWeave adopts another isolation primitive with equivalent non-aliasing ownership and destruction evidence. Do not replace disposable ownership with raw context identity. +Supersede this ADR if the browser platform provides a complete, queryable, generation-safe ownership primitive with exact destruction evidence, or if OriginWeave adopts another isolation primitive with equivalent guarantees. Do not regress to raw context identity as authority. ## References From f2295bcd2903996a6ecf936bbeb0900e40e3c2af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:14:29 +0900 Subject: [PATCH 52/58] docs: trace Browser Session recovery and ABA repair --- .../browser-session-lifecycle-authority.md | 83 +++++++++++-------- 1 file changed, 49 insertions(+), 34 deletions(-) diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index 61cfbe4e9..66336ac88 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -8,35 +8,52 @@ ## Problem and invariant -Browser-session and browsing-context identifiers are addresses. They are not evidence that the current Browser Session aggregate exclusively owns presentation mutation or cleanup. They may also be reused across separate aggregate incarnations, so an aggregate-local epoch does not by itself prevent cross-aggregate authority aliasing. +Browser-session, user-context/isolation, and browsing-context identifiers are addresses. They are not evidence that the current Browser Session aggregate exclusively owns presentation mutation or cleanup. A retained authority must not regain meaning if a later aggregate reuses the same remote identifiers and local epoch. -The active implementation establishes this fail-closed chain: +The active implementation now establishes this chain: ```text validated BrowserSessionId -→ BrowserSession::start -→ DisposableContextPort creates a fresh task-owned isolation boundary + browsing context +→ BrowserSession::start allocates non-reused BrowserSessionIncarnation +→ DisposableContextPort receives session id + incarnation +→ adapter creates fresh task-owned isolation boundary + browsing context → adapter returns DisposableIsolationId + BrowsingContextId -→ aggregate records exact isolation handle + monotonic context epoch -→ opaque PresentationMutationAuthority(session, isolation, context, epoch) -→ exact-authority validation before adapter I/O -→ destruction receives the stored isolation handle, not reconstructed session/context authority +→ aggregate records exact handle + monotonic context epoch +→ opaque PresentationMutationAuthority(session, incarnation, isolation, context, epoch) +→ exact authority validation before adapter I/O +→ destruction receives the same incarnation + stored handle → adapter proves exact disposable boundary destruction -→ context state Destroyed -→ normal BrowserSession::end is admitted +→ context Destroyed +→ normal BrowserSession::end admitted ``` -Creation failure is causal evidence with its own bounded type. `DisposableContextCreateError::CreateFailedClean` is allowed only when the adapter proves that no disposable browser state was created. `DisposableContextCreateError::CreateFailedUncertain`, duplicate browsing-context output, or duplicate isolation output enters `RecoveryRequired`, marks active owned contexts uncertain, and blocks all active-only transitions. This prevents a partial create from being followed by a false normal `end()`. +`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. -Destruction has a separate `DisposableContextDestroyError`; creation-only failures cannot be returned from the destroy boundary, and destruction-only failures cannot be returned from create. If exact disposable-boundary destruction cannot be proved, the failed record becomes `Uncertain`, the whole Browser Session enters `RecoveryRequired`, every remaining active record becomes uncertain, and later context creation, authority issuance/advance, destruction, and normal end are rejected before adapter I/O. A raw `BrowsingContextId`, stale epoch, foreign session, foreign isolation, unknown context, lost transport, or recovery-required session likewise cannot enter the successful chain. +## Lossless recovery evidence + +`DisposableContextCreateError::CreateFailedClean` is valid only when no disposable browser state exists. `CreateFailedUncertain(Some(isolation))` retains the exact known user-context/isolation identity as `BrowserSessionRecoveryEvidence::PartialCreationIsolation`; `None` remains representable when no identity was obtained. Both uncertain cases enter `RecoveryRequired` and mint no authority. + +Duplicate browsing-context or isolation output stores the complete offending `DisposableContextHandle` as `DuplicateAdapterHandle` before recovery quarantine. OriginWeave deliberately does not auto-destroy duplicate output because ownership may be foreign. Failed or unproven destruction records `UnprovenDestruction` with the exact owned handle. Recovery evidence authorizes no browser command; it exists only for a later reviewed reconciliation path. + +## Orthogonal transport liveness + +Transport liveness is tracked independently from ownership recovery. If transport loss occurs after `RecoveryRequired`, the aggregate keeps `RecoveryRequired`, preserves all recovery evidence, and separately records `transport_lost = true`. The first loss report is observable; repeated reports are idempotent. If loss occurs while `Active`, the lifecycle state becomes `TransportLost` and active context records become uncertain. + +This avoids conflating “ownership uncertain while transport may still be usable for separately authorized reconciliation” with “ownership uncertain and the transport is gone.” + +## Sequential ABA safety + +The sequential ABA hostile case is explicit: aggregate A creates `(S,U,C,epoch=1)`, proves destruction, and ends. Aggregate B later starts with the same external `S`; the adapter may return the same `U/C`, and B also begins at local epoch 1. A's retained authority must still fail before any B adapter I/O. B receives a different `BrowserSessionIncarnation`, and only B's newly minted authority is accepted. + +The port also receives the incarnation on create/destroy. This closes the prior gap where an aggregate-only nonce could protect token comparison while the browser adapter still keyed destruction by aliasable raw identifiers. ## Standards trace -The design dossier references the 9 September 2026 WebDriver BiDi Working Draft. A user context has a user-context id defined as a unique string set on creation. The browser module defines `browser.createUserContext`; `browsingContext.create` accepts a `userContext`; and `browser.removeUserContext` removes the selected user context after closing its navigables. +The design dossier references the 9 September 2026 WebDriver BiDi Working Draft. A user context has a user-context id set on creation. `browser.createUserContext` creates it, `browsingContext.create` can create a browsing context inside it, and `browser.removeUserContext` removes the selected user context after closing its navigables. -OriginWeave does not make the protocol identifier itself a policy authority. `DisposableIsolationId` is lifecycle addressability carried through the domain so cleanup cannot be reconstructed from aliasable session/context identifiers. A WebDriver BiDi implementation of `DisposableContextPort` must map the isolation id one-to-one to the specification-defined unique user-context id and must prove removal of that exact boundary. An unchecked random adapter token without that browser-lifecycle mapping does not satisfy the port contract. A successful command ACK is insufficient evidence that the disposable boundary is actually gone. +OriginWeave does not turn that protocol identifier into policy authority or assume historical non-reuse after removal. `DisposableIsolationId` remains lifecycle addressability. A successful command ACK is insufficient evidence that the disposable boundary is actually gone. -The active `originweave-bidi` adapter remains runtime-qualified against its separately documented 3 September 2026 revision. Tracking the 9 September publication here does not silently repin that runtime contract. +The active `originweave-bidi` adapter remains separately runtime-qualified against its documented 3 September 2026 revision. Tracking the 9 September publication here does not silently repin that runtime contract. ## Source and executable evidence @@ -44,34 +61,32 @@ The active `originweave-bidi` adapter remains runtime-qualified against its sepa |---|---| | independent Browser Session bounded context | `crates/originweave-browser-session/`; `tests/test_browser_session_lifecycle_contract.py` | | raw context cannot mint authority | `BrowserSession::presentation_authority`; `disposable_creation_is_the_only_raw_context_entry_to_authority` | -| authority is session/isolation/context/epoch bound | `PresentationMutationAuthority`; `epoch_advance_invalidates_old_and_cross_session_authority` | -| same external session/context/epoch cannot cross aggregate isolation | `BrowserSession::context_for_authority_mut`; `two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary` | -| destruction is scoped by the already-validated stored isolation handle | `BrowserSession::destroy_disposable_context`; `two_aggregate_alias_cannot_cross_mutation_or_destruction_boundary` | -| proved-clean versus uncertain creation is typed | `DisposableContextCreateError`; `creation_failure_is_typed_clean_or_recovery_required` | -| destruction failure is phase-specific | `DisposableContextDestroyError`; `destroy_failure_requires_recovery_before_any_new_authority` | -| duplicate adapter output requires recovery | `BrowserSession::create_disposable_context`; `duplicate_adapter_output_requires_recovery` | -| unproven destruction quarantines the aggregate | `BrowserSession::destroy_disposable_context`; `destroy_failure_requires_recovery_before_any_new_authority` | -| transport loss invalidates active contexts | `BrowserSession::record_transport_loss`; `transport_loss_invalidates_still_active_contexts` | -| normal end requires proved destruction | `BrowserSession::end`; `successful_destruction_is_required_before_normal_end` | - -The 10 September 2026 exact-head RED on predecessor `6486e916dceb4ab5f33f7b390cd76fd4673d6007` is part of this trace: CI `34440868057` failed rustfmt and exact coverage. The coverage artifact `10138258867` (`sha256:dc38bd6a2a2cb307f6b3fd34332cac04a71aa47e4bae83173afa00a99a85adea`) isolated two unexecuted `DisposableContextHandle` accessors and a structurally unreachable second context lookup after authority validation. The repair exercises the accessors and retains one validated mutable record across destroy I/O instead of testing or excluding an impossible branch. +| authority includes non-reused BrowserSessionIncarnation | `PresentationMutationAuthority`; `sequential_incarnation_reuse_rejects_stale_authority` | +| lifecycle port receives the same incarnation | `DisposableContextPort`; `stale_authority_cannot_cross_sequential_session_incarnations` | +| lossless recovery evidence for known partial identity | `BrowserSessionRecoveryEvidence`; `creation_failure_preserves_known_recovery_identity` | +| duplicate adapter handle retained without speculative cleanup | `BrowserSession::create_disposable_context`; `duplicate_adapter_output_preserves_offending_handle` | +| unproven destruction retains exact handle | `BrowserSession::destroy_disposable_context`; `destroy_failure_requires_recovery_before_any_new_authority` | +| transport liveness remains orthogonal to recovery | `BrowserSession::record_transport_loss`; `destroy_failure_retains_handle_and_transport_loss_orthogonally` | +| sequential ABA authority is rejected before I/O | `BrowserSession::context_for_authority_mut`; `stale_authority_cannot_cross_sequential_session_incarnations` | +| normal end requires proved destruction | `BrowserSession::end`; `normal_end_requires_proven_destruction_and_ignores_late_transport_report` | +| incarnation exhaustion fails closed | `allocate_incarnation`; `incarnation_allocator_fails_closed_before_wrap` | -A later exact test-only head `6da6015ba4cb2c9c8fa9fbc225ca9c2f5055f55d` supplied a second causal RED in CI `34446199538`: repository contracts and canonical formatting passed, then the hostile destroy-failure test observed `BrowserSessionState::Active` where `RecoveryRequired` was required. Exact `f5780fb3102c35f4c0239696ab2499060fc9a55b` subsequently proved repository contracts, formatting, locked tests, strict Clippy, rustdoc, and exact production coverage GREEN in CI `34448496423` before the method-specific failure-type repair was introduced. +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 method-specific failure-type contract was then added test-first on `ab84a5419893182fc5d6b0b4ef32b46089de6fac`: the contract requires `DisposableContextCreateError` and `DisposableContextDestroyError` and rejects the earlier cross-phase `DisposableContextPortError`. Production and documentation successors must earn their own exact-head GREEN; no predecessor result transfers. +The sequential ABA RED was then captured on exact `ec145963ad8fe19c9416f2b3856b94660082dbf7` in CI `34469580144`: Python repository contracts and canonical formatting passed; the Rust `Run tests` step failed at the newly added hostile sequential-incarnation test. That RED is the causal predecessor for the incarnation-aware domain/port repair. No earlier GREEN transfers to the repaired successor. -Protected-main integration is required before any capability maturity is promoted beyond `IMPLEMENTED_ON_ACTIVE_PR`. +Protected-main integration is required before capability maturity can be promoted beyond `IMPLEMENTED_ON_ACTIVE_PR`. ## Buyer acceptance still open This slice does not yet prove: -- actual WebDriver BiDi `browser.createUserContext`/`browsingContext.create` integration and one-to-one `DisposableIsolationId` mapping; -- observed `browser.removeUserContext` post-condition for the exact owned isolation boundary; -- reconciliation of `RecoveryRequired` after a partial create, duplicate response, or unproven destroy; -- conversion of domain authority into the BiDi presentation/screen-area private witnesses; +- actual WebDriver BiDi `browser.createUserContext`/`browsingContext.create` integration and incarnation-scoped mapping; +- observed `browser.removeUserContext` post-condition for the exact owned boundary; +- a separately authorized reconciliation service consuming `BrowserSessionRecoveryEvidence`; +- Browser Session authority conversion into BiDi presentation/screen-area private witnesses; - pinned Chromium post-condition observation after presentation mutation; -- browser crash/restart reconciliation of uncertain disposable contexts; +- crash/process-restart reconciliation of uncertain disposable contexts; - 3/3 complete #299 Agent Task browser trials; - protected-main release, SBOM, provenance, reproducibility, or rollback evidence. From 09a1de92df2e1ab85bb63c23394603c4266e25f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:14:49 +0900 Subject: [PATCH 53/58] docs: model incarnation and orthogonal recovery state --- .../browser-session-lifecycle-authority.md | 72 +++++++++++++------ 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/docs/uml/browser-session-lifecycle-authority.md b/docs/uml/browser-session-lifecycle-authority.md index 1a1356f05..5b171cfbf 100644 --- a/docs/uml/browser-session-lifecycle-authority.md +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -1,6 +1,6 @@ # Browser Session lifecycle authority -This diagram describes the active-PR domain contract introduced for issue #312. It is not evidence that a WebDriver BiDi or Chromium adapter already implements the port. +This diagram describes the active-PR domain contract for issue #312. It is not evidence that a WebDriver BiDi or Chromium adapter already implements the port. ```mermaid sequenceDiagram @@ -11,24 +11,25 @@ sequenceDiagram participant B as Browser adapter (planned) C->>S: start(valid BrowserSessionId) + S->>S: allocate BrowserSessionIncarnation C->>S: create_disposable_context(port) S->>S: reserve monotonic context epoch - S->>P: create_disposable_context(session_id) + S->>P: create_disposable_context(session_id, incarnation) P->>B: create fresh isolation boundary + browsing context - B-->>P: unique isolation id + BrowsingContextId or DisposableContextCreateError + B-->>P: unique isolation id + BrowsingContextId or typed create error P-->>S: DisposableContextHandle - S->>S: register exact isolation handle + Active epoch - S-->>C: PresentationMutationAuthority(session, isolation, context, epoch) + S->>S: register exact handle + Active epoch + S-->>C: PresentationMutationAuthority(session, incarnation, isolation, context, epoch) - Note over C,S: Raw BrowserSessionId/BrowsingContextId cannot mint authority. + Note over C,S: Raw BrowserSessionId/BrowsingContextId/user-context id cannot mint authority. C->>S: advance_context_epoch(context_id) S->>S: replace epoch; old authority becomes stale - S-->>C: new opaque authority carrying same isolation + S-->>C: new opaque authority carrying same incarnation + isolation C->>S: destroy_disposable_context(authority, port) - S->>S: validate exact session/isolation/context/epoch before I/O - S->>P: destroy_disposable_context(session_id, stored handle) + S->>S: validate exact session/incarnation/isolation/context/epoch before I/O + S->>P: destroy_disposable_context(session_id, incarnation, stored handle) P->>B: remove exact owned isolation boundary B-->>P: observed destruction post-condition or DisposableContextDestroyError P-->>S: success @@ -38,38 +39,63 @@ sequenceDiagram S-->>C: Ended ``` -Two aggregates may receive the same external `BrowserSessionId`, the same `BrowsingContextId`, and the same local epoch. Their authority must still differ because the adapter-created disposable isolation identity is non-aliasing for its live lifetime. Passing aggregate A's authority into aggregate B therefore fails before adapter I/O; aggregate B's own destroy call carries B's stored isolation handle instead of reconstructing cleanup authority from the shared transport identifiers. +`BrowserSessionIncarnation` separates two sequential aggregate lifecycles even when the browser or adapter later reuses the same external session, user-context/isolation, browsing-context, and local epoch values. The incarnation is checked by authority validation and reaches the lifecycle port. It is therefore not merely an aggregate-local nonce that the adapter can ignore. -For a WebDriver BiDi adapter, the isolation identity is expected to map one-to-one to the specification-defined unique user-context id created by `browser.createUserContext`, and cleanup targets that exact user context. The protocol id remains lifecycle addressability, not OriginWeave policy authority. Creation and destruction expose distinct error types, so an adapter cannot express a destruction-only outcome during creation or a creation-only outcome during cleanup. +For a WebDriver BiDi adapter, `DisposableIsolationId` maps to the user-context id created by `browser.createUserContext`. That protocol id remains lifecycle addressability rather than OriginWeave policy authority. Creation and destruction expose distinct typed errors. -## Failure state machine +## Recovery and transport state ```mermaid stateDiagram-v2 [*] --> Active - Active --> Active: fresh isolation + context created / authority minted + Active --> Active: fresh isolation + context / authority minted Active --> Active: context epoch advanced / prior authority stale Active --> Active: exact owned isolation destruction proved Active --> Active: DisposableContextCreateError::CreateFailedClean - Active --> RecoveryRequired: DisposableContextCreateError::CreateFailedUncertain - Active --> RecoveryRequired: duplicate context or isolation output + Active --> RecoveryRequired: CreateFailedUncertain / retain known partial isolation + Active --> RecoveryRequired: duplicate output / retain offending handle Active --> RecoveryRequired: DisposableContextDestroyError / cleanup unproven Active --> Ended: all owned contexts Destroyed + end Active --> TransportLost: browser transport lost + RecoveryRequired --> RecoveryRequired: transport_lost = true / preserve recovery evidence Ended --> [*] RecoveryRequired --> [*] TransportLost --> [*] - note right of Active - Normal end is admitted only after every - owned context has proven destruction. + 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 RecoveryRequired - Partial create, duplicate output, or an - unproven destroy leaves lifecycle state - uncertain. Active-only transitions fail closed. + note right of TransportLost + Transport liveness is orthogonal to ownership + recovery. Duplicate loss reports are idempotent. end note ``` -`RecoveryRequired` and `TransportLost` are terminal for this aggregate in the current slice. Recovery of uncertain remote browser state requires a separate reconciliation design; reopening the same aggregate would allow stale authority to regain meaning and is therefore not part of this implementation. +## Sequential ABA hostile case + +```mermaid +sequenceDiagram + autonumber + participant A as BrowserSession A + participant B as BrowserSession B + participant P as Lifecycle port + + A->>A: start(S) => incarnation A + A->>P: create(S, incarnation A) + P-->>A: U, C + A->>P: destroy(S, incarnation A, U/C) + A->>A: end() + + B->>B: start(S) => incarnation B + B->>P: create(S, incarnation B) + P-->>B: same U, same C + Note over A,B: both local context epochs may equal 1 + B->>B: validate retained authority A + B-->>A: AuthorityMismatch before adapter I/O + B->>P: destroy with authority B + incarnation B +``` + +`RecoveryRequired` and `TransportLost` remain terminal for normal authority in this slice. A later reconciliation design may inspect `BrowserSessionRecoveryEvidence`, but it must not reconstruct cleanup authority from raw identifiers or treat command ACK as proof of destruction. From ad9530a1b6cebdf7d682660ca7bec1e7bb467857 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:19:31 +0900 Subject: [PATCH 54/58] style: apply canonical Browser Session rustfmt --- crates/originweave-browser-session/src/lib.rs | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 75f2707cf..a1f73ddb7 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -372,13 +372,17 @@ impl BrowserSession { .any(|record| record.handle.isolation == handle.isolation) { self.recovery_evidence - .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(handle)); + .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( + handle, + )); self.enter_recovery_required(); return Err(BrowserSessionError::DuplicateDisposableIsolation); } if self.contexts.contains_key(&handle.browsing_context) { self.recovery_evidence - .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(handle)); + .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( + handle, + )); self.enter_recovery_required(); return Err(BrowserSessionError::DuplicateBrowsingContext); } @@ -696,7 +700,10 @@ mod tests { 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!( + session.presentation_authority(context_id(10)), + Ok(authority) + ); } #[test] @@ -732,9 +739,14 @@ mod tests { ); assert_eq!( known_session.recovery_evidence(), - &[BrowserSessionRecoveryEvidence::PartialCreationIsolation(known)] + &[BrowserSessionRecoveryEvidence::PartialCreationIsolation( + known + )] + ); + assert_eq!( + known_session.end(), + Err(BrowserSessionError::SessionNotActive) ); - assert_eq!(known_session.end(), Err(BrowserSessionError::SessionNotActive)); } #[test] @@ -744,10 +756,8 @@ mod tests { 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 duplicate_context_handle = + DisposableContextHandle::new(isolation_id("isolation-30-b"), context_id(30)); let mut duplicate_context_port = TestPort::new(30, "isolation-30-b"); assert_eq!( duplicate_context_session.create_disposable_context(&mut duplicate_context_port), @@ -765,10 +775,8 @@ mod tests { 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 duplicate_isolation_handle = + DisposableContextHandle::new(isolation_id("isolation-31"), context_id(311)); let mut duplicate_isolation_port = TestPort::new(311, "isolation-31"); assert_eq!( duplicate_isolation_session.create_disposable_context(&mut duplicate_isolation_port), @@ -897,10 +905,8 @@ mod tests { let authority = session .create_disposable_context(&mut port) .expect("owned context"); - let expected_handle = DisposableContextHandle::new( - isolation_id("isolation-90"), - context_id(90), - ); + let expected_handle = + DisposableContextHandle::new(isolation_id("isolation-90"), context_id(90)); port.fail_destroy = true; assert_eq!( session.destroy_disposable_context(&authority, &mut port), @@ -958,7 +964,10 @@ mod tests { let authority = session .create_disposable_context(&mut port) .expect("owned context"); - assert_eq!(session.end(), Err(BrowserSessionError::ActiveContextRemains)); + assert_eq!( + session.end(), + Err(BrowserSessionError::ActiveContextRemains) + ); session .destroy_disposable_context(&authority, &mut port) .expect("proven destruction"); From 110eb33a6d368be977a0c37e49556af976ca09f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:22:49 +0900 Subject: [PATCH 55/58] fix: reject unknown epoch advance without mutation --- crates/originweave-browser-session/src/lib.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index a1f73ddb7..facbf9f5b 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -425,11 +425,17 @@ impl BrowserSession { browsing_context: BrowsingContextId, ) -> Result { self.require_active()?; + if !self + .contexts + .get(&browsing_context) + .is_some_and(|record| record.state == OwnedContextState::Active) + { + return Err(BrowserSessionError::ContextNotOwned); + } let next = self.reserve_epoch()?; let record = self .contexts .get_mut(&browsing_context) - .filter(|record| record.state == OwnedContextState::Active) .ok_or(BrowserSessionError::ContextNotOwned)?; record.epoch = next; Ok(Self::authority_for( From 6eacfe4876c369794927a904bbc7035dbc5712d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 21:04:34 +0900 Subject: [PATCH 56/58] test: close Browser Session coverage edges --- crates/originweave-browser-session/src/lib.rs | 70 ++++++++++++------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index facbf9f5b..7f3305a33 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -301,7 +301,14 @@ impl BrowserSession { /// A fresh process-local incarnation is allocated before any browser I/O. Exhaustion fails closed /// rather than wrapping and making an older authority structurally valid again. pub fn start(id: BrowserSessionId) -> Result { - let incarnation = allocate_incarnation(&NEXT_BROWSER_SESSION_INCARNATION)?; + Self::start_with_counter(id, &NEXT_BROWSER_SESSION_INCARNATION) + } + + fn start_with_counter( + id: BrowserSessionId, + counter: &AtomicU64, + ) -> Result { + let incarnation = allocate_incarnation(counter)?; Ok(Self { id, incarnation, @@ -349,7 +356,7 @@ impl BrowserSession { port: &mut P, ) -> Result { self.require_active()?; - let epoch = self.reserve_epoch()?; + let epoch = reserve_epoch(&mut self.next_epoch)?; let handle = match port.create_disposable_context(self.id, self.incarnation) { Ok(handle) => handle, Err(DisposableContextCreateError::CreateFailedClean) => { @@ -425,22 +432,18 @@ impl BrowserSession { browsing_context: BrowsingContextId, ) -> Result { self.require_active()?; - if !self - .contexts - .get(&browsing_context) - .is_some_and(|record| record.state == OwnedContextState::Active) - { - return Err(BrowserSessionError::ContextNotOwned); - } - let next = self.reserve_epoch()?; + let browser_session = self.id; + let incarnation = self.incarnation; let record = self .contexts .get_mut(&browsing_context) + .filter(|record| record.state == OwnedContextState::Active) .ok_or(BrowserSessionError::ContextNotOwned)?; + let next = reserve_epoch(&mut self.next_epoch)?; record.epoch = next; Ok(Self::authority_for( - self.id, - self.incarnation, + browser_session, + incarnation, &record.handle, next, )) @@ -509,15 +512,6 @@ impl BrowserSession { } } - fn reserve_epoch(&mut self) -> Result { - let epoch = BrowserContextEpoch(self.next_epoch); - self.next_epoch = self - .next_epoch - .checked_add(1) - .ok_or(BrowserSessionError::EpochExhausted)?; - Ok(epoch) - } - fn authority_for( browser_session: BrowserSessionId, incarnation: BrowserSessionIncarnation, @@ -567,6 +561,14 @@ impl BrowserSession { } } +fn reserve_epoch(next_epoch: &mut u64) -> Result { + let epoch = BrowserContextEpoch(*next_epoch); + *next_epoch = next_epoch + .checked_add(1) + .ok_or(BrowserSessionError::EpochExhausted)?; + Ok(epoch) +} + fn allocate_incarnation( counter: &AtomicU64, ) -> Result { @@ -808,6 +810,24 @@ mod tests { assert_eq!(unused_port.create_calls, 0); } + #[test] + fn epoch_exhaustion_prevents_advance_mutation() { + let mut exhausted_session = session(41); + let mut port = TestPort::new(410, "isolation-410"); + let authority = exhausted_session + .create_disposable_context(&mut port) + .expect("owned context"); + exhausted_session.next_epoch = u64::MAX; + assert_eq!( + exhausted_session.advance_context_epoch(context_id(410)), + Err(BrowserSessionError::EpochExhausted) + ); + assert_eq!( + exhausted_session.presentation_authority(context_id(410)), + Ok(authority) + ); + } + #[test] fn epoch_advance_invalidates_old_and_unknown_authority() { let mut session = session(5); @@ -986,9 +1006,9 @@ mod tests { #[test] fn incarnation_allocator_fails_closed_before_wrap() { let counter = AtomicU64::new(u64::MAX); - assert_eq!( - allocate_incarnation(&counter), + assert!(matches!( + BrowserSession::start_with_counter(session_id(12), &counter), Err(BrowserSessionError::IncarnationExhausted) - ); + )); } -} +} \ No newline at end of file From 3aa113b567624a96085b63cc657c9e3894f8ffa6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 21:08:52 +0900 Subject: [PATCH 57/58] style: apply canonical Rust formatting --- crates/originweave-browser-session/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 7f3305a33..130483784 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -1011,4 +1011,4 @@ mod tests { Err(BrowserSessionError::IncarnationExhausted) )); } -} \ No newline at end of file +} From 0c6ed89c09ad16b5e170fd80604c29b16ce4f212 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 21:13:35 +0900 Subject: [PATCH 58/58] test: cover incarnation exhaustion without macro branch --- crates/originweave-browser-session/src/lib.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 130483784..66f5753c5 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -1006,9 +1006,8 @@ mod tests { #[test] fn incarnation_allocator_fails_closed_before_wrap() { let counter = AtomicU64::new(u64::MAX); - assert!(matches!( - BrowserSession::start_with_counter(session_id(12), &counter), - Err(BrowserSessionError::IncarnationExhausted) - )); + let error = BrowserSession::start_with_counter(session_id(12), &counter) + .expect_err("incarnation allocation must fail closed before wrapping"); + assert_eq!(error, BrowserSessionError::IncarnationExhausted); } }