From 24799e0dc502f81cd1d7663998a2e07afe501831 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 21:41:43 +0900 Subject: [PATCH 01/20] test(bidi): prepare Browser Session ACL RED dependencies --- crates/originweave-bidi/Cargo.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/originweave-bidi/Cargo.toml b/crates/originweave-bidi/Cargo.toml index 069119dd8..65d5f7523 100644 --- a/crates/originweave-bidi/Cargo.toml +++ b/crates/originweave-bidi/Cargo.toml @@ -11,6 +11,8 @@ homepage.workspace = true publish = false [dependencies] +originweave-browser-session = { path = "../originweave-browser-session" } +originweave-core = { path = "../originweave-core" } originweave-fingerprint = { path = "../originweave-fingerprint" } [lints] From a657dbf1c100f6166b524e62869096c0ef49f1e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 21:42:19 +0900 Subject: [PATCH 02/20] test(bidi): add hostile Browser Session ACL RED --- .../tests/browser_session_acl.rs | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 crates/originweave-bidi/tests/browser_session_acl.rs diff --git a/crates/originweave-bidi/tests/browser_session_acl.rs b/crates/originweave-bidi/tests/browser_session_acl.rs new file mode 100644 index 000000000..40065fd7e --- /dev/null +++ b/crates/originweave-bidi/tests/browser_session_acl.rs @@ -0,0 +1,223 @@ +#![allow(clippy::expect_used)] + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use originweave_bidi::{ + AuthorizedWebDriverBidiPresentationAction, WebDriverBidiAclError, + WebDriverBidiBrowsingContext, WebDriverBidiCreatedContext, WebDriverBidiLifecycleAdapter, + WebDriverBidiLifecycleBackend, +}; +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, DisposableContextCreateError, + DisposableContextDestroyError, DisposableIsolationId, PresentationMutationAuthority, +}; +use originweave_core::BrowserSessionId; +use originweave_fingerprint::{DevicePixelRatio, PresentationTimeZone, ViewportBounds}; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DestroyTrace { + session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + isolation: DisposableIsolationId, + remote_context: String, +} + +#[derive(Debug)] +struct FakeBackend { + creates: VecDeque, + destroys: Arc>>, +} + +impl FakeBackend { + fn new( + contexts: impl IntoIterator, + destroys: Arc>>, + ) -> Self { + let creates = contexts + .into_iter() + .map(|(isolation, remote_context)| { + WebDriverBidiCreatedContext::new( + DisposableIsolationId::parse(isolation).expect("valid user context"), + WebDriverBidiBrowsingContext::new(remote_context) + .expect("valid remote browsing context"), + ) + }) + .collect(); + Self { creates, destroys } + } +} + +impl WebDriverBidiLifecycleBackend for FakeBackend { + fn create_disposable_context( + &mut self, + _session: BrowserSessionId, + _incarnation: BrowserSessionIncarnation, + ) -> Result { + self.creates + .pop_front() + .ok_or(DisposableContextCreateError::CreateFailedClean) + } + + fn destroy_disposable_context( + &mut self, + session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + isolation: &DisposableIsolationId, + remote_context: &WebDriverBidiBrowsingContext, + ) -> Result<(), DisposableContextDestroyError> { + self.destroys + .lock() + .expect("trace lock") + .push(DestroyTrace { + session, + incarnation, + isolation: isolation.clone(), + remote_context: remote_context.as_str().to_owned(), + }); + Ok(()) + } +} + +fn authorize<'a>( + adapter: &'a WebDriverBidiLifecycleAdapter, + session: &'a BrowserSession, + authority: &PresentationMutationAuthority, +) -> Result, WebDriverBidiAclError> { + adapter.authorize_standard_presentation( + session, + authority, + ViewportBounds::new(1440, 900).expect("valid viewport"), + DevicePixelRatio::Quantized2, + PresentationTimeZone::Utc, + ) +} + +#[test] +fn exact_lifecycle_mapping_is_the_only_remote_context_source() { + let destroys = Arc::new(Mutex::new(Vec::new())); + let backend = FakeBackend::new( + [("user-context-a", "remote-context-a"), ("user-context-b", "remote-context-b")], + destroys, + ); + let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); + let mut session = BrowserSession::start(BrowserSessionId::new(41).expect("valid session")) + .expect("fresh incarnation"); + + let authority_a = session + .create_disposable_context(&mut adapter) + .expect("first owned context"); + let authority_b = session + .create_disposable_context(&mut adapter) + .expect("second owned context"); + + let plan_a = authorize(&adapter, &session, &authority_a).expect("current authority A"); + let plan_b = authorize(&adapter, &session, &authority_b).expect("current authority B"); + assert_eq!(plan_a.context().as_str(), "remote-context-a"); + assert_eq!(plan_b.context().as_str(), "remote-context-b"); + + let [viewport, timezone] = plan_a.apply_actions(); + assert!(matches!( + viewport, + AuthorizedWebDriverBidiPresentationAction::SetViewport { context, .. } + if context.as_str() == "remote-context-a" + )); + assert!(matches!( + timezone, + AuthorizedWebDriverBidiPresentationAction::SetTimezone { context, .. } + if context.as_str() == "remote-context-a" + )); + let [reset_viewport, reset_timezone] = plan_a.cleanup_actions(); + assert!(matches!( + reset_viewport, + AuthorizedWebDriverBidiPresentationAction::ResetViewport { context } + if context.as_str() == "remote-context-a" + )); + assert!(matches!( + reset_timezone, + AuthorizedWebDriverBidiPresentationAction::ResetTimezone { context } + if context.as_str() == "remote-context-a" + )); +} + +#[test] +fn stale_epoch_is_rejected_before_any_remote_target_can_be_projected() { + let destroys = Arc::new(Mutex::new(Vec::new())); + let backend = FakeBackend::new([("user-context-a", "remote-context-a")], destroys); + let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); + let mut session = BrowserSession::start(BrowserSessionId::new(42).expect("valid session")) + .expect("fresh incarnation"); + + let stale = session + .create_disposable_context(&mut adapter) + .expect("owned context"); + let current = session + .advance_context_epoch(stale.browsing_context()) + .expect("advance authority epoch"); + + assert_eq!( + authorize(&adapter, &session, &stale), + Err(WebDriverBidiAclError::BrowserSession( + BrowserSessionError::AuthorityMismatch + )) + ); + assert_eq!( + authorize(&adapter, &session, ¤t) + .expect("current authority") + .context() + .as_str(), + "remote-context-a" + ); +} + +#[test] +fn destroyed_or_transport_lost_context_cannot_project_bidi_authority() { + let destroys = Arc::new(Mutex::new(Vec::new())); + let backend = FakeBackend::new([("user-context-a", "remote-context-a")], destroys.clone()); + let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); + let mut session = BrowserSession::start(BrowserSessionId::new(43).expect("valid session")) + .expect("fresh incarnation"); + + let authority = session + .create_disposable_context(&mut adapter) + .expect("owned context"); + session + .destroy_disposable_context(&authority, &mut adapter) + .expect("proven destroy"); + assert_eq!( + authorize(&adapter, &session, &authority), + Err(WebDriverBidiAclError::BrowserSession( + BrowserSessionError::ContextNotOwned + )) + ); + let traces = destroys.lock().expect("trace lock"); + assert_eq!(traces.len(), 1); + assert_eq!(traces[0].remote_context, "remote-context-a"); + assert_eq!(traces[0].isolation.as_str(), "user-context-a"); + drop(traces); + + session.end().expect("all contexts destroyed"); + assert_eq!( + authorize(&adapter, &session, &authority), + Err(WebDriverBidiAclError::BrowserSession( + BrowserSessionError::SessionNotActive + )) + ); + + let destroys = Arc::new(Mutex::new(Vec::new())); + let backend = FakeBackend::new([("user-context-b", "remote-context-b")], destroys); + let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); + let mut lost_session = + BrowserSession::start(BrowserSessionId::new(44).expect("valid session")) + .expect("fresh incarnation"); + let lost_authority = lost_session + .create_disposable_context(&mut adapter) + .expect("owned context"); + assert!(lost_session.record_transport_loss()); + assert_eq!( + authorize(&adapter, &lost_session, &lost_authority), + Err(WebDriverBidiAclError::BrowserSession( + BrowserSessionError::SessionNotActive + )) + ); +} From b81d5591a31bf6174d9eeb773d8a7174baf45959 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 21:44:07 +0900 Subject: [PATCH 03/20] test(bidi): canonical-format Browser Session ACL RED --- .../originweave-bidi/tests/browser_session_acl.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/originweave-bidi/tests/browser_session_acl.rs b/crates/originweave-bidi/tests/browser_session_acl.rs index 40065fd7e..076824ce9 100644 --- a/crates/originweave-bidi/tests/browser_session_acl.rs +++ b/crates/originweave-bidi/tests/browser_session_acl.rs @@ -97,7 +97,10 @@ fn authorize<'a>( fn exact_lifecycle_mapping_is_the_only_remote_context_source() { let destroys = Arc::new(Mutex::new(Vec::new())); let backend = FakeBackend::new( - [("user-context-a", "remote-context-a"), ("user-context-b", "remote-context-b")], + [ + ("user-context-a", "remote-context-a"), + ("user-context-b", "remote-context-b"), + ], destroys, ); let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); @@ -158,7 +161,7 @@ fn stale_epoch_is_rejected_before_any_remote_target_can_be_projected() { assert_eq!( authorize(&adapter, &session, &stale), Err(WebDriverBidiAclError::BrowserSession( - BrowserSessionError::AuthorityMismatch + BrowserSessionError::AuthorityMismatch, )) ); assert_eq!( @@ -187,7 +190,7 @@ fn destroyed_or_transport_lost_context_cannot_project_bidi_authority() { assert_eq!( authorize(&adapter, &session, &authority), Err(WebDriverBidiAclError::BrowserSession( - BrowserSessionError::ContextNotOwned + BrowserSessionError::ContextNotOwned, )) ); let traces = destroys.lock().expect("trace lock"); @@ -200,7 +203,7 @@ fn destroyed_or_transport_lost_context_cannot_project_bidi_authority() { assert_eq!( authorize(&adapter, &session, &authority), Err(WebDriverBidiAclError::BrowserSession( - BrowserSessionError::SessionNotActive + BrowserSessionError::SessionNotActive, )) ); @@ -217,7 +220,7 @@ fn destroyed_or_transport_lost_context_cannot_project_bidi_authority() { assert_eq!( authorize(&adapter, &lost_session, &lost_authority), Err(WebDriverBidiAclError::BrowserSession( - BrowserSessionError::SessionNotActive + BrowserSessionError::SessionNotActive, )) ); } From 108ed642fcd8aba77f74230fbf217f171b09f579 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 21:45:34 +0900 Subject: [PATCH 04/20] test(bidi): require non-constructible authorized actions --- .../tests/browser_session_acl.rs | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/crates/originweave-bidi/tests/browser_session_acl.rs b/crates/originweave-bidi/tests/browser_session_acl.rs index 076824ce9..456aee986 100644 --- a/crates/originweave-bidi/tests/browser_session_acl.rs +++ b/crates/originweave-bidi/tests/browser_session_acl.rs @@ -4,9 +4,9 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex}; use originweave_bidi::{ - AuthorizedWebDriverBidiPresentationAction, WebDriverBidiAclError, - WebDriverBidiBrowsingContext, WebDriverBidiCreatedContext, WebDriverBidiLifecycleAdapter, - WebDriverBidiLifecycleBackend, + WebDriverBidiAclError, WebDriverBidiBrowsingContext, WebDriverBidiCreatedContext, + WebDriverBidiLifecycleAdapter, WebDriverBidiLifecycleBackend, + WebDriverBidiPresentationOperation, }; use originweave_browser_session::{ BrowserSession, BrowserSessionError, BrowserSessionIncarnation, DisposableContextCreateError, @@ -120,27 +120,28 @@ fn exact_lifecycle_mapping_is_the_only_remote_context_source() { assert_eq!(plan_b.context().as_str(), "remote-context-b"); let [viewport, timezone] = plan_a.apply_actions(); - assert!(matches!( - viewport, - AuthorizedWebDriverBidiPresentationAction::SetViewport { context, .. } - if context.as_str() == "remote-context-a" - )); - assert!(matches!( - timezone, - AuthorizedWebDriverBidiPresentationAction::SetTimezone { context, .. } - if context.as_str() == "remote-context-a" - )); + assert_eq!(viewport.operation(), WebDriverBidiPresentationOperation::SetViewport); + assert_eq!(viewport.context().as_str(), "remote-context-a"); + assert_eq!(viewport.viewport(), Some(ViewportBounds::new(1440, 900).expect("viewport"))); + assert_eq!(viewport.device_pixel_ratio(), Some(DevicePixelRatio::Quantized2)); + assert_eq!(viewport.timezone(), None); + assert_eq!(timezone.operation(), WebDriverBidiPresentationOperation::SetTimezone); + assert_eq!(timezone.context().as_str(), "remote-context-a"); + assert_eq!(timezone.viewport(), None); + assert_eq!(timezone.device_pixel_ratio(), None); + assert_eq!(timezone.timezone(), Some(PresentationTimeZone::Utc)); + let [reset_viewport, reset_timezone] = plan_a.cleanup_actions(); - assert!(matches!( - reset_viewport, - AuthorizedWebDriverBidiPresentationAction::ResetViewport { context } - if context.as_str() == "remote-context-a" - )); - assert!(matches!( - reset_timezone, - AuthorizedWebDriverBidiPresentationAction::ResetTimezone { context } - if context.as_str() == "remote-context-a" - )); + assert_eq!( + reset_viewport.operation(), + WebDriverBidiPresentationOperation::ResetViewport + ); + assert_eq!(reset_viewport.context().as_str(), "remote-context-a"); + assert_eq!( + reset_timezone.operation(), + WebDriverBidiPresentationOperation::ResetTimezone + ); + assert_eq!(reset_timezone.context().as_str(), "remote-context-a"); } #[test] From c849e84e75ee4b097fc60458073cc591b77f625b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 21:47:04 +0900 Subject: [PATCH 05/20] feat(bidi): bind live Browser Session authority to lifecycle mapping --- crates/originweave-bidi/src/lifecycle_acl.rs | 506 +++++++++++++++++++ 1 file changed, 506 insertions(+) create mode 100644 crates/originweave-bidi/src/lifecycle_acl.rs diff --git a/crates/originweave-bidi/src/lifecycle_acl.rs b/crates/originweave-bidi/src/lifecycle_acl.rs new file mode 100644 index 000000000..207114a7d --- /dev/null +++ b/crates/originweave-bidi/src/lifecycle_acl.rs @@ -0,0 +1,506 @@ +use std::collections::BTreeMap; +use std::marker::PhantomData; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, DisposableContextCreateError, + DisposableContextDestroyError, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, PresentationMutationAuthority, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; +use originweave_fingerprint::{DevicePixelRatio, PresentationTimeZone, ViewportBounds}; + +use crate::presentation_capabilities::WebDriverBidiBrowsingContext; + +/// Failure while projecting current Browser Session authority into a WebDriver BiDi target. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBidiAclError { + /// The Browser Session rejected the retained authority as non-current or non-active. + BrowserSession(BrowserSessionError), + /// The lifecycle adapter has no exact remote-context binding for the current authority. + LifecycleBindingMissing, +} + +/// Browser-issued identities returned by the reviewed WebDriver BiDi lifecycle backend. +/// +/// The user-context isolation identity and remote browsing-context string are adapter addressability, +/// not mutation authority. They become usable for presentation planning only after the owning +/// [`BrowserSession`] revalidates the exact retained authority against its current epoch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebDriverBidiCreatedContext { + isolation: DisposableIsolationId, + context: WebDriverBidiBrowsingContext, +} + +impl WebDriverBidiCreatedContext { + /// Bind one browser-issued user-context identity to its exact remote browsing context. + #[must_use] + pub fn new(isolation: DisposableIsolationId, context: WebDriverBidiBrowsingContext) -> Self { + Self { isolation, context } + } + + /// Return the browser-issued disposable user-context identity. + #[must_use] + pub const fn isolation(&self) -> &DisposableIsolationId { + &self.isolation + } + + /// Return the exact opaque WebDriver BiDi browsing-context identity. + #[must_use] + pub const fn context(&self) -> &WebDriverBidiBrowsingContext { + &self.context + } +} + +/// Reviewed transport boundary used by the WebDriver BiDi disposable-context adapter. +/// +/// A production implementation maps creation to `browser.createUserContext` followed by +/// `browsingContext.create`, returning the browser-issued identities without coercing OriginWeave's +/// numeric domain context id. Destruction maps to the exact user-context boundary and may return +/// success only after the remote boundary is proven absent; a command acknowledgement alone is not a +/// destruction post-condition. +pub trait WebDriverBidiLifecycleBackend { + /// Create one disposable user context and one independently navigable context inside it. + fn create_disposable_context( + &mut self, + session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + ) -> Result; + + /// Destroy the exact disposable user-context boundary represented by these browser-issued ids. + fn destroy_disposable_context( + &mut self, + session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + isolation: &DisposableIsolationId, + remote_context: &WebDriverBidiBrowsingContext, + ) -> Result<(), DisposableContextDestroyError>; +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct LifecycleBindingKey { + session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + isolation: DisposableIsolationId, + browsing_context: BrowsingContextId, +} + +impl LifecycleBindingKey { + fn from_handle( + session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + handle: &DisposableContextHandle, + ) -> Self { + Self { + session, + incarnation, + isolation: handle.isolation().clone(), + browsing_context: handle.browsing_context(), + } + } + + fn from_authority(authority: &PresentationMutationAuthority) -> Self { + Self { + session: authority.browser_session(), + incarnation: authority.incarnation(), + isolation: authority.isolation().clone(), + browsing_context: authority.browsing_context(), + } + } +} + +/// WebDriver BiDi adapter that keeps remote context addressability bound to disposable lifecycle. +/// +/// The private mapping is populated only by this adapter's successful lifecycle creation call. A raw +/// domain [`BrowsingContextId`] is never stringified into a protocol id, and callers cannot provide an +/// unrelated remote context to the authorization method. The mapping key includes Browser Session +/// incarnation and isolation identity so sequential id reuse cannot redirect a retained authority. +#[derive(Debug)] +pub struct WebDriverBidiLifecycleAdapter { + backend: B, + next_browsing_context: u64, + bindings: BTreeMap, +} + +impl WebDriverBidiLifecycleAdapter { + /// Create an adapter with a fresh, process-local monotonic domain-context allocator. + #[must_use] + pub fn new(backend: B) -> Self { + Self { + backend, + next_browsing_context: 1, + bindings: BTreeMap::new(), + } + } +} + +impl WebDriverBidiLifecycleAdapter { + /// Revalidate one retained Browser Session authority and bind it to its exact BiDi context. + /// + /// The returned plan borrows both this adapter mapping and the Browser Session. While the plan is + /// alive, safe Rust therefore cannot mutably advance/destroy/end that session or mutate this + /// lifecycle adapter. Stale epoch, destruction, transport loss, recovery state, session end, or a + /// different incarnation is rejected before a remote target is returned. + pub fn authorize_standard_presentation<'a>( + &'a self, + session: &'a BrowserSession, + authority: &PresentationMutationAuthority, + viewport: ViewportBounds, + device_pixel_ratio: DevicePixelRatio, + timezone: PresentationTimeZone, + ) -> Result, WebDriverBidiAclError> { + let current = session + .presentation_authority(authority.browsing_context()) + .map_err(WebDriverBidiAclError::BrowserSession)?; + if current != *authority { + return Err(WebDriverBidiAclError::BrowserSession( + BrowserSessionError::AuthorityMismatch, + )); + } + let key = LifecycleBindingKey::from_authority(¤t); + let context = self + .bindings + .get(&key) + .ok_or(WebDriverBidiAclError::LifecycleBindingMissing)?; + Ok(AuthorizedWebDriverBidiPresentationPlan { + context, + viewport, + device_pixel_ratio, + timezone, + _session: PhantomData, + }) + } +} + +impl DisposableContextPort for WebDriverBidiLifecycleAdapter { + fn create_disposable_context( + &mut self, + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + ) -> Result { + let browsing_context = reserve_domain_context_id(&mut self.next_browsing_context)?; + let created = self + .backend + .create_disposable_context(browser_session, incarnation)?; + let handle = DisposableContextHandle::new(created.isolation.clone(), browsing_context); + let key = LifecycleBindingKey::from_handle(browser_session, incarnation, &handle); + self.bindings.insert(key, created.context); + Ok(handle) + } + + fn destroy_disposable_context( + &mut self, + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + context: &DisposableContextHandle, + ) -> Result<(), DisposableContextDestroyError> { + let key = LifecycleBindingKey::from_handle(browser_session, incarnation, context); + let remote_context = self + .bindings + .get(&key) + .cloned() + .ok_or(DisposableContextDestroyError::DestroyFailed)?; + self.backend.destroy_disposable_context( + browser_session, + incarnation, + context.isolation(), + &remote_context, + )?; + self.bindings.remove(&key); + Ok(()) + } +} + +fn reserve_domain_context_id( + next: &mut u64, +) -> Result { + let value = *next; + *next = value + .checked_add(1) + .ok_or(DisposableContextCreateError::CreateFailedClean)?; + BrowsingContextId::new(value).map_err(|_error| DisposableContextCreateError::CreateFailedClean) +} + +/// Standard presentation operation authorized for one currently owned BiDi context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBidiPresentationOperation { + /// Set viewport dimensions and device-pixel ratio. + SetViewport, + /// Set the named time zone. + SetTimezone, + /// Remove owned viewport and device-pixel-ratio overrides. + ResetViewport, + /// Remove the owned time-zone override. + ResetTimezone, +} + +/// Non-constructible, lifetime-bound standard presentation action. +/// +/// Only [`AuthorizedWebDriverBidiPresentationPlan`] can create this value. The action retains the +/// plan's Browser Session lifetime and exact adapter-owned remote context, so raw ids cannot be +/// substituted between policy authorization and transport planning. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuthorizedWebDriverBidiPresentationAction<'a> { + operation: WebDriverBidiPresentationOperation, + context: &'a WebDriverBidiBrowsingContext, + viewport: Option, + device_pixel_ratio: Option, + timezone: Option, + _session: PhantomData<&'a BrowserSession>, +} + +impl AuthorizedWebDriverBidiPresentationAction<'_> { + /// Return the standard WebDriver BiDi presentation operation. + #[must_use] + pub const fn operation(&self) -> WebDriverBidiPresentationOperation { + self.operation + } + + /// Return the exact remote context selected by the lifecycle adapter mapping. + #[must_use] + pub const fn context(&self) -> &WebDriverBidiBrowsingContext { + self.context + } + + /// Return viewport payload only for [`WebDriverBidiPresentationOperation::SetViewport`]. + #[must_use] + pub const fn viewport(&self) -> Option { + self.viewport + } + + /// Return device-pixel-ratio payload only for [`WebDriverBidiPresentationOperation::SetViewport`]. + #[must_use] + pub const fn device_pixel_ratio(&self) -> Option { + self.device_pixel_ratio + } + + /// Return time-zone payload only for [`WebDriverBidiPresentationOperation::SetTimezone`]. + #[must_use] + pub const fn timezone(&self) -> Option { + self.timezone + } +} + +/// Lifetime-bound plan for standard BiDi presentation mutation and cleanup. +/// +/// Constructing this plan is policy admission, not browser success. A later transport must still +/// observe the browser/page post-condition and provenance required by OriginWeave before reporting a +/// successful interaction. +#[derive(Debug, PartialEq, Eq)] +pub struct AuthorizedWebDriverBidiPresentationPlan<'a> { + context: &'a WebDriverBidiBrowsingContext, + viewport: ViewportBounds, + device_pixel_ratio: DevicePixelRatio, + timezone: PresentationTimeZone, + _session: PhantomData<&'a BrowserSession>, +} + +impl AuthorizedWebDriverBidiPresentationPlan<'_> { + /// Return the exact remote context selected by the lifecycle adapter mapping. + #[must_use] + pub const fn context(&self) -> &WebDriverBidiBrowsingContext { + self.context + } + + /// Materialize the two standard mutation actions while retaining this plan's lifetime. + #[must_use] + pub fn apply_actions(&self) -> [AuthorizedWebDriverBidiPresentationAction<'_>; 2] { + [ + AuthorizedWebDriverBidiPresentationAction { + operation: WebDriverBidiPresentationOperation::SetViewport, + context: self.context, + viewport: Some(self.viewport), + device_pixel_ratio: Some(self.device_pixel_ratio), + timezone: None, + _session: PhantomData, + }, + AuthorizedWebDriverBidiPresentationAction { + operation: WebDriverBidiPresentationOperation::SetTimezone, + context: self.context, + viewport: None, + device_pixel_ratio: None, + timezone: Some(self.timezone), + _session: PhantomData, + }, + ] + } + + /// Materialize default-reset cleanup for only the same currently owned lifecycle. + #[must_use] + pub fn cleanup_actions(&self) -> [AuthorizedWebDriverBidiPresentationAction<'_>; 2] { + [ + AuthorizedWebDriverBidiPresentationAction { + operation: WebDriverBidiPresentationOperation::ResetViewport, + context: self.context, + viewport: None, + device_pixel_ratio: None, + timezone: None, + _session: PhantomData, + }, + AuthorizedWebDriverBidiPresentationAction { + operation: WebDriverBidiPresentationOperation::ResetTimezone, + context: self.context, + viewport: None, + device_pixel_ratio: None, + timezone: None, + _session: PhantomData, + }, + ] + } +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + + #[derive(Debug)] + struct Backend { + created: Option, + fail_create: bool, + fail_destroy: bool, + destroy_calls: usize, + } + + impl Backend { + fn healthy() -> Self { + Self { + created: Some(WebDriverBidiCreatedContext::new( + isolation("user-context"), + WebDriverBidiBrowsingContext::new("remote-context") + .expect("valid remote context"), + )), + fail_create: false, + fail_destroy: false, + destroy_calls: 0, + } + } + } + + impl WebDriverBidiLifecycleBackend for Backend { + fn create_disposable_context( + &mut self, + _session: BrowserSessionId, + _incarnation: BrowserSessionIncarnation, + ) -> Result { + if self.fail_create { + return Err(DisposableContextCreateError::CreateFailedClean); + } + self.created + .take() + .ok_or(DisposableContextCreateError::CreateFailedClean) + } + + fn destroy_disposable_context( + &mut self, + _session: BrowserSessionId, + _incarnation: BrowserSessionIncarnation, + _isolation: &DisposableIsolationId, + _remote_context: &WebDriverBidiBrowsingContext, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls += 1; + if self.fail_destroy { + Err(DisposableContextDestroyError::DestroyFailed) + } else { + Ok(()) + } + } + } + + fn isolation(value: &str) -> DisposableIsolationId { + DisposableIsolationId::parse(value).expect("valid isolation") + } + + fn session_id(value: u64) -> BrowserSessionId { + BrowserSessionId::new(value).expect("valid session") + } + + #[test] + fn created_context_keeps_browser_issued_identities() { + let created = WebDriverBidiCreatedContext::new( + isolation("user-context-a"), + WebDriverBidiBrowsingContext::new("remote-a").expect("valid remote context"), + ); + assert_eq!(created.isolation().as_str(), "user-context-a"); + assert_eq!(created.context().as_str(), "remote-a"); + } + + #[test] + fn domain_context_allocator_fails_closed_without_wrapping_or_zero() { + let mut next = 1; + assert_eq!( + reserve_domain_context_id(&mut next) + .expect("first id") + .value(), + 1 + ); + assert_eq!(next, 2); + + let mut exhausted = u64::MAX; + assert_eq!( + reserve_domain_context_id(&mut exhausted), + Err(DisposableContextCreateError::CreateFailedClean) + ); + + let mut zero = 0; + assert_eq!( + reserve_domain_context_id(&mut zero), + Err(DisposableContextCreateError::CreateFailedClean) + ); + } + + #[test] + fn adapter_forwards_clean_create_failure_without_binding() { + let mut backend = Backend::healthy(); + backend.fail_create = true; + let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); + let incarnation = BrowserSessionIncarnation::from_test_value(1); + assert_eq!( + adapter.create_disposable_context(session_id(1), incarnation), + Err(DisposableContextCreateError::CreateFailedClean) + ); + assert!(adapter.bindings.is_empty()); + } + + #[test] + fn destroy_requires_exact_binding_and_retains_binding_on_backend_failure() { + let mut adapter = WebDriverBidiLifecycleAdapter::new(Backend::healthy()); + let incarnation = BrowserSessionIncarnation::from_test_value(2); + let handle = adapter + .create_disposable_context(session_id(2), incarnation) + .expect("create mapping"); + adapter.backend.fail_destroy = true; + assert_eq!( + adapter.destroy_disposable_context(session_id(2), incarnation, &handle), + Err(DisposableContextDestroyError::DestroyFailed) + ); + assert_eq!(adapter.backend.destroy_calls, 1); + assert_eq!(adapter.bindings.len(), 1); + + adapter.bindings.clear(); + assert_eq!( + adapter.destroy_disposable_context(session_id(2), incarnation, &handle), + Err(DisposableContextDestroyError::DestroyFailed) + ); + assert_eq!(adapter.backend.destroy_calls, 1); + } + + #[test] + fn authorization_fails_if_lifecycle_mapping_is_missing() { + let mut adapter = WebDriverBidiLifecycleAdapter::new(Backend::healthy()); + let mut session = BrowserSession::start(session_id(3)).expect("session"); + let authority = session + .create_disposable_context(&mut adapter) + .expect("owned context"); + adapter.bindings.clear(); + assert_eq!( + adapter.authorize_standard_presentation( + &session, + &authority, + ViewportBounds::new(800, 600).expect("viewport"), + DevicePixelRatio::Quantized1, + PresentationTimeZone::Utc, + ), + Err(WebDriverBidiAclError::LifecycleBindingMissing) + ); + } +} From d73837767571659d2cc3248ce9e5ddb12929e477 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 21:48:33 +0900 Subject: [PATCH 06/20] fix(bidi): keep lifecycle mapping adapter-owned and current --- crates/originweave-bidi/src/lifecycle_acl.rs | 231 +++---------------- 1 file changed, 38 insertions(+), 193 deletions(-) diff --git a/crates/originweave-bidi/src/lifecycle_acl.rs b/crates/originweave-bidi/src/lifecycle_acl.rs index 207114a7d..726a277da 100644 --- a/crates/originweave-bidi/src/lifecycle_acl.rs +++ b/crates/originweave-bidi/src/lifecycle_acl.rs @@ -20,22 +20,31 @@ pub enum WebDriverBidiAclError { LifecycleBindingMissing, } -/// Browser-issued identities returned by the reviewed WebDriver BiDi lifecycle backend. +/// Browser-issued and domain identities returned together by the reviewed lifecycle backend. /// -/// The user-context isolation identity and remote browsing-context string are adapter addressability, -/// not mutation authority. They become usable for presentation planning only after the owning -/// [`BrowserSession`] revalidates the exact retained authority against its current epoch. +/// The user-context isolation identity, domain context identity, and remote browsing-context string +/// are addressability, not mutation authority. Returning them as one value prevents the ACL from +/// stringifying a numeric domain id or accepting an unrelated remote context from its caller. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WebDriverBidiCreatedContext { isolation: DisposableIsolationId, - context: WebDriverBidiBrowsingContext, + browsing_context: BrowsingContextId, + remote_context: WebDriverBidiBrowsingContext, } impl WebDriverBidiCreatedContext { - /// Bind one browser-issued user-context identity to its exact remote browsing context. + /// Bind one adapter-allocated domain context to the exact browser-issued lifecycle identities. #[must_use] - pub fn new(isolation: DisposableIsolationId, context: WebDriverBidiBrowsingContext) -> Self { - Self { isolation, context } + pub fn new( + isolation: DisposableIsolationId, + browsing_context: BrowsingContextId, + remote_context: WebDriverBidiBrowsingContext, + ) -> Self { + Self { + isolation, + browsing_context, + remote_context, + } } /// Return the browser-issued disposable user-context identity. @@ -44,22 +53,28 @@ impl WebDriverBidiCreatedContext { &self.isolation } + /// Return the adapter-allocated OriginWeave domain context identity. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.browsing_context + } + /// Return the exact opaque WebDriver BiDi browsing-context identity. #[must_use] - pub const fn context(&self) -> &WebDriverBidiBrowsingContext { - &self.context + pub const fn remote_context(&self) -> &WebDriverBidiBrowsingContext { + &self.remote_context } } /// Reviewed transport boundary used by the WebDriver BiDi disposable-context adapter. /// /// A production implementation maps creation to `browser.createUserContext` followed by -/// `browsingContext.create`, returning the browser-issued identities without coercing OriginWeave's -/// numeric domain context id. Destruction maps to the exact user-context boundary and may return -/// success only after the remote boundary is proven absent; a command acknowledgement alone is not a -/// destruction post-condition. +/// `browsingContext.create`. It allocates the OriginWeave [`BrowsingContextId`] itself and returns that +/// domain identity together with the browser-issued user-context and remote browsing-context ids. +/// Destruction maps to the exact user-context boundary and may return success only after the remote +/// boundary is proven absent; a command acknowledgement alone is not a destruction post-condition. pub trait WebDriverBidiLifecycleBackend { - /// Create one disposable user context and one independently navigable context inside it. + /// Create one disposable user context and independently navigable context inside it. fn create_disposable_context( &mut self, session: BrowserSessionId, @@ -110,24 +125,22 @@ impl LifecycleBindingKey { /// WebDriver BiDi adapter that keeps remote context addressability bound to disposable lifecycle. /// -/// The private mapping is populated only by this adapter's successful lifecycle creation call. A raw -/// domain [`BrowsingContextId`] is never stringified into a protocol id, and callers cannot provide an -/// unrelated remote context to the authorization method. The mapping key includes Browser Session -/// incarnation and isolation identity so sequential id reuse cannot redirect a retained authority. +/// The private mapping is populated only from this adapter's successful lifecycle backend result. A +/// raw domain [`BrowsingContextId`] is never coerced into a protocol string, and authorization accepts +/// no caller-supplied remote context. The key includes Browser Session incarnation and isolation +/// identity so sequential id reuse cannot redirect a retained authority. #[derive(Debug)] pub struct WebDriverBidiLifecycleAdapter { backend: B, - next_browsing_context: u64, bindings: BTreeMap, } impl WebDriverBidiLifecycleAdapter { - /// Create an adapter with a fresh, process-local monotonic domain-context allocator. + /// Create an adapter with no ambient or caller-provided lifecycle bindings. #[must_use] pub fn new(backend: B) -> Self { Self { backend, - next_browsing_context: 1, bindings: BTreeMap::new(), } } @@ -137,7 +150,7 @@ impl WebDriverBidiLifecycleAdapter { /// Revalidate one retained Browser Session authority and bind it to its exact BiDi context. /// /// The returned plan borrows both this adapter mapping and the Browser Session. While the plan is - /// alive, safe Rust therefore cannot mutably advance/destroy/end that session or mutate this + /// alive, safe Rust cannot mutably advance, destroy, lose, or end that session or mutate this /// lifecycle adapter. Stale epoch, destruction, transport loss, recovery state, session end, or a /// different incarnation is rejected before a remote target is returned. pub fn authorize_standard_presentation<'a>( @@ -177,13 +190,12 @@ impl DisposableContextPort for WebDriverBidiLi browser_session: BrowserSessionId, incarnation: BrowserSessionIncarnation, ) -> Result { - let browsing_context = reserve_domain_context_id(&mut self.next_browsing_context)?; let created = self .backend .create_disposable_context(browser_session, incarnation)?; - let handle = DisposableContextHandle::new(created.isolation.clone(), browsing_context); + let handle = DisposableContextHandle::new(created.isolation.clone(), created.browsing_context); let key = LifecycleBindingKey::from_handle(browser_session, incarnation, &handle); - self.bindings.insert(key, created.context); + self.bindings.insert(key, created.remote_context); Ok(handle) } @@ -210,16 +222,6 @@ impl DisposableContextPort for WebDriverBidiLi } } -fn reserve_domain_context_id( - next: &mut u64, -) -> Result { - let value = *next; - *next = value - .checked_add(1) - .ok_or(DisposableContextCreateError::CreateFailedClean)?; - BrowsingContextId::new(value).map_err(|_error| DisposableContextCreateError::CreateFailedClean) -} - /// Standard presentation operation authorized for one currently owned BiDi context. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WebDriverBidiPresentationOperation { @@ -347,160 +349,3 @@ impl AuthorizedWebDriverBidiPresentationPlan<'_> { ] } } - -#[cfg(test)] -#[allow(clippy::expect_used)] -mod tests { - use super::*; - - #[derive(Debug)] - struct Backend { - created: Option, - fail_create: bool, - fail_destroy: bool, - destroy_calls: usize, - } - - impl Backend { - fn healthy() -> Self { - Self { - created: Some(WebDriverBidiCreatedContext::new( - isolation("user-context"), - WebDriverBidiBrowsingContext::new("remote-context") - .expect("valid remote context"), - )), - fail_create: false, - fail_destroy: false, - destroy_calls: 0, - } - } - } - - impl WebDriverBidiLifecycleBackend for Backend { - fn create_disposable_context( - &mut self, - _session: BrowserSessionId, - _incarnation: BrowserSessionIncarnation, - ) -> Result { - if self.fail_create { - return Err(DisposableContextCreateError::CreateFailedClean); - } - self.created - .take() - .ok_or(DisposableContextCreateError::CreateFailedClean) - } - - fn destroy_disposable_context( - &mut self, - _session: BrowserSessionId, - _incarnation: BrowserSessionIncarnation, - _isolation: &DisposableIsolationId, - _remote_context: &WebDriverBidiBrowsingContext, - ) -> Result<(), DisposableContextDestroyError> { - self.destroy_calls += 1; - if self.fail_destroy { - Err(DisposableContextDestroyError::DestroyFailed) - } else { - Ok(()) - } - } - } - - fn isolation(value: &str) -> DisposableIsolationId { - DisposableIsolationId::parse(value).expect("valid isolation") - } - - fn session_id(value: u64) -> BrowserSessionId { - BrowserSessionId::new(value).expect("valid session") - } - - #[test] - fn created_context_keeps_browser_issued_identities() { - let created = WebDriverBidiCreatedContext::new( - isolation("user-context-a"), - WebDriverBidiBrowsingContext::new("remote-a").expect("valid remote context"), - ); - assert_eq!(created.isolation().as_str(), "user-context-a"); - assert_eq!(created.context().as_str(), "remote-a"); - } - - #[test] - fn domain_context_allocator_fails_closed_without_wrapping_or_zero() { - let mut next = 1; - assert_eq!( - reserve_domain_context_id(&mut next) - .expect("first id") - .value(), - 1 - ); - assert_eq!(next, 2); - - let mut exhausted = u64::MAX; - assert_eq!( - reserve_domain_context_id(&mut exhausted), - Err(DisposableContextCreateError::CreateFailedClean) - ); - - let mut zero = 0; - assert_eq!( - reserve_domain_context_id(&mut zero), - Err(DisposableContextCreateError::CreateFailedClean) - ); - } - - #[test] - fn adapter_forwards_clean_create_failure_without_binding() { - let mut backend = Backend::healthy(); - backend.fail_create = true; - let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); - let incarnation = BrowserSessionIncarnation::from_test_value(1); - assert_eq!( - adapter.create_disposable_context(session_id(1), incarnation), - Err(DisposableContextCreateError::CreateFailedClean) - ); - assert!(adapter.bindings.is_empty()); - } - - #[test] - fn destroy_requires_exact_binding_and_retains_binding_on_backend_failure() { - let mut adapter = WebDriverBidiLifecycleAdapter::new(Backend::healthy()); - let incarnation = BrowserSessionIncarnation::from_test_value(2); - let handle = adapter - .create_disposable_context(session_id(2), incarnation) - .expect("create mapping"); - adapter.backend.fail_destroy = true; - assert_eq!( - adapter.destroy_disposable_context(session_id(2), incarnation, &handle), - Err(DisposableContextDestroyError::DestroyFailed) - ); - assert_eq!(adapter.backend.destroy_calls, 1); - assert_eq!(adapter.bindings.len(), 1); - - adapter.bindings.clear(); - assert_eq!( - adapter.destroy_disposable_context(session_id(2), incarnation, &handle), - Err(DisposableContextDestroyError::DestroyFailed) - ); - assert_eq!(adapter.backend.destroy_calls, 1); - } - - #[test] - fn authorization_fails_if_lifecycle_mapping_is_missing() { - let mut adapter = WebDriverBidiLifecycleAdapter::new(Backend::healthy()); - let mut session = BrowserSession::start(session_id(3)).expect("session"); - let authority = session - .create_disposable_context(&mut adapter) - .expect("owned context"); - adapter.bindings.clear(); - assert_eq!( - adapter.authorize_standard_presentation( - &session, - &authority, - ViewportBounds::new(800, 600).expect("viewport"), - DevicePixelRatio::Quantized1, - PresentationTimeZone::Utc, - ), - Err(WebDriverBidiAclError::LifecycleBindingMissing) - ); - } -} From dbedabc3235f9006506845aeaad1c25fbe0be865 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 21:49:27 +0900 Subject: [PATCH 07/20] test(bidi): cover lifecycle mapping failure and destruction edges --- .../tests/browser_session_acl.rs | 141 ++++++++++++++++-- 1 file changed, 126 insertions(+), 15 deletions(-) diff --git a/crates/originweave-bidi/tests/browser_session_acl.rs b/crates/originweave-bidi/tests/browser_session_acl.rs index 456aee986..7a7ca4e38 100644 --- a/crates/originweave-bidi/tests/browser_session_acl.rs +++ b/crates/originweave-bidi/tests/browser_session_acl.rs @@ -12,7 +12,7 @@ use originweave_browser_session::{ BrowserSession, BrowserSessionError, BrowserSessionIncarnation, DisposableContextCreateError, DisposableContextDestroyError, DisposableIsolationId, PresentationMutationAuthority, }; -use originweave_core::BrowserSessionId; +use originweave_core::{BrowserSessionId, BrowsingContextId}; use originweave_fingerprint::{DevicePixelRatio, PresentationTimeZone, ViewportBounds}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -27,24 +27,39 @@ struct DestroyTrace { struct FakeBackend { creates: VecDeque, destroys: Arc>>, + fail_destroy: bool, } impl FakeBackend { fn new( - contexts: impl IntoIterator, + contexts: impl IntoIterator, destroys: Arc>>, ) -> Self { let creates = contexts .into_iter() - .map(|(isolation, remote_context)| { + .map(|(domain_context, isolation, remote_context)| { WebDriverBidiCreatedContext::new( DisposableIsolationId::parse(isolation).expect("valid user context"), + BrowsingContextId::new(domain_context).expect("valid domain context"), WebDriverBidiBrowsingContext::new(remote_context) .expect("valid remote browsing context"), ) }) .collect(); - Self { creates, destroys } + Self { + creates, + destroys, + fail_destroy: false, + } + } + + fn failing_destroy( + contexts: impl IntoIterator, + destroys: Arc>>, + ) -> Self { + let mut backend = Self::new(contexts, destroys); + backend.fail_destroy = true; + backend } } @@ -75,7 +90,11 @@ impl WebDriverBidiLifecycleBackend for FakeBackend { isolation: isolation.clone(), remote_context: remote_context.as_str().to_owned(), }); - Ok(()) + if self.fail_destroy { + Err(DisposableContextDestroyError::DestroyFailed) + } else { + Ok(()) + } } } @@ -93,13 +112,25 @@ fn authorize<'a>( ) } +#[test] +fn created_context_keeps_domain_and_remote_identities_distinct() { + let created = WebDriverBidiCreatedContext::new( + DisposableIsolationId::parse("user-context").expect("valid user context"), + BrowsingContextId::new(77).expect("valid domain context"), + WebDriverBidiBrowsingContext::new("remote-context").expect("valid remote context"), + ); + assert_eq!(created.isolation().as_str(), "user-context"); + assert_eq!(created.browsing_context().value(), 77); + assert_eq!(created.remote_context().as_str(), "remote-context"); +} + #[test] fn exact_lifecycle_mapping_is_the_only_remote_context_source() { let destroys = Arc::new(Mutex::new(Vec::new())); let backend = FakeBackend::new( [ - ("user-context-a", "remote-context-a"), - ("user-context-b", "remote-context-b"), + (101, "user-context-a", "remote-context-a"), + (102, "user-context-b", "remote-context-b"), ], destroys, ); @@ -120,12 +151,24 @@ fn exact_lifecycle_mapping_is_the_only_remote_context_source() { assert_eq!(plan_b.context().as_str(), "remote-context-b"); let [viewport, timezone] = plan_a.apply_actions(); - assert_eq!(viewport.operation(), WebDriverBidiPresentationOperation::SetViewport); + assert_eq!( + viewport.operation(), + WebDriverBidiPresentationOperation::SetViewport + ); assert_eq!(viewport.context().as_str(), "remote-context-a"); - assert_eq!(viewport.viewport(), Some(ViewportBounds::new(1440, 900).expect("viewport"))); - assert_eq!(viewport.device_pixel_ratio(), Some(DevicePixelRatio::Quantized2)); + assert_eq!( + viewport.viewport(), + Some(ViewportBounds::new(1440, 900).expect("viewport")) + ); + assert_eq!( + viewport.device_pixel_ratio(), + Some(DevicePixelRatio::Quantized2) + ); assert_eq!(viewport.timezone(), None); - assert_eq!(timezone.operation(), WebDriverBidiPresentationOperation::SetTimezone); + assert_eq!( + timezone.operation(), + WebDriverBidiPresentationOperation::SetTimezone + ); assert_eq!(timezone.context().as_str(), "remote-context-a"); assert_eq!(timezone.viewport(), None); assert_eq!(timezone.device_pixel_ratio(), None); @@ -147,7 +190,7 @@ fn exact_lifecycle_mapping_is_the_only_remote_context_source() { #[test] fn stale_epoch_is_rejected_before_any_remote_target_can_be_projected() { let destroys = Arc::new(Mutex::new(Vec::new())); - let backend = FakeBackend::new([("user-context-a", "remote-context-a")], destroys); + let backend = FakeBackend::new([(201, "user-context-a", "remote-context-a")], destroys); let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); let mut session = BrowserSession::start(BrowserSessionId::new(42).expect("valid session")) .expect("fresh incarnation"); @@ -175,9 +218,77 @@ fn stale_epoch_is_rejected_before_any_remote_target_can_be_projected() { } #[test] -fn destroyed_or_transport_lost_context_cannot_project_bidi_authority() { +fn unrelated_adapter_mapping_is_rejected_and_cannot_destroy_owned_context() { + let destroys = Arc::new(Mutex::new(Vec::new())); + let mut owner_adapter = WebDriverBidiLifecycleAdapter::new(FakeBackend::new( + [(301, "user-context-a", "remote-context-a")], + destroys.clone(), + )); + let mut unrelated_adapter = + WebDriverBidiLifecycleAdapter::new(FakeBackend::new([], destroys.clone())); + let mut session = BrowserSession::start(BrowserSessionId::new(45).expect("valid session")) + .expect("fresh incarnation"); + let authority = session + .create_disposable_context(&mut owner_adapter) + .expect("owned context"); + + assert_eq!( + authorize(&unrelated_adapter, &session, &authority), + Err(WebDriverBidiAclError::LifecycleBindingMissing) + ); + assert_eq!( + session.destroy_disposable_context(&authority, &mut unrelated_adapter), + Err(BrowserSessionError::ContextDestructionFailed) + ); + assert!(destroys.lock().expect("trace lock").is_empty()); +} + +#[test] +fn clean_creation_failure_does_not_mint_authority_or_remote_binding() { let destroys = Arc::new(Mutex::new(Vec::new())); - let backend = FakeBackend::new([("user-context-a", "remote-context-a")], destroys.clone()); + let mut adapter = WebDriverBidiLifecycleAdapter::new(FakeBackend::new([], destroys)); + let mut session = BrowserSession::start(BrowserSessionId::new(46).expect("valid session")) + .expect("fresh incarnation"); + assert_eq!( + session.create_disposable_context(&mut adapter), + Err(BrowserSessionError::ContextCreationFailed) + ); +} + +#[test] +fn failed_remote_destruction_remains_unproven_and_blocks_authorization() { + let destroys = Arc::new(Mutex::new(Vec::new())); + let backend = FakeBackend::failing_destroy( + [(401, "user-context-a", "remote-context-a")], + destroys.clone(), + ); + let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); + let mut session = BrowserSession::start(BrowserSessionId::new(47).expect("valid session")) + .expect("fresh incarnation"); + let authority = session + .create_disposable_context(&mut adapter) + .expect("owned context"); + + assert_eq!( + session.destroy_disposable_context(&authority, &mut adapter), + Err(BrowserSessionError::ContextDestructionFailed) + ); + assert_eq!(destroys.lock().expect("trace lock").len(), 1); + assert_eq!( + authorize(&adapter, &session, &authority), + Err(WebDriverBidiAclError::BrowserSession( + BrowserSessionError::SessionNotActive, + )) + ); +} + +#[test] +fn destroyed_transport_lost_or_ended_context_cannot_project_bidi_authority() { + let destroys = Arc::new(Mutex::new(Vec::new())); + let backend = FakeBackend::new( + [(501, "user-context-a", "remote-context-a")], + destroys.clone(), + ); let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); let mut session = BrowserSession::start(BrowserSessionId::new(43).expect("valid session")) .expect("fresh incarnation"); @@ -209,7 +320,7 @@ fn destroyed_or_transport_lost_context_cannot_project_bidi_authority() { ); let destroys = Arc::new(Mutex::new(Vec::new())); - let backend = FakeBackend::new([("user-context-b", "remote-context-b")], destroys); + let backend = FakeBackend::new([(502, "user-context-b", "remote-context-b")], destroys); let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); let mut lost_session = BrowserSession::start(BrowserSessionId::new(44).expect("valid session")) From 185ec27e54f5eb2a90b757b02b47f0d50adbc41d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 21:49:37 +0900 Subject: [PATCH 08/20] feat(bidi): expose lifecycle-bound presentation ACL --- crates/originweave-bidi/src/lib.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/originweave-bidi/src/lib.rs b/crates/originweave-bidi/src/lib.rs index 23ba4862c..e1b7b3707 100644 --- a/crates/originweave-bidi/src/lib.rs +++ b/crates/originweave-bidi/src/lib.rs @@ -1,15 +1,21 @@ //! Narrow WebDriver BiDi adapter contracts for OriginWeave browser sessions. //! -//! This crate depends inward on presentation-identity values. It records only -//! capabilities that the pinned WebDriver BiDi specification can express; it -//! does not expose generic JavaScript or DevTools pass-through authority and it -//! does not claim that a command acknowledgement proves page-visible state. +//! This crate depends inward on presentation-identity and Browser Session values. It records only +//! capabilities that the pinned WebDriver BiDi specification can express; it does not expose generic +//! JavaScript or DevTools pass-through authority and it does not claim that a command acknowledgement +//! proves page-visible state. #![forbid(unsafe_code)] #![deny(missing_docs)] +mod lifecycle_acl; mod presentation_capabilities; +pub use lifecycle_acl::{ + AuthorizedWebDriverBidiPresentationAction, AuthorizedWebDriverBidiPresentationPlan, + WebDriverBidiAclError, WebDriverBidiCreatedContext, WebDriverBidiLifecycleAdapter, + WebDriverBidiLifecycleBackend, WebDriverBidiPresentationOperation, +}; pub use presentation_capabilities::{ WEBDRIVER_BIDI_PRESENTATION_DOCTORING_SOURCE_COMMIT, WEBDRIVER_BIDI_PRESENTATION_REVISION, WebDriverBidiBrowsingContext, WebDriverBidiCommandError, WebDriverBidiPresentationCommand, From 6bf863f44155c45a08dabe4efadfef5a2ac62c4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 22:10:45 +0900 Subject: [PATCH 09/20] chore: refresh lockfile for BiDi lifecycle ACL --- Cargo.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index c268c0ccc..56021bfa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -271,6 +271,8 @@ version = "0.1.0" name = "originweave-bidi" version = "0.1.0" dependencies = [ + "originweave-browser-session", + "originweave-core", "originweave-fingerprint", ] From 13e71aa0cc236db5dd0068a70ff7ac6aa4f0b800 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 22:14:12 +0900 Subject: [PATCH 10/20] style: apply canonical rustfmt to BiDi lifecycle ACL --- crates/originweave-bidi/src/lifecycle_acl.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-bidi/src/lifecycle_acl.rs b/crates/originweave-bidi/src/lifecycle_acl.rs index 726a277da..bdf3ac4c7 100644 --- a/crates/originweave-bidi/src/lifecycle_acl.rs +++ b/crates/originweave-bidi/src/lifecycle_acl.rs @@ -193,7 +193,8 @@ impl DisposableContextPort for WebDriverBidiLi let created = self .backend .create_disposable_context(browser_session, incarnation)?; - let handle = DisposableContextHandle::new(created.isolation.clone(), created.browsing_context); + let handle = + DisposableContextHandle::new(created.isolation.clone(), created.browsing_context); let key = LifecycleBindingKey::from_handle(browser_session, incarnation, &handle); self.bindings.insert(key, created.remote_context); Ok(handle) From 8a9b9e436ffc2f929d91ce605401df4ef51be819 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 22:15:19 +0900 Subject: [PATCH 11/20] style: apply canonical rustfmt to BiDi ACL tests --- crates/originweave-bidi/tests/browser_session_acl.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/originweave-bidi/tests/browser_session_acl.rs b/crates/originweave-bidi/tests/browser_session_acl.rs index 7a7ca4e38..2cde1fc6f 100644 --- a/crates/originweave-bidi/tests/browser_session_acl.rs +++ b/crates/originweave-bidi/tests/browser_session_acl.rs @@ -322,9 +322,8 @@ fn destroyed_transport_lost_or_ended_context_cannot_project_bidi_authority() { let destroys = Arc::new(Mutex::new(Vec::new())); let backend = FakeBackend::new([(502, "user-context-b", "remote-context-b")], destroys); let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); - let mut lost_session = - BrowserSession::start(BrowserSessionId::new(44).expect("valid session")) - .expect("fresh incarnation"); + let mut lost_session = BrowserSession::start(BrowserSessionId::new(44).expect("valid session")) + .expect("fresh incarnation"); let lost_authority = lost_session .create_disposable_context(&mut adapter) .expect("owned context"); From 2a8cffa6022fd9e3ef66f46d18e645e861c64ce3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 22:17:33 +0900 Subject: [PATCH 12/20] test: reject exact BiDi lifecycle binding reuse before replacement --- .../tests/browser_session_acl.rs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/originweave-bidi/tests/browser_session_acl.rs b/crates/originweave-bidi/tests/browser_session_acl.rs index 2cde1fc6f..7e9c0bf35 100644 --- a/crates/originweave-bidi/tests/browser_session_acl.rs +++ b/crates/originweave-bidi/tests/browser_session_acl.rs @@ -9,8 +9,9 @@ use originweave_bidi::{ WebDriverBidiPresentationOperation, }; use originweave_browser_session::{ - BrowserSession, BrowserSessionError, BrowserSessionIncarnation, DisposableContextCreateError, - DisposableContextDestroyError, DisposableIsolationId, PresentationMutationAuthority, + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, BrowserSessionState, + DisposableContextCreateError, DisposableContextDestroyError, DisposableIsolationId, + PresentationMutationAuthority, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; use originweave_fingerprint::{DevicePixelRatio, PresentationTimeZone, ViewportBounds}; @@ -187,6 +188,30 @@ fn exact_lifecycle_mapping_is_the_only_remote_context_source() { assert_eq!(reset_timezone.context().as_str(), "remote-context-a"); } +#[test] +fn exact_lifecycle_key_reuse_is_quarantined_before_binding_replacement() { + let destroys = Arc::new(Mutex::new(Vec::new())); + let backend = FakeBackend::new( + [ + (150, "user-context-reused", "remote-context-original"), + (150, "user-context-reused", "remote-context-conflicting"), + ], + destroys, + ); + let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); + let mut session = BrowserSession::start(BrowserSessionId::new(48).expect("valid session")) + .expect("fresh incarnation"); + + session + .create_disposable_context(&mut adapter) + .expect("first owned context"); + assert_eq!( + session.create_disposable_context(&mut adapter), + Err(BrowserSessionError::ContextCreationUncertain) + ); + assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); +} + #[test] fn stale_epoch_is_rejected_before_any_remote_target_can_be_projected() { let destroys = Arc::new(Mutex::new(Vec::new())); From a5f68d34477a082e118b85390f8dbfb1dfe8a2e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 22:20:31 +0900 Subject: [PATCH 13/20] fix(bidi): quarantine duplicate lifecycle binding keys --- crates/originweave-bidi/src/lifecycle_acl.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/originweave-bidi/src/lifecycle_acl.rs b/crates/originweave-bidi/src/lifecycle_acl.rs index bdf3ac4c7..849236a0e 100644 --- a/crates/originweave-bidi/src/lifecycle_acl.rs +++ b/crates/originweave-bidi/src/lifecycle_acl.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, btree_map::Entry}; use std::marker::PhantomData; use originweave_browser_session::{ @@ -196,8 +196,15 @@ impl DisposableContextPort for WebDriverBidiLi let handle = DisposableContextHandle::new(created.isolation.clone(), created.browsing_context); let key = LifecycleBindingKey::from_handle(browser_session, incarnation, &handle); - self.bindings.insert(key, created.remote_context); - Ok(handle) + match self.bindings.entry(key) { + Entry::Vacant(binding) => { + binding.insert(created.remote_context); + Ok(handle) + } + Entry::Occupied(_) => Err(DisposableContextCreateError::CreateFailedUncertain(Some( + created.isolation, + ))), + } } fn destroy_disposable_context( From 215338b77ddb3d828ebc95b796f5e8080000afde Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 23:09:43 +0900 Subject: [PATCH 14/20] test(bidi): reject remote context alias across owned handles --- .../tests/remote_context_alias.rs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 crates/originweave-bidi/tests/remote_context_alias.rs diff --git a/crates/originweave-bidi/tests/remote_context_alias.rs b/crates/originweave-bidi/tests/remote_context_alias.rs new file mode 100644 index 000000000..37af4f3b2 --- /dev/null +++ b/crates/originweave-bidi/tests/remote_context_alias.rs @@ -0,0 +1,71 @@ +#![allow(clippy::expect_used)] + +use std::collections::VecDeque; + +use originweave_bidi::{ + WebDriverBidiBrowsingContext, WebDriverBidiCreatedContext, WebDriverBidiLifecycleAdapter, + WebDriverBidiLifecycleBackend, +}; +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, BrowserSessionState, + DisposableContextCreateError, DisposableContextDestroyError, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct AliasingBackend { + creates: VecDeque, +} + +impl WebDriverBidiLifecycleBackend for AliasingBackend { + fn create_disposable_context( + &mut self, + _session: BrowserSessionId, + _incarnation: BrowserSessionIncarnation, + ) -> Result { + self.creates + .pop_front() + .ok_or(DisposableContextCreateError::CreateFailedClean) + } + + fn destroy_disposable_context( + &mut self, + _session: BrowserSessionId, + _incarnation: BrowserSessionIncarnation, + _isolation: &DisposableIsolationId, + _remote_context: &WebDriverBidiBrowsingContext, + ) -> Result<(), DisposableContextDestroyError> { + Ok(()) + } +} + +#[test] +fn duplicate_remote_context_is_rejected_before_second_authority_is_minted() { + let shared_remote = "remote-context-shared"; + let backend = AliasingBackend { + creates: VecDeque::from([ + WebDriverBidiCreatedContext::new( + DisposableIsolationId::parse("user-context-a").expect("valid isolation"), + BrowsingContextId::new(701).expect("valid domain context"), + WebDriverBidiBrowsingContext::new(shared_remote).expect("valid remote context"), + ), + WebDriverBidiCreatedContext::new( + DisposableIsolationId::parse("user-context-b").expect("valid isolation"), + BrowsingContextId::new(702).expect("valid domain context"), + WebDriverBidiBrowsingContext::new(shared_remote).expect("valid remote context"), + ), + ]), + }; + let mut adapter = WebDriverBidiLifecycleAdapter::new(backend); + let mut session = BrowserSession::start(BrowserSessionId::new(70).expect("valid session")) + .expect("fresh incarnation"); + + session + .create_disposable_context(&mut adapter) + .expect("first context owns the remote target"); + assert_eq!( + session.create_disposable_context(&mut adapter), + Err(BrowserSessionError::ContextCreationUncertain) + ); + assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); +} From 48eefea1f8a50deaa84cec27d414d0008372d072 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 23:12:17 +0900 Subject: [PATCH 15/20] fix(bidi): fail closed on live remote context alias --- crates/originweave-bidi/src/lifecycle_acl.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/originweave-bidi/src/lifecycle_acl.rs b/crates/originweave-bidi/src/lifecycle_acl.rs index 849236a0e..181b69691 100644 --- a/crates/originweave-bidi/src/lifecycle_acl.rs +++ b/crates/originweave-bidi/src/lifecycle_acl.rs @@ -193,6 +193,15 @@ impl DisposableContextPort for WebDriverBidiLi let created = self .backend .create_disposable_context(browser_session, incarnation)?; + if self + .bindings + .values() + .any(|remote_context| remote_context == &created.remote_context) + { + return Err(DisposableContextCreateError::CreateFailedUncertain(Some( + created.isolation, + ))); + } let handle = DisposableContextHandle::new(created.isolation.clone(), created.browsing_context); let key = LifecycleBindingKey::from_handle(browser_session, incarnation, &handle); From 67ee72a04425766826f432c938cd18dab6e9fb85 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 23:21:02 +0900 Subject: [PATCH 16/20] fix(bidi): cover remote context alias guard exactly --- crates/originweave-bidi/src/lifecycle_acl.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-bidi/src/lifecycle_acl.rs b/crates/originweave-bidi/src/lifecycle_acl.rs index 181b69691..475304ee0 100644 --- a/crates/originweave-bidi/src/lifecycle_acl.rs +++ b/crates/originweave-bidi/src/lifecycle_acl.rs @@ -193,11 +193,11 @@ impl DisposableContextPort for WebDriverBidiLi let created = self .backend .create_disposable_context(browser_session, incarnation)?; - if self + let remote_context_is_aliased = self .bindings .values() - .any(|remote_context| remote_context == &created.remote_context) - { + .any(|remote_context| remote_context == &created.remote_context); + if remote_context_is_aliased { return Err(DisposableContextCreateError::CreateFailedUncertain(Some( created.isolation, ))); From e35c9865a3dcb9fd1fd482c6d6bb7f77cb00c7e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 23:25:05 +0900 Subject: [PATCH 17/20] docs(adr): define Browser Session to BiDi lifecycle ACL --- ...er-session-webdriver-bidi-lifecycle-acl.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/adr/0115-browser-session-webdriver-bidi-lifecycle-acl.md diff --git a/docs/adr/0115-browser-session-webdriver-bidi-lifecycle-acl.md b/docs/adr/0115-browser-session-webdriver-bidi-lifecycle-acl.md new file mode 100644 index 000000000..744a68ff5 --- /dev/null +++ b/docs/adr/0115-browser-session-webdriver-bidi-lifecycle-acl.md @@ -0,0 +1,118 @@ +# ADR 0115: Browser Session to WebDriver BiDi lifecycle ACL + +- Status: Proposed +- Date: 2026-09-10 + +## Context + +OriginWeave must translate Browser Session-owned presentation authority into WebDriver BiDi addressability without allowing raw protocol identifiers to become authority. ADR 0114 establishes disposable-context ownership, session incarnation, context epochs, recovery evidence, and transport-liveness semantics in the Browser Session bounded context. The WebDriver BiDi adapter still needs a separate anti-corruption boundary that binds those domain identities to the browser-issued browsing-context string used by presentation commands. + +An exact domain key alone is insufficient. A backend can return two apparently different lifecycle handles whose isolation and OriginWeave browsing-context identities differ while their opaque WebDriver BiDi browsing-context string aliases the same live remote target. If both results are accepted, Browser Session can mint two valid authorities that later project to one browser context. That is cross-owner authority confusion even though neither domain key collides. + +The 9 September 2026 WebDriver BiDi Working Draft defines `browser.createUserContext`, `browsingContext.create`, and `browser.removeUserContext`. User-context and browsing-context identifiers provide protocol addressability. They do not replace OriginWeave's policy and lifecycle authority, and a successful command response does not by itself prove the browser-side post-condition required for buyer evidence. + +## Decision drivers + +- Browser Session remains the authority owner; WebDriver BiDi remains an adapter. +- Raw browser-session, user-context, domain-context, or remote BiDi identifiers must not mint presentation authority. +- A retained authority must be revalidated against the current Browser Session immediately before remote-target projection. +- One live opaque remote browsing-context string must not be bound to multiple independently owned lifecycle handles. +- Sequential external identifier reuse must not revive stale authority across Browser Session incarnations. +- Planning must remain distinct from browser command acknowledgement and observed post-condition evidence. +- Ambiguous remote state must fail closed without speculative cleanup of potentially foreign browser state. + +## Assumptions and authority boundaries + +ADR 0114's `BrowserSession`, `BrowserSessionIncarnation`, `DisposableContextHandle`, context epoch, and `PresentationMutationAuthority` are the canonical domain inputs. `WebDriverBidiLifecycleAdapter` owns a private anti-corruption mapping from that lifecycle identity to `WebDriverBidiBrowsingContext`. The mapping is addressability state, not authority state. + +The reviewed lifecycle backend may obtain browser-issued user-context and browsing-context identifiers by WebDriver BiDi. It may allocate the OriginWeave domain `BrowsingContextId` required by the port contract. It cannot manufacture Browser Session authority, weaken epoch or incarnation checks, or accept caller-supplied remote identifiers as substitutes for the stored mapping. + +Screen-area mutation remains outside the standard reusable plan because its complete presentation-surface ownership contract is not yet established. This ADR does not broaden that capability. + +## Options considered + +### Reconstruct the remote context from the OriginWeave browsing-context id + +Rejected. Domain identity and protocol addressability have different semantics, and numeric/string coercion would create an implicit cross-boundary alias. + +### Let callers supply the remote BiDi context during authorization + +Rejected. A caller that possesses raw protocol addressability would be able to redirect an otherwise valid Browser Session authority to another target. + +### Key the adapter only by Browser Session lifecycle identity + +Rejected as incomplete. Exact-key uniqueness does not prevent two distinct keys from aliasing the same live remote browsing-context string. + +### Treat WebDriver BiDi identifiers as durable capabilities + +Rejected. The protocol identifiers are addressability. OriginWeave authority is generated and revalidated by Browser Session lifecycle state, incarnation, isolation, and epoch. + +### Automatically destroy a duplicate or ambiguous remote result + +Rejected. When ownership is ambiguous, cleanup itself can become a cross-owner destructive action. The adapter must quarantine/fail closed and preserve recovery evidence instead of guessing ownership. + +## Decision + +1. `WebDriverBidiLifecycleAdapter` owns the private mapping from the exact Browser Session lifecycle key to the opaque `WebDriverBidiBrowsingContext` returned by its reviewed backend. +2. The mapping key binds browser session, `BrowserSessionIncarnation`, disposable isolation identity, and OriginWeave `BrowsingContextId`. No public constructor exposes an equivalent command capability. +3. Before accepting a newly created lifecycle result, the adapter rejects any result whose opaque remote browsing-context string is already bound to another live lifecycle key. The existing binding is not replaced. The Browser Session receives an uncertain creation result and enters its fail-closed recovery path before a second authority can be minted. +4. Exact lifecycle-key reuse is also rejected before replacement. Domain-key collision and remote-target collision are independent checks. +5. `authorize_standard_presentation` asks the live `BrowserSession` for the current authority for the requested domain context and requires exact equality with the retained token before consulting the private remote mapping. Stale epoch, destruction, transport loss, recovery state, session end, foreign isolation, foreign session, or foreign incarnation therefore fails before a remote target is returned. +6. The authorized presentation plan borrows both the Browser Session and lifecycle adapter. While it is alive, safe Rust cannot mutably advance, destroy, lose, or end that session or replace the adapter mapping. The plan and its actions have private construction paths. +7. The standard plan may express only the already-admitted viewport/device-pixel-ratio and timezone apply/reset operations. It does not grant screen-area mutation or any unrelated BiDi command authority. +8. Plan creation proves policy/lifecycle admission only. Transport command acknowledgement, page-observed state, cleanup, and destruction post-conditions require separate runtime evidence. +9. Ambiguous post-create results must not be speculatively destroyed. The current Browser Session recovery contract can preserve a known isolation identity, but complete BiDi-specific recovery must additionally retain the domain browsing-context and opaque remote browsing-context identities when they are known. That adapter-specific recovery evidence is a follow-up and grants no command authority. + +## Consequences + +A valid Browser Session authority can no longer be redirected by supplying or reconstructing a remote BiDi context. A second lifecycle result that aliases an already-live remote target is quarantined before Browser Session can mint another authority, even when its user-context and domain-context identities are distinct. + +The adapter now has a stronger uniqueness invariant than its map key alone expresses: live remote browsing-context identity is unique across accepted bindings. This check is intentionally local to the BiDi anti-corruption boundary because Browser Session must not depend on protocol-specific strings. + +The lifetime-bound plan reduces time-of-check/time-of-use drift inside safe Rust, but it does not prove external browser state. A transport or browser crash after planning remains an execution/recovery concern. + +The current uncertain-create interface loses part of a complete BiDi-created tuple when the adapter rejects an alias after the backend has returned it. Until adapter-specific quarantine evidence retains the known isolation, domain context, and remote context together, recovery diagnostics are incomplete. This is an explicit open gap rather than a reason to relax the fail-closed behavior. + +## Failure and degraded behavior + +Missing lifecycle mappings, stale or foreign authority, duplicate lifecycle keys, and duplicate live remote targets fail closed before presentation planning. Creation ambiguity moves Browser Session into `RecoveryRequired`; no new normal authority is issued. A failed remote destruction remains unproven and blocks further authorization according to ADR 0114. + +If the adapter has an ambiguous browser-created tuple that cannot yet be represented completely in recovery evidence, it must retain fail-closed product behavior and must not auto-destroy the remote state. Reconciliation requires a separately authorized design. + +## Security / privacy / governance impact + +This ACL prevents protocol-addressability confusion from crossing the Browser Session authority boundary. Page content, LLM output, MCP input, extension input, or raw BiDi identifiers cannot construct the plan or choose its remote target. + +The change does not replace Chromium sandboxing, EgressWeave network policy, Keyverse identity/secrets, Wardnet controls, contextual-orchestrator model governance, or central repository security gates. It introduces no cross-service SQL, mutable external dependency, provider/model routing, or new secret surface. + +## Tests and acceptance evidence + +The ACL tests cover exact lifecycle mapping, raw/unrelated mapping rejection, stale epoch, destruction, transport loss, session end, clean creation failure, exact lifecycle-key reuse, failed destruction, and lifetime-bound planning. + +A hostile test, `duplicate_remote_context_is_rejected_before_second_authority_is_minted`, creates one accepted lifecycle result and then returns a second result with distinct isolation and OriginWeave browsing-context identities but the same opaque remote BiDi browsing-context string. On exact commit `215338b77ddb3d828ebc95b796f5e8080000afde`, CI run `34487233621` failed because the second creation incorrectly returned a valid `PresentationMutationAuthority`, establishing the RED. The minimal causal fix rejects the alias before insertion and authority minting while leaving the existing binding intact. + +Repository contracts, canonical formatting, locked Rust tests, strict Clippy, rustdoc/API docs, and exact production function/line/region/branch coverage remain mandatory on the final exact head. Predecessor GREEN does not transfer after source or documentation changes. Independent review and applicable central checks remain required before ordinary adoption. + +Real-browser acceptance remains separate: a production backend must prove `browser.createUserContext`/`browsingContext.create` creation, exact `browser.removeUserContext` destruction or equivalent observed absence, presentation application, page-observed post-conditions, cleanup, crash/restart behavior, and the applicable pinned/current Chromium qualification. + +## Migration and rollback + +This is an additive stacked-branch ACL over the Browser Session lifecycle foundation. Consumers should obtain remote context only through the lifecycle adapter and should not persist or reconstruct the authorized plan. No protected-main migration or durable database schema is introduced. + +Rollback removes this active feature slice while leaving ADR 0114's Browser Session authority fail closed. It must not restore caller-supplied remote contexts, raw-id coercion, or duplicate-target acceptance. + +## Open follow-ups + +- Add adapter-specific quarantine/recovery evidence that losslessly preserves every known `WebDriverBidiCreatedContext` identity after uncertain post-create outcomes without turning that evidence into cleanup authority. +- Reject or quarantine all other partial identity aliases that can leave an adapter mapping for a handle Browser Session did not adopt, including isolation-only or domain-context-only reuse. +- Implement the real WebDriver BiDi disposable-user-context backend and prove browser-observed destruction rather than command ACK. +- Integrate authorized presentation plans with the pinned Chromium Agent Task lane and page-observed evidence. +- Reconcile the parent Browser Session allocator deprecation (`AtomicU64::fetch_update` renamed to `try_update`) in its canonical owner without mixing that maintenance change into this ACL authority slice. + +## Supersession / reversal conditions + +Supersede this ADR if the browser protocol or a later OriginWeave adapter provides a stronger generation-safe lifecycle primitive that directly proves unique remote ownership and observed destruction while preserving Browser Session as policy authority. Do not regress to raw protocol identity as authority. + +## References + +Browser Testing and Tools Working Group. (2026, September 9). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/ From b1c1648127defb89723606207033f5da5826c3ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 23:25:56 +0900 Subject: [PATCH 18/20] docs(adr): index Browser Session BiDi ACL decision --- 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 2c492ba95..b021f8f06 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -67,10 +67,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 | +| [0115](0115-browser-session-webdriver-bidi-lifecycle-acl.md) | Browser Session to WebDriver BiDi lifecycle ACL | Proposed | live-authority revalidation, private remote-context mapping, remote-target alias rejection, lifetime-bound presentation planning | -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. +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. ADR 0115 belongs to the Browser Session→WebDriver BiDi ACL successor for issue #314. 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, ADR 0113, or ADR 0114 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, ADR 0114, or ADR 0115 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 3b2e80047cbe992ac441b989ae213bf65cbb3f17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 04:09:31 +0900 Subject: [PATCH 19/20] docs(adr): index Browser Session BiDi lifecycle ACL --- docs/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index 772ccead9..3fb3e7d01 100644 --- a/docs/README.md +++ b/docs/README.md @@ -95,9 +95,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 0115: Browser Session to WebDriver BiDi lifecycle ACL](adr/0115-browser-session-webdriver-bidi-lifecycle-acl.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. 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. +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 Browser Session lifecycle lineage now inherited by this active stack. ADR 0115 is owned by the active Browser Session→WebDriver BiDi ACL 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, ADR 0113, or ADR 0114 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, ADR 0114, or ADR 0115 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 8ca6c5a190d9ad2b4c7843d440e91f6070d681c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 04:10:58 +0900 Subject: [PATCH 20/20] docs(adr): bound BiDi execution by remote lifecycle evidence --- ...er-session-webdriver-bidi-lifecycle-acl.md | 85 ++++++++++++------- 1 file changed, 54 insertions(+), 31 deletions(-) diff --git a/docs/adr/0115-browser-session-webdriver-bidi-lifecycle-acl.md b/docs/adr/0115-browser-session-webdriver-bidi-lifecycle-acl.md index 744a68ff5..e6a724976 100644 --- a/docs/adr/0115-browser-session-webdriver-bidi-lifecycle-acl.md +++ b/docs/adr/0115-browser-session-webdriver-bidi-lifecycle-acl.md @@ -9,15 +9,20 @@ OriginWeave must translate Browser Session-owned presentation authority into Web An exact domain key alone is insufficient. A backend can return two apparently different lifecycle handles whose isolation and OriginWeave browsing-context identities differ while their opaque WebDriver BiDi browsing-context string aliases the same live remote target. If both results are accepted, Browser Session can mint two valid authorities that later project to one browser context. That is cross-owner authority confusion even though neither domain key collides. -The 9 September 2026 WebDriver BiDi Working Draft defines `browser.createUserContext`, `browsingContext.create`, and `browser.removeUserContext`. User-context and browsing-context identifiers provide protocol addressability. They do not replace OriginWeave's policy and lifecycle authority, and a successful command response does not by itself prove the browser-side post-condition required for buyer evidence. +Local Rust lifetime also is not browser-liveness proof. The browser can destroy a navigable or terminate the BiDi session while OriginWeave still holds immutable `BrowserSession` and adapter borrows. A plan that remains type-valid after `browsingContext.contextDestroyed` or transport loss therefore cannot be executable merely because no local aggregate mutation occurred. + +The 9 September 2026 WebDriver BiDi Working Draft defines `browser.createUserContext`, `browsingContext.create`, `browser.removeUserContext`, and the `browsingContext.contextDestroyed` event. User-context and browsing-context identifiers provide protocol addressability. They do not replace OriginWeave's policy and lifecycle authority, and a successful command response does not by itself prove the browser-side post-condition required for buyer evidence. ## Decision drivers - Browser Session remains the authority owner; WebDriver BiDi remains an adapter. -- Raw browser-session, user-context, domain-context, or remote BiDi identifiers must not mint presentation authority. -- A retained authority must be revalidated against the current Browser Session immediately before remote-target projection. +- Raw browser-session, user-context, domain-context, or remote BiDi identifiers must not mint presentation authority or directly authorize lifecycle mutation. +- A retained authority must be revalidated against the current Browser Session immediately before browser mutation, not only when a plan is created. +- The same execution boundary must verify current adapter-observed remote-context and BiDi-session liveness immediately before I/O. - One live opaque remote browsing-context string must not be bound to multiple independently owned lifecycle handles. - Sequential external identifier reuse must not revive stale authority across Browser Session incarnations. +- Remote creation must not become an authorizing binding until Browser Session accepts the returned domain handle. +- Standard apply/reset must have one canonical command vocabulary while durable command intent remains distinct from ephemeral mutation authority. - Planning must remain distinct from browser command acknowledgement and observed post-condition evidence. - Ambiguous remote state must fail closed without speculative cleanup of potentially foreign browser state. @@ -25,7 +30,7 @@ The 9 September 2026 WebDriver BiDi Working Draft defines `browser.createUserCon ADR 0114's `BrowserSession`, `BrowserSessionIncarnation`, `DisposableContextHandle`, context epoch, and `PresentationMutationAuthority` are the canonical domain inputs. `WebDriverBidiLifecycleAdapter` owns a private anti-corruption mapping from that lifecycle identity to `WebDriverBidiBrowsingContext`. The mapping is addressability state, not authority state. -The reviewed lifecycle backend may obtain browser-issued user-context and browsing-context identifiers by WebDriver BiDi. It may allocate the OriginWeave domain `BrowsingContextId` required by the port contract. It cannot manufacture Browser Session authority, weaken epoch or incarnation checks, or accept caller-supplied remote identifiers as substitutes for the stored mapping. +The reviewed lifecycle backend may obtain browser-issued user-context and browsing-context identifiers by WebDriver BiDi. It may allocate the OriginWeave domain `BrowsingContextId` required by the port contract. It cannot manufacture Browser Session authority, weaken epoch or incarnation checks, or accept caller-supplied remote identifiers as substitutes for the stored mapping. Browser lifecycle events remain protocol evidence in the BiDi ACL; the Browser Session domain receives only the domain transition required to invalidate or recover ownership/transport state. Screen-area mutation remains outside the standard reusable plan because its complete presentation-surface ownership contract is not yet established. This ADR does not broaden that capability. @@ -41,77 +46,95 @@ Rejected. A caller that possesses raw protocol addressability would be able to r ### Key the adapter only by Browser Session lifecycle identity -Rejected as incomplete. Exact-key uniqueness does not prevent two distinct keys from aliasing the same live remote browsing-context string. +Rejected as incomplete. Exact-key uniqueness does not prevent two distinct keys from aliasing the same live remote browsing-context string, and separate adapter instances can otherwise maintain contradictory mappings for the same domain lifecycle tuple. ### Treat WebDriver BiDi identifiers as durable capabilities Rejected. The protocol identifiers are addressability. OriginWeave authority is generated and revalidated by Browser Session lifecycle state, incarnation, isolation, and epoch. +### Treat an immutable Rust borrow as proof that the remote target still exists + +Rejected. Remote `contextDestroyed`, browser crash, WebSocket/session termination, or implementation-owned user-context removal can occur without a mutable borrow of either local object. Local alias safety cannot establish remote liveness. + +### Return cloneable canonical commands as already-authorized executable capability + +Rejected. A caller could retain the command after the live Browser Session check, allow the lifecycle to advance or end, and later submit stale intent. Canonical command payload and execution authority therefore have different lifetimes. + ### Automatically destroy a duplicate or ambiguous remote result Rejected. When ownership is ambiguous, cleanup itself can become a cross-owner destructive action. The adapter must quarantine/fail closed and preserve recovery evidence instead of guessing ownership. ## Decision -1. `WebDriverBidiLifecycleAdapter` owns the private mapping from the exact Browser Session lifecycle key to the opaque `WebDriverBidiBrowsingContext` returned by its reviewed backend. -2. The mapping key binds browser session, `BrowserSessionIncarnation`, disposable isolation identity, and OriginWeave `BrowsingContextId`. No public constructor exposes an equivalent command capability. -3. Before accepting a newly created lifecycle result, the adapter rejects any result whose opaque remote browsing-context string is already bound to another live lifecycle key. The existing binding is not replaced. The Browser Session receives an uncertain creation result and enters its fail-closed recovery path before a second authority can be minted. -4. Exact lifecycle-key reuse is also rejected before replacement. Domain-key collision and remote-target collision are independent checks. -5. `authorize_standard_presentation` asks the live `BrowserSession` for the current authority for the requested domain context and requires exact equality with the retained token before consulting the private remote mapping. Stale epoch, destruction, transport loss, recovery state, session end, foreign isolation, foreign session, or foreign incarnation therefore fails before a remote target is returned. -6. The authorized presentation plan borrows both the Browser Session and lifecycle adapter. While it is alive, safe Rust cannot mutably advance, destroy, lose, or end that session or replace the adapter mapping. The plan and its actions have private construction paths. -7. The standard plan may express only the already-admitted viewport/device-pixel-ratio and timezone apply/reset operations. It does not grant screen-area mutation or any unrelated BiDi command authority. -8. Plan creation proves policy/lifecycle admission only. Transport command acknowledgement, page-observed state, cleanup, and destruction post-conditions require separate runtime evidence. -9. Ambiguous post-create results must not be speculatively destroyed. The current Browser Session recovery contract can preserve a known isolation identity, but complete BiDi-specific recovery must additionally retain the domain browsing-context and opaque remote browsing-context identities when they are known. That adapter-specific recovery evidence is a follow-up and grants no command authority. +1. `WebDriverBidiLifecycleAdapter` owns the private mapping from an accepted Browser Session lifecycle key to the opaque `WebDriverBidiBrowsingContext` returned by its reviewed backend. +2. The mapping key binds browser session, `BrowserSessionIncarnation`, disposable isolation identity, and OriginWeave `BrowsingContextId`. No public constructor or raw identifier tuple exposes an equivalent lifecycle or command capability. +3. Lifecycle create/destroy is entered only through non-caller-constructible Browser Session-issued request/capability values bound to the aggregate-approved lifecycle-port ownership. A separate adapter instance cannot replay those capabilities to seed or destroy an unrelated remote mapping. +4. Creation is a transaction across the domain/adapter boundary. The adapter retains the complete protocol tuple as pending evidence; Browser Session validates the returned domain handle; only an aggregate-issued acceptance promotes the pending tuple into normal authorizing bindings. Rejection keeps the known tuple solely as non-authorizing quarantine/recovery evidence. +5. Before accepting a newly created lifecycle result, the adapter rejects any result whose opaque remote browsing-context string is already bound to another live lifecycle key. Exact lifecycle-key reuse, isolation-only alias, domain-context-only alias, and remote-target alias are distinct failure classes; none replaces an existing accepted binding. +6. Presentation authorization asks the live `BrowserSession` for the current authority for the requested domain context and requires exact equality with the retained token before consulting the private remote mapping. Stale epoch, destruction, transport loss, recovery state, session end, foreign isolation, foreign session, or foreign incarnation fails before a remote target is submitted. +7. Standard viewport/device-pixel-ratio and timezone apply/reset use the existing canonical `WebDriverBidiPresentationCommand` semantics. The ACL must not maintain a second operation/payload vocabulary. Durable command intent is not durable mutation authority. +8. Any authorized execution wrapper is ephemeral and non-caller-constructible. The sole public browser-mutation execution boundary revalidates Browser Session authority, exact lifecycle-adapter ownership, and current adapter-observed remote-context/session membership immediately before backend I/O. A plan's Rust borrow lifetime alone is insufficient. +9. The adapter consumes remote lifecycle events. `browsingContext.contextDestroyed`, BiDi session loss, or equivalent observed disappearance invalidates the corresponding executable mapping and is reconciled into Browser Session recovery/transport-liveness state before another mutation can proceed. OriginWeave does not silently recreate or rebind the lost target under the same domain identity. +10. Screen-area mutation remains separately ownership-gated and is not admitted by this standard presentation path. +11. Plan or intent creation proves neither command success nor browser state. Command acknowledgement, page-observed state, cleanup, and destruction post-conditions require separate runtime evidence. +12. Ambiguous post-create results must not be speculatively destroyed. Complete known BiDi recovery identity—disposable isolation, domain browsing context, and opaque remote browsing context—must be retained in adapter-specific quarantine evidence and grants no command authority. ## Consequences -A valid Browser Session authority can no longer be redirected by supplying or reconstructing a remote BiDi context. A second lifecycle result that aliases an already-live remote target is quarantined before Browser Session can mint another authority, even when its user-context and domain-context identities are distinct. +A valid Browser Session authority cannot be redirected by supplying or reconstructing a remote BiDi context. A second lifecycle result that aliases an already-live remote target is rejected before a second authority becomes executable, including when a different adapter instance is involved. -The adapter now has a stronger uniqueness invariant than its map key alone expresses: live remote browsing-context identity is unique across accepted bindings. This check is intentionally local to the BiDi anti-corruption boundary because Browser Session must not depend on protocol-specific strings. +The adapter has a stronger invariant than its map key alone expresses: accepted remote browsing-context identity is unique within an aggregate-approved lifecycle-port ownership, and a backend result is not an authorizing binding until the aggregate accepts it. Protocol-specific quarantine remains outside Browser Session so the domain does not depend on remote strings. -The lifetime-bound plan reduces time-of-check/time-of-use drift inside safe Rust, but it does not prove external browser state. A transport or browser crash after planning remains an execution/recovery concern. +Lifetime-bound local values reduce accidental local time-of-check/time-of-use drift, but mutation safety ultimately terminates at the transport submission boundary. A remote destruction or session loss after planning makes previously prepared intent non-executable until fresh domain and protocol liveness are proven. -The current uncertain-create interface loses part of a complete BiDi-created tuple when the adapter rejects an alias after the backend has returned it. Until adapter-specific quarantine evidence retains the known isolation, domain context, and remote context together, recovery diagnostics are incomplete. This is an explicit open gap rather than a reason to relax the fail-closed behavior. +The canonical command vocabulary remains reusable as typed intent without turning its cloneability into a stale capability. Authority is attached only for the duration of a fresh, checked execution. ## Failure and degraded behavior -Missing lifecycle mappings, stale or foreign authority, duplicate lifecycle keys, and duplicate live remote targets fail closed before presentation planning. Creation ambiguity moves Browser Session into `RecoveryRequired`; no new normal authority is issued. A failed remote destruction remains unproven and blocks further authorization according to ADR 0114. +Missing lifecycle mappings, stale or foreign authority, duplicate lifecycle keys, duplicate isolation/domain identities, duplicate live remote targets, wrong adapter ownership, destroyed remote contexts, and lost BiDi transport fail closed before browser mutation. Creation ambiguity moves Browser Session into `RecoveryRequired`; no new normal authority is issued. -If the adapter has an ambiguous browser-created tuple that cannot yet be represented completely in recovery evidence, it must retain fail-closed product behavior and must not auto-destroy the remote state. Reconciliation requires a separately authorized design. +A failed remote destruction remains unproven and blocks further authorization according to ADR 0114. If the adapter has an ambiguous browser-created tuple, it preserves the exact known tuple as non-authorizing recovery evidence and must not auto-destroy or guess another target. A remote `contextDestroyed` received before local cleanup is evidence of lifecycle change, not an implicit successful cleanup result unless the separately defined destruction post-condition is satisfied. ## Security / privacy / governance impact -This ACL prevents protocol-addressability confusion from crossing the Browser Session authority boundary. Page content, LLM output, MCP input, extension input, or raw BiDi identifiers cannot construct the plan or choose its remote target. +This ACL prevents protocol-addressability confusion and remote-liveness drift from crossing the Browser Session authority boundary. Page content, LLM output, MCP input, extension input, raw BiDi identifiers, stale command intent, or a second adapter instance cannot construct executable lifecycle/presentation authority. The change does not replace Chromium sandboxing, EgressWeave network policy, Keyverse identity/secrets, Wardnet controls, contextual-orchestrator model governance, or central repository security gates. It introduces no cross-service SQL, mutable external dependency, provider/model routing, or new secret surface. ## Tests and acceptance evidence -The ACL tests cover exact lifecycle mapping, raw/unrelated mapping rejection, stale epoch, destruction, transport loss, session end, clean creation failure, exact lifecycle-key reuse, failed destruction, and lifetime-bound planning. +The final ACL test set must cover exact lifecycle mapping, raw/unrelated mapping rejection, stale epoch, destruction, transport loss, session end, clean creation failure, exact lifecycle-key reuse, failed destruction, cross-adapter aliasing, wrong-adapter target redirect, pending acceptance/rejection, quarantine isolation, retained-intent staleness, and remote lifecycle loss. + +The hostile remote-liveness RED is: create and adopt `(S,I,U,C)->R1`, obtain a presentation intent through the current ACL, then observe `browsingContext.contextDestroyed` or BiDi session loss before submission. The intent must fail before remote mutation unless the execution boundary has freshly re-established both current Browser Session authority and current adapter-observed remote membership. No silent recreation/rebinding is accepted. -A hostile test, `duplicate_remote_context_is_rejected_before_second_authority_is_minted`, creates one accepted lifecycle result and then returns a second result with distinct isolation and OriginWeave browsing-context identities but the same opaque remote BiDi browsing-context string. On exact commit `215338b77ddb3d828ebc95b796f5e8080000afde`, CI run `34487233621` failed because the second creation incorrectly returned a valid `PresentationMutationAuthority`, establishing the RED. The minimal causal fix rejects the alias before insertion and authority minting while leaving the existing binding intact. +A prior hostile test, `duplicate_remote_context_is_rejected_before_second_authority_is_minted`, created one accepted lifecycle result and then returned a second result with distinct isolation and OriginWeave browsing-context identities but the same opaque remote BiDi browsing-context string. On exact commit `215338b77ddb3d828ebc95b796f5e8080000afde`, CI run `34487233621` failed because the second creation incorrectly returned a valid `PresentationMutationAuthority`, establishing that narrower RED. -Repository contracts, canonical formatting, locked Rust tests, strict Clippy, rustdoc/API docs, and exact production function/line/region/branch coverage remain mandatory on the final exact head. Predecessor GREEN does not transfer after source or documentation changes. Independent review and applicable central checks remain required before ordinary adoption. +Current exact source is still under repair. Repository contracts, canonical formatting, locked Rust tests, strict Clippy, rustdoc/API docs, and exact production function/line/region/branch coverage remain mandatory on the final exact head. Predecessor GREEN does not transfer after source or documentation changes. Independent review and applicable central checks remain required before ordinary adoption. -Real-browser acceptance remains separate: a production backend must prove `browser.createUserContext`/`browsingContext.create` creation, exact `browser.removeUserContext` destruction or equivalent observed absence, presentation application, page-observed post-conditions, cleanup, crash/restart behavior, and the applicable pinned/current Chromium qualification. +Real-browser acceptance remains separate: a production backend must prove `browser.createUserContext`/`browsingContext.create` creation, lifecycle-event observation, exact `browser.removeUserContext` destruction or equivalent observed absence, presentation application, page-observed post-conditions, cleanup, crash/restart behavior, and the applicable pinned/current Chromium qualification. ## Migration and rollback -This is an additive stacked-branch ACL over the Browser Session lifecycle foundation. Consumers should obtain remote context only through the lifecycle adapter and should not persist or reconstruct the authorized plan. No protected-main migration or durable database schema is introduced. +This is an additive stacked-branch ACL over the Browser Session lifecycle foundation. Consumers should obtain remote context only through the lifecycle adapter and should not persist an authorized execution wrapper. Durable typed intent may be persisted only if later execution necessarily performs fresh authority/liveness validation. -Rollback removes this active feature slice while leaving ADR 0114's Browser Session authority fail closed. It must not restore caller-supplied remote contexts, raw-id coercion, or duplicate-target acceptance. +No protected-main migration or durable database schema is introduced. Rollback removes this active feature slice while leaving ADR 0114's Browser Session authority fail closed. It must not restore caller-supplied remote contexts, raw-id coercion, duplicate-target acceptance, or executable stale intents. ## Open follow-ups -- Add adapter-specific quarantine/recovery evidence that losslessly preserves every known `WebDriverBidiCreatedContext` identity after uncertain post-create outcomes without turning that evidence into cleanup authority. -- Reject or quarantine all other partial identity aliases that can leave an adapter mapping for a handle Browser Session did not adopt, including isolation-only or domain-context-only reuse. +The following are adoption blockers for this Proposed ADR, not optional post-merge debt: + +- Replace the public raw lifecycle-port side door with Browser Session-issued non-forgeable create/accept/reject/destroy capability bound to lifecycle-port ownership. +- Implement pending → accepted/quarantined creation so complete known `WebDriverBidiCreatedContext` identity is never promoted before aggregate acceptance or discarded after rejection. +- Collapse the parallel ACL action vocabulary into the canonical `WebDriverBidiPresentationCommand` semantics while keeping execution authority ephemeral. +- Add adapter remote-lifecycle observation for `browsingContext.contextDestroyed` and BiDi-session loss, with reconciliation into Browser Session recovery/transport-liveness before further mutation. +- Restore exact function/line/region/branch 100% coverage and both canonical ADR indexes on one fresh exact head, then obtain independent review. - Implement the real WebDriver BiDi disposable-user-context backend and prove browser-observed destruction rather than command ACK. -- Integrate authorized presentation plans with the pinned Chromium Agent Task lane and page-observed evidence. +- Integrate the verified execution path with the pinned Chromium Agent Task lane and page-observed evidence. - Reconcile the parent Browser Session allocator deprecation (`AtomicU64::fetch_update` renamed to `try_update`) in its canonical owner without mixing that maintenance change into this ACL authority slice. ## Supersession / reversal conditions -Supersede this ADR if the browser protocol or a later OriginWeave adapter provides a stronger generation-safe lifecycle primitive that directly proves unique remote ownership and observed destruction while preserving Browser Session as policy authority. Do not regress to raw protocol identity as authority. +Supersede this ADR if the browser protocol or a later OriginWeave adapter provides a stronger generation-safe lifecycle primitive that directly proves unique remote ownership and observed destruction while preserving Browser Session as policy authority. Do not regress to raw protocol identity, local borrow lifetime, or command acknowledgement as authority/liveness proof. ## References