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", ] 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] 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, diff --git a/crates/originweave-bidi/src/lifecycle_acl.rs b/crates/originweave-bidi/src/lifecycle_acl.rs new file mode 100644 index 000000000..475304ee0 --- /dev/null +++ b/crates/originweave-bidi/src/lifecycle_acl.rs @@ -0,0 +1,368 @@ +use std::collections::{BTreeMap, btree_map::Entry}; +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 and domain identities returned together by the reviewed lifecycle backend. +/// +/// 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, + browsing_context: BrowsingContextId, + remote_context: WebDriverBidiBrowsingContext, +} + +impl WebDriverBidiCreatedContext { + /// Bind one adapter-allocated domain context to the exact browser-issued lifecycle identities. + #[must_use] + 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. + #[must_use] + pub const fn isolation(&self) -> &DisposableIsolationId { + &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 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`. 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 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 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, + bindings: BTreeMap, +} + +impl WebDriverBidiLifecycleAdapter { + /// Create an adapter with no ambient or caller-provided lifecycle bindings. + #[must_use] + pub fn new(backend: B) -> Self { + Self { + backend, + 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 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>( + &'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 created = self + .backend + .create_disposable_context(browser_session, incarnation)?; + let remote_context_is_aliased = self + .bindings + .values() + .any(|remote_context| remote_context == &created.remote_context); + if remote_context_is_aliased { + 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); + 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( + &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(()) + } +} + +/// 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, + }, + ] + } +} 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..7e9c0bf35 --- /dev/null +++ b/crates/originweave-bidi/tests/browser_session_acl.rs @@ -0,0 +1,362 @@ +#![allow(clippy::expect_used)] + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use originweave_bidi::{ + WebDriverBidiAclError, WebDriverBidiBrowsingContext, WebDriverBidiCreatedContext, + WebDriverBidiLifecycleAdapter, WebDriverBidiLifecycleBackend, + WebDriverBidiPresentationOperation, +}; +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, BrowserSessionState, + DisposableContextCreateError, DisposableContextDestroyError, DisposableIsolationId, + PresentationMutationAuthority, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; +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>>, + fail_destroy: bool, +} + +impl FakeBackend { + fn new( + contexts: impl IntoIterator, + destroys: Arc>>, + ) -> Self { + let creates = contexts + .into_iter() + .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, + fail_destroy: false, + } + } + + fn failing_destroy( + contexts: impl IntoIterator, + destroys: Arc>>, + ) -> Self { + let mut backend = Self::new(contexts, destroys); + backend.fail_destroy = true; + backend + } +} + +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(), + }); + if self.fail_destroy { + Err(DisposableContextDestroyError::DestroyFailed) + } else { + 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 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( + [ + (101, "user-context-a", "remote-context-a"), + (102, "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_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_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] +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())); + 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"); + + 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 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 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"); + + 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([(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 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, + )) + ); +} 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); +} 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. 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..e6a724976 --- /dev/null +++ b/docs/adr/0115-browser-session-webdriver-bidi-lifecycle-acl.md @@ -0,0 +1,141 @@ +# 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. + +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 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. + +## 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. 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. + +## 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, 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 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 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 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. + +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 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, 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. + +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 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 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 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. + +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, 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 an authorized execution wrapper. Durable typed intent may be persisted only if later execution necessarily performs fresh authority/liveness validation. + +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 + +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 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, local borrow lifetime, or command acknowledgement as authority/liveness proof. + +## 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/ 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.