From 603ae1c629408e670ff07f11464adea674bb8969 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:37:34 +0900 Subject: [PATCH 001/121] test(core): require bounded semantic node observation --- .../tests/semantic_node_observation.rs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 crates/originweave-core/tests/semantic_node_observation.rs diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs new file mode 100644 index 000000000..595963ede --- /dev/null +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -0,0 +1,56 @@ +use std::collections::BTreeSet; +use std::error::Error; + +use originweave_core::{ + BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeActionKind, ObservationChannel, Origin, + ObservedNodeHandle, SemanticNodeObservation, SemanticNodeObservationInput, +}; + +fn observed_node() -> Result> { + Ok(ObservedNodeHandle::new( + BrowserSessionId::new(7)?, + BrowsingContextId::new(11)?, + Origin::parse("https://example.com")?, + DocumentEpoch::new(3)?, + 17, + )?) +} + +#[test] +fn semantic_node_preserves_authority_and_bounded_surface() -> Result<(), Box> { + let handle = observed_node()?; + let observation = SemanticNodeObservation::new(SemanticNodeObservationInput { + handle: handle.clone(), + role: "textbox".to_owned(), + accessible_name: "Email address".to_owned(), + visible_text: Some("name@example.test".to_owned()), + enabled: true, + visible: true, + selected: None, + supported_actions: BTreeSet::from([NodeActionKind::Click, NodeActionKind::TypeText]), + evidence_channels: BTreeSet::from([ + ObservationChannel::Accessibility, + ObservationChannel::Dom, + ]), + })?; + + assert_eq!(observation.handle(), &handle); + assert_eq!(observation.role(), "textbox"); + assert_eq!(observation.accessible_name(), "Email address"); + assert_eq!(observation.visible_text(), Some("name@example.test")); + assert!(observation.is_enabled()); + assert!(observation.is_visible()); + assert_eq!(observation.is_selected(), None); + assert_eq!( + observation.supported_actions(), + &BTreeSet::from([NodeActionKind::Click, NodeActionKind::TypeText]) + ); + assert_eq!( + observation.evidence_channels(), + &BTreeSet::from([ + ObservationChannel::Accessibility, + ObservationChannel::Dom, + ]) + ); + Ok(()) +} From f876711ef2cf4ab7223bb1063bb95b24b8200f99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:40:20 +0900 Subject: [PATCH 002/121] style(core): format semantic observation RED contract --- .../originweave-core/tests/semantic_node_observation.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs index 595963ede..f2826ace1 100644 --- a/crates/originweave-core/tests/semantic_node_observation.rs +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -2,8 +2,8 @@ use std::collections::BTreeSet; use std::error::Error; use originweave_core::{ - BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeActionKind, ObservationChannel, Origin, - ObservedNodeHandle, SemanticNodeObservation, SemanticNodeObservationInput, + BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeActionKind, ObservationChannel, + ObservedNodeHandle, Origin, SemanticNodeObservation, SemanticNodeObservationInput, }; fn observed_node() -> Result> { @@ -47,10 +47,7 @@ fn semantic_node_preserves_authority_and_bounded_surface() -> Result<(), Box Date: Tue, 11 Aug 2026 01:41:52 +0900 Subject: [PATCH 003/121] test(core): isolate semantic observation RED failure --- .../tests/semantic_node_observation.rs | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs index f2826ace1..a57496c00 100644 --- a/crates/originweave-core/tests/semantic_node_observation.rs +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -1,23 +1,21 @@ use std::collections::BTreeSet; -use std::error::Error; use originweave_core::{ BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeActionKind, ObservationChannel, ObservedNodeHandle, Origin, SemanticNodeObservation, SemanticNodeObservationInput, }; -fn observed_node() -> Result> { - Ok(ObservedNodeHandle::new( - BrowserSessionId::new(7)?, - BrowsingContextId::new(11)?, - Origin::parse("https://example.com")?, - DocumentEpoch::new(3)?, - 17, - )?) +fn observed_node() -> Result { + let browser_session = BrowserSessionId::new(7).map_err(|error| error.to_string())?; + let browsing_context = BrowsingContextId::new(11).map_err(|error| error.to_string())?; + let origin = Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; + let document_epoch = DocumentEpoch::new(3).map_err(|error| error.to_string())?; + ObservedNodeHandle::new(browser_session, browsing_context, origin, document_epoch, 17) + .map_err(|error| error.to_string()) } #[test] -fn semantic_node_preserves_authority_and_bounded_surface() -> Result<(), Box> { +fn semantic_node_preserves_authority_and_bounded_surface() -> Result<(), String> { let handle = observed_node()?; let observation = SemanticNodeObservation::new(SemanticNodeObservationInput { handle: handle.clone(), @@ -32,7 +30,8 @@ fn semantic_node_preserves_authority_and_bounded_surface() -> Result<(), Box Date: Tue, 11 Aug 2026 01:43:25 +0900 Subject: [PATCH 004/121] test(core): specify bounded semantic observation failures --- .../tests/semantic_node_observation.rs | 118 ++++++++++++++++-- 1 file changed, 108 insertions(+), 10 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs index a57496c00..a970c42aa 100644 --- a/crates/originweave-core/tests/semantic_node_observation.rs +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -2,7 +2,9 @@ use std::collections::BTreeSet; use originweave_core::{ BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeActionKind, ObservationChannel, - ObservedNodeHandle, Origin, SemanticNodeObservation, SemanticNodeObservationInput, + ObservedNodeHandle, Origin, SemanticNodeObservation, SemanticNodeObservationError, + SemanticNodeObservationInput, MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_ROLE_BYTES, + MAX_VISIBLE_TEXT_BYTES, }; fn observed_node() -> Result { @@ -14,14 +16,16 @@ fn observed_node() -> Result { .map_err(|error| error.to_string()) } -#[test] -fn semantic_node_preserves_authority_and_bounded_surface() -> Result<(), String> { - let handle = observed_node()?; - let observation = SemanticNodeObservation::new(SemanticNodeObservationInput { - handle: handle.clone(), - role: "textbox".to_owned(), - accessible_name: "Email address".to_owned(), - visible_text: Some("name@example.test".to_owned()), +fn semantic_input( + role: String, + accessible_name: String, + visible_text: Option, +) -> Result { + Ok(SemanticNodeObservationInput { + handle: observed_node()?, + role, + accessible_name, + visible_text, enabled: true, visible: true, selected: None, @@ -31,7 +35,17 @@ fn semantic_node_preserves_authority_and_bounded_surface() -> Result<(), String> ObservationChannel::Dom, ]), }) - .map_err(|error| error.to_string())?; +} + +#[test] +fn semantic_node_preserves_authority_and_bounded_surface() -> Result<(), String> { + let input = semantic_input( + "textbox".to_owned(), + "Email address".to_owned(), + Some("name@example.test".to_owned()), + )?; + let handle = input.handle.clone(); + let observation = SemanticNodeObservation::new(input).map_err(|error| error.to_string())?; assert_eq!(observation.handle(), &handle); assert_eq!(observation.role(), "textbox"); @@ -50,3 +64,87 @@ fn semantic_node_preserves_authority_and_bounded_surface() -> Result<(), String> ); Ok(()) } + +#[test] +fn reviewed_text_bounds_are_inclusive_and_visible_text_is_optional() -> Result<(), String> { + let boundary = SemanticNodeObservation::new(semantic_input( + "r".repeat(MAX_SEMANTIC_ROLE_BYTES), + "n".repeat(MAX_ACCESSIBLE_NAME_BYTES), + Some("v".repeat(MAX_VISIBLE_TEXT_BYTES)), + )?) + .map_err(|error| error.to_string())?; + assert_eq!(boundary.role().len(), MAX_SEMANTIC_ROLE_BYTES); + assert_eq!(boundary.accessible_name().len(), MAX_ACCESSIBLE_NAME_BYTES); + assert_eq!(boundary.visible_text().map(str::len), Some(MAX_VISIBLE_TEXT_BYTES)); + + let without_text = SemanticNodeObservation::new(semantic_input( + "button".to_owned(), + String::new(), + None, + )?) + .map_err(|error| error.to_string())?; + assert_eq!(without_text.visible_text(), None); + Ok(()) +} + +#[test] +fn semantic_node_rejects_unbounded_or_missing_role_text() -> Result<(), String> { + let empty_role = SemanticNodeObservation::new(semantic_input( + String::new(), + "name".to_owned(), + None, + )?) + .err(); + assert_eq!(empty_role, Some(SemanticNodeObservationError::EmptyRole)); + + let long_role = SemanticNodeObservation::new(semantic_input( + "r".repeat(MAX_SEMANTIC_ROLE_BYTES + 1), + "name".to_owned(), + None, + )?) + .err(); + assert_eq!(long_role, Some(SemanticNodeObservationError::RoleTooLong)); + + let long_name = SemanticNodeObservation::new(semantic_input( + "button".to_owned(), + "n".repeat(MAX_ACCESSIBLE_NAME_BYTES + 1), + None, + )?) + .err(); + assert_eq!( + long_name, + Some(SemanticNodeObservationError::AccessibleNameTooLong) + ); + + let long_visible_text = SemanticNodeObservation::new(semantic_input( + "button".to_owned(), + "name".to_owned(), + Some("v".repeat(MAX_VISIBLE_TEXT_BYTES + 1)), + )?) + .err(); + assert_eq!( + long_visible_text, + Some(SemanticNodeObservationError::VisibleTextTooLong) + ); + Ok(()) +} + +#[test] +fn semantic_node_errors_are_stable_and_credential_free() { + assert_eq!( + SemanticNodeObservationError::EmptyRole.to_string(), + "semantic node role must not be empty" + ); + assert_eq!( + SemanticNodeObservationError::RoleTooLong.to_string(), + "semantic node role exceeds 64 UTF-8 bytes" + ); + assert_eq!( + SemanticNodeObservationError::AccessibleNameTooLong.to_string(), + "semantic node accessible name exceeds 512 UTF-8 bytes" + ); + assert_eq!( + SemanticNodeObservationError::VisibleTextTooLong.to_string(), + "semantic node visible text exceeds 4096 UTF-8 bytes" + ); +} From 4aae3bce287c62fe8aa27194692f09dffd600d09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:44:49 +0900 Subject: [PATCH 005/121] feat(core): scaffold semantic observation module --- crates/originweave-core/src/semantic_observation.rs | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 crates/originweave-core/src/semantic_observation.rs diff --git a/crates/originweave-core/src/semantic_observation.rs b/crates/originweave-core/src/semantic_observation.rs new file mode 100644 index 000000000..f87d0a755 --- /dev/null +++ b/crates/originweave-core/src/semantic_observation.rs @@ -0,0 +1,4 @@ +use std::collections::BTreeSet; +use std::fmt; + +use crate::ObservedNodeHandle; From 59e00a77fb6ad747e6551a396e9e7780152dd063 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:45:28 +0900 Subject: [PATCH 006/121] feat(core): implement bounded semantic observation --- .../src/semantic_observation.rs | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/crates/originweave-core/src/semantic_observation.rs b/crates/originweave-core/src/semantic_observation.rs index f87d0a755..18028c4b2 100644 --- a/crates/originweave-core/src/semantic_observation.rs +++ b/crates/originweave-core/src/semantic_observation.rs @@ -2,3 +2,202 @@ use std::collections::BTreeSet; use std::fmt; use crate::ObservedNodeHandle; + +/// Maximum UTF-8 byte length retained for one semantic node role. +pub const MAX_SEMANTIC_ROLE_BYTES: usize = 64; +/// Maximum UTF-8 byte length retained for one semantic node accessible name. +pub const MAX_ACCESSIBLE_NAME_BYTES: usize = 512; +/// Maximum UTF-8 byte length retained for one semantic node visible-text excerpt. +pub const MAX_VISIBLE_TEXT_BYTES: usize = 4_096; + +/// A node-local typed action advertised by an observation adapter. +/// +/// This is descriptive evidence only and never grants execution authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NodeActionKind { + /// Activate the node using browser-native click semantics. + Click, + /// Insert bounded non-secret text using browser-native input semantics. + TypeText, + /// Select one option using browser-native selection semantics. + SelectOption, + /// Set a checkable control to an explicit checked state. + SetChecked, + /// Scroll the node into the viewport without activating it. + ScrollIntoView, +} + +/// A structured evidence channel that contributed to a semantic observation. +/// +/// Channel provenance never converts page-provided content into trusted instruction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ObservationChannel { + /// Experimental structured browser tool metadata, such as WebMCP when available. + WebMcp, + /// Structured data interpreted by a versioned adapter. + StructuredData, + /// Browser accessibility-tree evidence. + Accessibility, + /// Browser DOM evidence used through a bounded adapter. + Dom, + /// Browser layout evidence used through a bounded adapter. + Layout, + /// Bounded visual evidence used when structured channels are insufficient. + Visual, +} + +/// Caller-owned fields used to construct one bounded semantic node observation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemanticNodeObservationInput { + /// Exact OriginWeave authority handle for the observed node. + pub handle: ObservedNodeHandle, + /// Bounded semantic or accessibility role. + pub role: String, + /// Bounded accessible name; an empty name is valid. + pub accessible_name: String, + /// Optional bounded visible-text excerpt. + pub visible_text: Option, + /// Whether the adapter observed the node as enabled. + pub enabled: bool, + /// Whether the adapter observed the node as visible. + pub visible: bool, + /// Optional selected state when that concept applies. + pub selected: Option, + /// Finite typed actions the adapter reports as meaningful for this node. + pub supported_actions: BTreeSet, + /// Finite evidence channels that contributed to this observation. + pub evidence_channels: BTreeSet, +} + +/// A bounded semantic view of one authority-bound browser node. +/// +/// The value carries no raw HTML, protocol-local identifier, or independent authorization. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemanticNodeObservation { + handle: ObservedNodeHandle, + role: String, + accessible_name: String, + visible_text: Option, + enabled: bool, + visible: bool, + selected: Option, + supported_actions: BTreeSet, + evidence_channels: BTreeSet, +} + +impl SemanticNodeObservation { + /// Validate reviewed text budgets and create one semantic observation. + pub fn new(input: SemanticNodeObservationInput) -> Result { + if input.role.is_empty() { + return Err(SemanticNodeObservationError::EmptyRole); + } + if input.role.len() > MAX_SEMANTIC_ROLE_BYTES { + return Err(SemanticNodeObservationError::RoleTooLong); + } + if input.accessible_name.len() > MAX_ACCESSIBLE_NAME_BYTES { + return Err(SemanticNodeObservationError::AccessibleNameTooLong); + } + if input + .visible_text + .as_ref() + .is_some_and(|text| text.len() > MAX_VISIBLE_TEXT_BYTES) + { + return Err(SemanticNodeObservationError::VisibleTextTooLong); + } + Ok(Self { + handle: input.handle, + role: input.role, + accessible_name: input.accessible_name, + visible_text: input.visible_text, + enabled: input.enabled, + visible: input.visible, + selected: input.selected, + supported_actions: input.supported_actions, + evidence_channels: input.evidence_channels, + }) + } + + /// Return the exact authority-bound node handle. + #[must_use] + pub const fn handle(&self) -> &ObservedNodeHandle { + &self.handle + } + + /// Return the bounded semantic role. + #[must_use] + pub fn role(&self) -> &str { + &self.role + } + + /// Return the bounded accessible name. + #[must_use] + pub fn accessible_name(&self) -> &str { + &self.accessible_name + } + + /// Return the optional bounded visible-text excerpt. + #[must_use] + pub fn visible_text(&self) -> Option<&str> { + self.visible_text.as_deref() + } + + /// Return whether the node was observed as enabled. + #[must_use] + pub const fn is_enabled(&self) -> bool { + self.enabled + } + + /// Return whether the node was observed as visible. + #[must_use] + pub const fn is_visible(&self) -> bool { + self.visible + } + + /// Return the optional selected state. + #[must_use] + pub const fn is_selected(&self) -> Option { + self.selected + } + + /// Return the adapter-advertised node action set. + #[must_use] + pub const fn supported_actions(&self) -> &BTreeSet { + &self.supported_actions + } + + /// Return the evidence-channel provenance set. + #[must_use] + pub const fn evidence_channels(&self) -> &BTreeSet { + &self.evidence_channels + } +} + +/// A bounded validation failure for one semantic node observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SemanticNodeObservationError { + /// The semantic role was empty. + EmptyRole, + /// The role exceeded [`MAX_SEMANTIC_ROLE_BYTES`]. + RoleTooLong, + /// The accessible name exceeded [`MAX_ACCESSIBLE_NAME_BYTES`]. + AccessibleNameTooLong, + /// The visible-text excerpt exceeded [`MAX_VISIBLE_TEXT_BYTES`]. + VisibleTextTooLong, +} + +impl fmt::Display for SemanticNodeObservationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyRole => formatter.write_str("semantic node role must not be empty"), + Self::RoleTooLong => formatter.write_str("semantic node role exceeds 64 UTF-8 bytes"), + Self::AccessibleNameTooLong => { + formatter.write_str("semantic node accessible name exceeds 512 UTF-8 bytes") + } + Self::VisibleTextTooLong => { + formatter.write_str("semantic node visible text exceeds 4096 UTF-8 bytes") + } + } + } +} + +impl std::error::Error for SemanticNodeObservationError {} From 3754f47f59a81d17ad16204bf2f24c988dbc7a51 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:45:52 +0900 Subject: [PATCH 007/121] feat(core): export semantic observation contract --- crates/originweave-core/src/lib.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index bdd1b2aa9..fdbc4d605 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1,8 +1,8 @@ //! Shared security and governance contracts for OriginWeave. //! -//! This crate keeps the long-lived value contracts in `contracts` and the -//! protocol-identifier registry in a focused module so browser adapters can -//! evolve without turning raw CDP or WebDriver identifiers into authority. +//! This crate keeps the long-lived value contracts in `contracts`, the +//! protocol-identifier registry in a focused module, and bounded semantic +//! observations in a separate authority-preserving module. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -11,8 +11,14 @@ mod browser_registry; #[cfg(test)] mod browser_registry_coverage; mod contracts; +mod semantic_observation; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; pub use contracts::*; +pub use semantic_observation::{ + NodeActionKind, ObservationChannel, SemanticNodeObservation, SemanticNodeObservationError, + SemanticNodeObservationInput, MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_ROLE_BYTES, + MAX_VISIBLE_TEXT_BYTES, +}; From 10a40bea2fe754a51cf3ca8c87bfc92dc82df848 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:47:08 +0900 Subject: [PATCH 008/121] style(core): apply rustfmt to semantic exports --- crates/originweave-core/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index fdbc4d605..c45bba45e 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -18,7 +18,7 @@ pub use browser_registry::{ }; pub use contracts::*; pub use semantic_observation::{ - NodeActionKind, ObservationChannel, SemanticNodeObservation, SemanticNodeObservationError, - SemanticNodeObservationInput, MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_ROLE_BYTES, - MAX_VISIBLE_TEXT_BYTES, + MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_ROLE_BYTES, MAX_VISIBLE_TEXT_BYTES, NodeActionKind, + ObservationChannel, SemanticNodeObservation, SemanticNodeObservationError, + SemanticNodeObservationInput, }; From 84f609e31314f50f364c2f60163cd712acf60185 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:48:15 +0900 Subject: [PATCH 009/121] style(core): apply rustfmt to semantic observation tests --- .../tests/semantic_node_observation.rs | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs index a970c42aa..3871426a6 100644 --- a/crates/originweave-core/tests/semantic_node_observation.rs +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -1,10 +1,10 @@ use std::collections::BTreeSet; use originweave_core::{ - BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeActionKind, ObservationChannel, + BrowserSessionId, BrowsingContextId, DocumentEpoch, MAX_ACCESSIBLE_NAME_BYTES, + MAX_SEMANTIC_ROLE_BYTES, MAX_VISIBLE_TEXT_BYTES, NodeActionKind, ObservationChannel, ObservedNodeHandle, Origin, SemanticNodeObservation, SemanticNodeObservationError, - SemanticNodeObservationInput, MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_ROLE_BYTES, - MAX_VISIBLE_TEXT_BYTES, + SemanticNodeObservationInput, }; fn observed_node() -> Result { @@ -12,8 +12,14 @@ fn observed_node() -> Result { let browsing_context = BrowsingContextId::new(11).map_err(|error| error.to_string())?; let origin = Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; let document_epoch = DocumentEpoch::new(3).map_err(|error| error.to_string())?; - ObservedNodeHandle::new(browser_session, browsing_context, origin, document_epoch, 17) - .map_err(|error| error.to_string()) + ObservedNodeHandle::new( + browser_session, + browsing_context, + origin, + document_epoch, + 17, + ) + .map_err(|error| error.to_string()) } fn semantic_input( @@ -75,26 +81,22 @@ fn reviewed_text_bounds_are_inclusive_and_visible_text_is_optional() -> Result<( .map_err(|error| error.to_string())?; assert_eq!(boundary.role().len(), MAX_SEMANTIC_ROLE_BYTES); assert_eq!(boundary.accessible_name().len(), MAX_ACCESSIBLE_NAME_BYTES); - assert_eq!(boundary.visible_text().map(str::len), Some(MAX_VISIBLE_TEXT_BYTES)); + assert_eq!( + boundary.visible_text().map(str::len), + Some(MAX_VISIBLE_TEXT_BYTES) + ); - let without_text = SemanticNodeObservation::new(semantic_input( - "button".to_owned(), - String::new(), - None, - )?) - .map_err(|error| error.to_string())?; + let without_text = + SemanticNodeObservation::new(semantic_input("button".to_owned(), String::new(), None)?) + .map_err(|error| error.to_string())?; assert_eq!(without_text.visible_text(), None); Ok(()) } #[test] fn semantic_node_rejects_unbounded_or_missing_role_text() -> Result<(), String> { - let empty_role = SemanticNodeObservation::new(semantic_input( - String::new(), - "name".to_owned(), - None, - )?) - .err(); + let empty_role = + SemanticNodeObservation::new(semantic_input(String::new(), "name".to_owned(), None)?).err(); assert_eq!(empty_role, Some(SemanticNodeObservationError::EmptyRole)); let long_role = SemanticNodeObservation::new(semantic_input( From 939ab063d62bdc5ba1f88cbc044ed5921d98d7ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:53:40 +0900 Subject: [PATCH 010/121] docs(changelog): record semantic observation slice --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39ca..7bb12e374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding. - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. +- Authority-bound, bounded semantic node observations with typed node-local action evidence and explicit observation-channel provenance for the first Chromium vertical slice; observation metadata grants no execution authority. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. From bda159a512e0d90b9d36e64408dab6820b164145 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:08:50 +0900 Subject: [PATCH 011/121] test(core): require semantic observation provenance --- .../tests/semantic_node_observation.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs index 3871426a6..9a86d0107 100644 --- a/crates/originweave-core/tests/semantic_node_observation.rs +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -93,6 +93,19 @@ fn reviewed_text_bounds_are_inclusive_and_visible_text_is_optional() -> Result<( Ok(()) } +#[test] +fn semantic_node_requires_observation_provenance() -> Result<(), String> { + let mut input = semantic_input("button".to_owned(), "Submit".to_owned(), None)?; + input.evidence_channels.clear(); + + let error = SemanticNodeObservation::new(input).err(); + assert_eq!( + error, + Some(SemanticNodeObservationError::MissingEvidenceChannel) + ); + Ok(()) +} + #[test] fn semantic_node_rejects_unbounded_or_missing_role_text() -> Result<(), String> { let empty_role = From df54c613c5c4858fc2de8103669b2139de8c053b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:12:25 +0900 Subject: [PATCH 012/121] fix(core): require semantic observation provenance --- crates/originweave-core/src/semantic_observation.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/semantic_observation.rs b/crates/originweave-core/src/semantic_observation.rs index 18028c4b2..1d3ed52c9 100644 --- a/crates/originweave-core/src/semantic_observation.rs +++ b/crates/originweave-core/src/semantic_observation.rs @@ -86,7 +86,7 @@ pub struct SemanticNodeObservation { } impl SemanticNodeObservation { - /// Validate reviewed text budgets and create one semantic observation. + /// Validate reviewed text budgets and provenance before creating one semantic observation. pub fn new(input: SemanticNodeObservationInput) -> Result { if input.role.is_empty() { return Err(SemanticNodeObservationError::EmptyRole); @@ -104,6 +104,9 @@ impl SemanticNodeObservation { { return Err(SemanticNodeObservationError::VisibleTextTooLong); } + if input.evidence_channels.is_empty() { + return Err(SemanticNodeObservationError::MissingEvidenceChannel); + } Ok(Self { handle: input.handle, role: input.role, @@ -165,7 +168,7 @@ impl SemanticNodeObservation { &self.supported_actions } - /// Return the evidence-channel provenance set. + /// Return the non-empty evidence-channel provenance set. #[must_use] pub const fn evidence_channels(&self) -> &BTreeSet { &self.evidence_channels @@ -183,6 +186,8 @@ pub enum SemanticNodeObservationError { AccessibleNameTooLong, /// The visible-text excerpt exceeded [`MAX_VISIBLE_TEXT_BYTES`]. VisibleTextTooLong, + /// No evidence channel was supplied for the observation. + MissingEvidenceChannel, } impl fmt::Display for SemanticNodeObservationError { @@ -196,6 +201,9 @@ impl fmt::Display for SemanticNodeObservationError { Self::VisibleTextTooLong => { formatter.write_str("semantic node visible text exceeds 4096 UTF-8 bytes") } + Self::MissingEvidenceChannel => { + formatter.write_str("semantic node observation requires at least one evidence channel") + } } } } From 3f52c1c75fd42d274fbeec13c67cbf0bb6a8488b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:13:01 +0900 Subject: [PATCH 013/121] test(core): cover provenance validation error --- crates/originweave-core/tests/semantic_node_observation.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs index 9a86d0107..dd9e67325 100644 --- a/crates/originweave-core/tests/semantic_node_observation.rs +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -162,4 +162,8 @@ fn semantic_node_errors_are_stable_and_credential_free() { SemanticNodeObservationError::VisibleTextTooLong.to_string(), "semantic node visible text exceeds 4096 UTF-8 bytes" ); + assert_eq!( + SemanticNodeObservationError::MissingEvidenceChannel.to_string(), + "semantic node observation requires at least one evidence channel" + ); } From 661091dcc52f0a52e7a6a636b0f4bcea5469f82d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:18:11 +0900 Subject: [PATCH 014/121] style(core): apply rustfmt to provenance error --- crates/originweave-core/src/semantic_observation.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/semantic_observation.rs b/crates/originweave-core/src/semantic_observation.rs index 1d3ed52c9..611a214ae 100644 --- a/crates/originweave-core/src/semantic_observation.rs +++ b/crates/originweave-core/src/semantic_observation.rs @@ -201,9 +201,8 @@ impl fmt::Display for SemanticNodeObservationError { Self::VisibleTextTooLong => { formatter.write_str("semantic node visible text exceeds 4096 UTF-8 bytes") } - Self::MissingEvidenceChannel => { - formatter.write_str("semantic node observation requires at least one evidence channel") - } + Self::MissingEvidenceChannel => formatter + .write_str("semantic node observation requires at least one evidence channel"), } } } From b1bd4f8bd3b5597dac8ad3c40530beba7288e8ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 02:26:03 +0900 Subject: [PATCH 015/121] test(core): require bounded semantic relationships --- .../tests/semantic_node_observation.rs | 118 +++++++++++++++++- 1 file changed, 113 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs index dd9e67325..b4d500d46 100644 --- a/crates/originweave-core/tests/semantic_node_observation.rs +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -2,12 +2,12 @@ use std::collections::BTreeSet; use originweave_core::{ BrowserSessionId, BrowsingContextId, DocumentEpoch, MAX_ACCESSIBLE_NAME_BYTES, - MAX_SEMANTIC_ROLE_BYTES, MAX_VISIBLE_TEXT_BYTES, NodeActionKind, ObservationChannel, - ObservedNodeHandle, Origin, SemanticNodeObservation, SemanticNodeObservationError, - SemanticNodeObservationInput, + MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES, MAX_VISIBLE_TEXT_BYTES, NodeActionKind, + ObservationChannel, ObservedNodeHandle, Origin, SemanticNodeObservation, + SemanticNodeObservationError, SemanticNodeObservationInput, }; -fn observed_node() -> Result { +fn observed_node_with_id(node_id: u64) -> Result { let browser_session = BrowserSessionId::new(7).map_err(|error| error.to_string())?; let browsing_context = BrowsingContextId::new(11).map_err(|error| error.to_string())?; let origin = Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; @@ -17,11 +17,15 @@ fn observed_node() -> Result { browsing_context, origin, document_epoch, - 17, + node_id, ) .map_err(|error| error.to_string()) } +fn observed_node() -> Result { + observed_node_with_id(17) +} + fn semantic_input( role: String, accessible_name: String, @@ -29,6 +33,8 @@ fn semantic_input( ) -> Result { Ok(SemanticNodeObservationInput { handle: observed_node()?, + parent: None, + children: Vec::new(), role, accessible_name, visible_text, @@ -54,6 +60,8 @@ fn semantic_node_preserves_authority_and_bounded_surface() -> Result<(), String> let observation = SemanticNodeObservation::new(input).map_err(|error| error.to_string())?; assert_eq!(observation.handle(), &handle); + assert_eq!(observation.parent(), None); + assert!(observation.children().is_empty()); assert_eq!(observation.role(), "textbox"); assert_eq!(observation.accessible_name(), "Email address"); assert_eq!(observation.visible_text(), Some("name@example.test")); @@ -71,6 +79,90 @@ fn semantic_node_preserves_authority_and_bounded_surface() -> Result<(), String> Ok(()) } +#[test] +fn semantic_node_preserves_bounded_authority_scoped_relationships() -> Result<(), String> { + let parent = observed_node_with_id(16)?; + let first_child = observed_node_with_id(18)?; + let second_child = observed_node_with_id(19)?; + let mut input = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + input.parent = Some(parent.clone()); + input.children = vec![first_child.clone(), second_child.clone()]; + + let observation = SemanticNodeObservation::new(input).map_err(|error| error.to_string())?; + assert_eq!(observation.parent(), Some(&parent)); + assert_eq!(observation.children(), &[first_child, second_child]); + Ok(()) +} + +#[test] +fn semantic_node_bounds_child_relationship_count() -> Result<(), String> { + let mut boundary = semantic_input("list".to_owned(), "Items".to_owned(), None)?; + boundary.children = (0..MAX_SEMANTIC_CHILDREN) + .map(|offset| observed_node_with_id(100 + offset as u64)) + .collect::, _>>()?; + let observation = SemanticNodeObservation::new(boundary).map_err(|error| error.to_string())?; + assert_eq!(observation.children().len(), MAX_SEMANTIC_CHILDREN); + + let mut overflow = semantic_input("list".to_owned(), "Items".to_owned(), None)?; + overflow.children = (0..=MAX_SEMANTIC_CHILDREN) + .map(|offset| observed_node_with_id(1_000 + offset as u64)) + .collect::, _>>()?; + assert_eq!( + SemanticNodeObservation::new(overflow).err(), + Some(SemanticNodeObservationError::TooManyChildren) + ); + Ok(()) +} + +#[test] +fn semantic_node_rejects_relationships_outside_exact_authority() -> Result<(), String> { + let mut input = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + let different_origin = Origin::parse("https://other.example") + .map_err(|error| format!("{error:?}"))?; + input.parent = Some( + ObservedNodeHandle::new( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + different_origin, + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + 16, + ) + .map_err(|error| error.to_string())?, + ); + + assert_eq!( + SemanticNodeObservation::new(input).err(), + Some(SemanticNodeObservationError::RelationshipAuthorityMismatch) + ); + Ok(()) +} + +#[test] +fn semantic_node_rejects_self_and_duplicate_child_relationships() -> Result<(), String> { + let mut self_parent = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + self_parent.parent = Some(self_parent.handle.clone()); + assert_eq!( + SemanticNodeObservation::new(self_parent).err(), + Some(SemanticNodeObservationError::SelfRelationship) + ); + + let mut self_child = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + self_child.children = vec![self_child.handle.clone()]; + assert_eq!( + SemanticNodeObservation::new(self_child).err(), + Some(SemanticNodeObservationError::SelfRelationship) + ); + + let child = observed_node_with_id(18)?; + let mut duplicate = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + duplicate.children = vec![child.clone(), child]; + assert_eq!( + SemanticNodeObservation::new(duplicate).err(), + Some(SemanticNodeObservationError::DuplicateChild) + ); + Ok(()) +} + #[test] fn reviewed_text_bounds_are_inclusive_and_visible_text_is_optional() -> Result<(), String> { let boundary = SemanticNodeObservation::new(semantic_input( @@ -166,4 +258,20 @@ fn semantic_node_errors_are_stable_and_credential_free() { SemanticNodeObservationError::MissingEvidenceChannel.to_string(), "semantic node observation requires at least one evidence channel" ); + assert_eq!( + SemanticNodeObservationError::TooManyChildren.to_string(), + "semantic node observation exceeds 128 child relationships" + ); + assert_eq!( + SemanticNodeObservationError::RelationshipAuthorityMismatch.to_string(), + "semantic node relationship crosses its session, context, origin, or document authority" + ); + assert_eq!( + SemanticNodeObservationError::SelfRelationship.to_string(), + "semantic node observation cannot relate the node to itself" + ); + assert_eq!( + SemanticNodeObservationError::DuplicateChild.to_string(), + "semantic node observation contains a duplicate child relationship" + ); } From e8be794fe46b167ba8446da1a0f9c4725a4a5ab6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:08:28 +0900 Subject: [PATCH 016/121] feat(core): bound semantic node relationships to exact authority --- .../src/semantic_observation.rs | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/semantic_observation.rs b/crates/originweave-core/src/semantic_observation.rs index 611a214ae..950cff6db 100644 --- a/crates/originweave-core/src/semantic_observation.rs +++ b/crates/originweave-core/src/semantic_observation.rs @@ -9,6 +9,8 @@ pub const MAX_SEMANTIC_ROLE_BYTES: usize = 64; pub const MAX_ACCESSIBLE_NAME_BYTES: usize = 512; /// Maximum UTF-8 byte length retained for one semantic node visible-text excerpt. pub const MAX_VISIBLE_TEXT_BYTES: usize = 4_096; +/// Maximum number of child relationships retained for one semantic node observation. +pub const MAX_SEMANTIC_CHILDREN: usize = 128; /// A node-local typed action advertised by an observation adapter. /// @@ -51,6 +53,10 @@ pub enum ObservationChannel { pub struct SemanticNodeObservationInput { /// Exact OriginWeave authority handle for the observed node. pub handle: ObservedNodeHandle, + /// Optional exact-authority parent relationship. + pub parent: Option, + /// Bounded exact-authority child relationships in adapter-observed order. + pub children: Vec, /// Bounded semantic or accessibility role. pub role: String, /// Bounded accessible name; an empty name is valid. @@ -75,6 +81,8 @@ pub struct SemanticNodeObservationInput { #[derive(Debug, Clone, PartialEq, Eq)] pub struct SemanticNodeObservation { handle: ObservedNodeHandle, + parent: Option, + children: Vec, role: String, accessible_name: String, visible_text: Option, @@ -86,7 +94,7 @@ pub struct SemanticNodeObservation { } impl SemanticNodeObservation { - /// Validate reviewed text budgets and provenance before creating one semantic observation. + /// Validate reviewed text, relationship, authority, and provenance bounds. pub fn new(input: SemanticNodeObservationInput) -> Result { if input.role.is_empty() { return Err(SemanticNodeObservationError::EmptyRole); @@ -107,8 +115,22 @@ impl SemanticNodeObservation { if input.evidence_channels.is_empty() { return Err(SemanticNodeObservationError::MissingEvidenceChannel); } + if input.children.len() > MAX_SEMANTIC_CHILDREN { + return Err(SemanticNodeObservationError::TooManyChildren); + } + if let Some(parent) = input.parent.as_ref() { + validate_relationship(&input.handle, parent)?; + } + for (index, child) in input.children.iter().enumerate() { + validate_relationship(&input.handle, child)?; + if input.children[..index].contains(child) { + return Err(SemanticNodeObservationError::DuplicateChild); + } + } Ok(Self { handle: input.handle, + parent: input.parent, + children: input.children, role: input.role, accessible_name: input.accessible_name, visible_text: input.visible_text, @@ -126,6 +148,18 @@ impl SemanticNodeObservation { &self.handle } + /// Return the optional exact-authority parent relationship. + #[must_use] + pub const fn parent(&self) -> Option<&ObservedNodeHandle> { + self.parent.as_ref() + } + + /// Return the bounded exact-authority child relationships in observed order. + #[must_use] + pub fn children(&self) -> &[ObservedNodeHandle] { + &self.children + } + /// Return the bounded semantic role. #[must_use] pub fn role(&self) -> &str { @@ -175,6 +209,23 @@ impl SemanticNodeObservation { } } +fn validate_relationship( + handle: &ObservedNodeHandle, + related: &ObservedNodeHandle, +) -> Result<(), SemanticNodeObservationError> { + if handle == related { + return Err(SemanticNodeObservationError::SelfRelationship); + } + if handle.browser_session() != related.browser_session() + || handle.browsing_context() != related.browsing_context() + || handle.origin() != related.origin() + || handle.document_epoch() != related.document_epoch() + { + return Err(SemanticNodeObservationError::RelationshipAuthorityMismatch); + } + Ok(()) +} + /// A bounded validation failure for one semantic node observation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SemanticNodeObservationError { @@ -188,6 +239,14 @@ pub enum SemanticNodeObservationError { VisibleTextTooLong, /// No evidence channel was supplied for the observation. MissingEvidenceChannel, + /// The child relationship list exceeded [`MAX_SEMANTIC_CHILDREN`]. + TooManyChildren, + /// A relationship crossed the observation's session, context, origin, or document authority. + RelationshipAuthorityMismatch, + /// The observation attempted to relate the node to itself. + SelfRelationship, + /// The child relationship list contained the same exact handle more than once. + DuplicateChild, } impl fmt::Display for SemanticNodeObservationError { @@ -203,6 +262,17 @@ impl fmt::Display for SemanticNodeObservationError { } Self::MissingEvidenceChannel => formatter .write_str("semantic node observation requires at least one evidence channel"), + Self::TooManyChildren => { + formatter.write_str("semantic node observation exceeds 128 child relationships") + } + Self::RelationshipAuthorityMismatch => formatter.write_str( + "semantic node relationship crosses its session, context, origin, or document authority", + ), + Self::SelfRelationship => { + formatter.write_str("semantic node observation cannot relate the node to itself") + } + Self::DuplicateChild => formatter + .write_str("semantic node observation contains a duplicate child relationship"), } } } From 632e72421e2008ff434bbc0a2027dc28334fa73c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:08:49 +0900 Subject: [PATCH 017/121] feat(core): export semantic relationship bound --- crates/originweave-core/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index c45bba45e..9d75d9e6e 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -18,7 +18,7 @@ pub use browser_registry::{ }; pub use contracts::*; pub use semantic_observation::{ - MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_ROLE_BYTES, MAX_VISIBLE_TEXT_BYTES, NodeActionKind, - ObservationChannel, SemanticNodeObservation, SemanticNodeObservationError, - SemanticNodeObservationInput, + MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES, + MAX_VISIBLE_TEXT_BYTES, NodeActionKind, ObservationChannel, SemanticNodeObservation, + SemanticNodeObservationError, SemanticNodeObservationInput, }; From dbe75ca557fc6f501b0e54846c81dffa58812ced Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:09:41 +0900 Subject: [PATCH 018/121] style(core): apply canonical semantic relationship formatting --- crates/originweave-core/tests/semantic_node_observation.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs index b4d500d46..13f6ade52 100644 --- a/crates/originweave-core/tests/semantic_node_observation.rs +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -117,8 +117,8 @@ fn semantic_node_bounds_child_relationship_count() -> Result<(), String> { #[test] fn semantic_node_rejects_relationships_outside_exact_authority() -> Result<(), String> { let mut input = semantic_input("group".to_owned(), "Account".to_owned(), None)?; - let different_origin = Origin::parse("https://other.example") - .map_err(|error| format!("{error:?}"))?; + let different_origin = + Origin::parse("https://other.example").map_err(|error| format!("{error:?}"))?; input.parent = Some( ObservedNodeHandle::new( BrowserSessionId::new(7).map_err(|error| error.to_string())?, From 94fd284fe41746eeba9edc05d9753903b1c41ebf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:19:22 +0900 Subject: [PATCH 019/121] test(core): cover each semantic relationship authority axis --- .../tests/semantic_node_observation.rs | 63 +++++++++++++------ 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs index 13f6ade52..0e75d698f 100644 --- a/crates/originweave-core/tests/semantic_node_observation.rs +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -7,11 +7,20 @@ use originweave_core::{ SemanticNodeObservationError, SemanticNodeObservationInput, }; -fn observed_node_with_id(node_id: u64) -> Result { - let browser_session = BrowserSessionId::new(7).map_err(|error| error.to_string())?; - let browsing_context = BrowsingContextId::new(11).map_err(|error| error.to_string())?; - let origin = Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; - let document_epoch = DocumentEpoch::new(3).map_err(|error| error.to_string())?; +fn observed_node_with_authority( + browser_session_id: u64, + browsing_context_id: u64, + origin_value: &str, + document_epoch_value: u64, + node_id: u64, +) -> Result { + let browser_session = + BrowserSessionId::new(browser_session_id).map_err(|error| error.to_string())?; + let browsing_context = + BrowsingContextId::new(browsing_context_id).map_err(|error| error.to_string())?; + let origin = Origin::parse(origin_value).map_err(|error| format!("{error:?}"))?; + let document_epoch = + DocumentEpoch::new(document_epoch_value).map_err(|error| error.to_string())?; ObservedNodeHandle::new( browser_session, browsing_context, @@ -22,6 +31,10 @@ fn observed_node_with_id(node_id: u64) -> Result { .map_err(|error| error.to_string()) } +fn observed_node_with_id(node_id: u64) -> Result { + observed_node_with_authority(7, 11, "https://example.com", 3, node_id) +} + fn observed_node() -> Result { observed_node_with_id(17) } @@ -115,23 +128,33 @@ fn semantic_node_bounds_child_relationship_count() -> Result<(), String> { } #[test] -fn semantic_node_rejects_relationships_outside_exact_authority() -> Result<(), String> { - let mut input = semantic_input("group".to_owned(), "Account".to_owned(), None)?; - let different_origin = - Origin::parse("https://other.example").map_err(|error| format!("{error:?}"))?; - input.parent = Some( - ObservedNodeHandle::new( - BrowserSessionId::new(7).map_err(|error| error.to_string())?, - BrowsingContextId::new(11).map_err(|error| error.to_string())?, - different_origin, - DocumentEpoch::new(3).map_err(|error| error.to_string())?, - 16, - ) - .map_err(|error| error.to_string())?, - ); +fn semantic_node_rejects_each_relationship_authority_axis() -> Result<(), String> { + let mismatched_parents = [ + observed_node_with_authority(8, 11, "https://example.com", 3, 16)?, + observed_node_with_authority(7, 12, "https://example.com", 3, 16)?, + observed_node_with_authority(7, 11, "https://other.example", 3, 16)?, + observed_node_with_authority(7, 11, "https://example.com", 4, 16)?, + ]; + + for parent in mismatched_parents { + let mut input = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + input.parent = Some(parent); + assert_eq!( + SemanticNodeObservation::new(input).err(), + Some(SemanticNodeObservationError::RelationshipAuthorityMismatch) + ); + } + let mut child_input = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + child_input.children = vec![observed_node_with_authority( + 7, + 11, + "https://other.example", + 3, + 18, + )?]; assert_eq!( - SemanticNodeObservation::new(input).err(), + SemanticNodeObservation::new(child_input).err(), Some(SemanticNodeObservationError::RelationshipAuthorityMismatch) ); Ok(()) From e8cf13458b483ab372a993d0682f441e77b2e49e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:24:11 +0900 Subject: [PATCH 020/121] test(core): require typed semantic node query --- .../tests/semantic_node_query.rs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 crates/originweave-core/tests/semantic_node_query.rs diff --git a/crates/originweave-core/tests/semantic_node_query.rs b/crates/originweave-core/tests/semantic_node_query.rs new file mode 100644 index 000000000..9c09c42a5 --- /dev/null +++ b/crates/originweave-core/tests/semantic_node_query.rs @@ -0,0 +1,108 @@ +use std::collections::BTreeSet; + +use originweave_core::{ + BrowserSessionId, BrowsingContextId, DocumentEpoch, MAX_ACCESSIBLE_NAME_BYTES, + MAX_SEMANTIC_ROLE_BYTES, NodeActionKind, ObservationChannel, ObservedNodeHandle, Origin, + SemanticNodeObservation, SemanticNodeObservationInput, SemanticNodeQuery, + SemanticNodeQueryError, +}; + +fn observation() -> Result { + let handle = ObservedNodeHandle::new( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?, + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + 17, + ) + .map_err(|error| error.to_string())?; + + SemanticNodeObservation::new(SemanticNodeObservationInput { + handle, + parent: None, + children: Vec::new(), + role: "textbox".to_owned(), + accessible_name: "Email address".to_owned(), + visible_text: Some("name@example.test".to_owned()), + enabled: true, + visible: true, + selected: None, + supported_actions: BTreeSet::from([NodeActionKind::Click, NodeActionKind::TypeText]), + evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]), + }) + .map_err(|error| error.to_string()) +} + +#[test] +fn semantic_node_query_matches_exact_reviewed_fields_and_action() -> Result<(), String> { + let observed = observation()?; + let query = SemanticNodeQuery::new( + Some("textbox".to_owned()), + Some("Email address".to_owned()), + Some(NodeActionKind::TypeText), + ) + .map_err(|error| error.to_string())?; + + assert!(query.matches(&observed)); + assert_eq!(query.role(), Some("textbox")); + assert_eq!(query.accessible_name(), Some("Email address")); + assert_eq!(query.required_action(), Some(NodeActionKind::TypeText)); + Ok(()) +} + +#[test] +fn semantic_node_query_fails_closed_on_each_exact_selector_mismatch() -> Result<(), String> { + let observed = observation()?; + let cases = [ + SemanticNodeQuery::new(Some("button".to_owned()), None, None), + SemanticNodeQuery::new(None, Some("Different label".to_owned()), None), + SemanticNodeQuery::new(None, None, Some(NodeActionKind::SelectOption)), + ]; + + for query in cases { + let query = query.map_err(|error| error.to_string())?; + assert!(!query.matches(&observed)); + } + Ok(()) +} + +#[test] +fn semantic_node_query_requires_at_least_one_selector() { + assert_eq!( + SemanticNodeQuery::new(None, None, None).err(), + Some(SemanticNodeQueryError::EmptySelector) + ); +} + +#[test] +fn semantic_node_query_bounds_attacker_controlled_text() { + assert_eq!( + SemanticNodeQuery::new(Some("r".repeat(MAX_SEMANTIC_ROLE_BYTES + 1)), None, None).err(), + Some(SemanticNodeQueryError::RoleTooLong) + ); + assert_eq!( + SemanticNodeQuery::new( + None, + Some("n".repeat(MAX_ACCESSIBLE_NAME_BYTES + 1)), + None, + ) + .err(), + Some(SemanticNodeQueryError::AccessibleNameTooLong) + ); +} + +#[test] +fn semantic_node_query_errors_are_stable_and_credential_free() { + assert_eq!( + SemanticNodeQueryError::EmptySelector.to_string(), + "semantic node query requires at least one selector" + ); + assert_eq!( + SemanticNodeQueryError::RoleTooLong.to_string(), + "semantic node query role exceeds 64 UTF-8 bytes" + ); + assert_eq!( + SemanticNodeQueryError::AccessibleNameTooLong.to_string(), + "semantic node query accessible name exceeds 512 UTF-8 bytes" + ); +} From d0cd133f5be62fff99612d5b08aa4cf08ce2f29f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:26:42 +0900 Subject: [PATCH 021/121] style(core): apply canonical semantic query formatting --- crates/originweave-core/tests/semantic_node_query.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_query.rs b/crates/originweave-core/tests/semantic_node_query.rs index 9c09c42a5..5cf063daa 100644 --- a/crates/originweave-core/tests/semantic_node_query.rs +++ b/crates/originweave-core/tests/semantic_node_query.rs @@ -81,12 +81,7 @@ fn semantic_node_query_bounds_attacker_controlled_text() { Some(SemanticNodeQueryError::RoleTooLong) ); assert_eq!( - SemanticNodeQuery::new( - None, - Some("n".repeat(MAX_ACCESSIBLE_NAME_BYTES + 1)), - None, - ) - .err(), + SemanticNodeQuery::new(None, Some("n".repeat(MAX_ACCESSIBLE_NAME_BYTES + 1)), None,).err(), Some(SemanticNodeQueryError::AccessibleNameTooLong) ); } From 096bb97c05f5e294d711284e669e74a4a8f00656 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:32:34 +0900 Subject: [PATCH 022/121] feat(core): implement typed semantic node query --- .../src/semantic_observation.rs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/crates/originweave-core/src/semantic_observation.rs b/crates/originweave-core/src/semantic_observation.rs index 950cff6db..0ab4301ec 100644 --- a/crates/originweave-core/src/semantic_observation.rs +++ b/crates/originweave-core/src/semantic_observation.rs @@ -209,6 +209,114 @@ impl SemanticNodeObservation { } } +/// A bounded typed selector over already validated semantic node observations. +/// +/// Queries match only reviewed semantic fields and descriptive action evidence. They never expose +/// raw DOM/protocol selectors and never grant browser action authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemanticNodeQuery { + role: Option, + accessible_name: Option, + required_action: Option, +} + +impl SemanticNodeQuery { + /// Validate and construct a query with at least one exact typed selector. + pub fn new( + role: Option, + accessible_name: Option, + required_action: Option, + ) -> Result { + if role.is_none() { + if accessible_name.is_none() { + if required_action.is_none() { + return Err(SemanticNodeQueryError::EmptySelector); + } + } + } + if let Some(role) = role.as_ref() { + if role.len() > MAX_SEMANTIC_ROLE_BYTES { + return Err(SemanticNodeQueryError::RoleTooLong); + } + } + if let Some(accessible_name) = accessible_name.as_ref() { + if accessible_name.len() > MAX_ACCESSIBLE_NAME_BYTES { + return Err(SemanticNodeQueryError::AccessibleNameTooLong); + } + } + Ok(Self { + role, + accessible_name, + required_action, + }) + } + + /// Return the optional exact semantic-role selector. + #[must_use] + pub fn role(&self) -> Option<&str> { + self.role.as_deref() + } + + /// Return the optional exact accessible-name selector. + #[must_use] + pub fn accessible_name(&self) -> Option<&str> { + self.accessible_name.as_deref() + } + + /// Return the optional required descriptive node action. + #[must_use] + pub const fn required_action(&self) -> Option { + self.required_action + } + + /// Match the query against one already bounded semantic observation. + #[must_use] + pub fn matches(&self, observation: &SemanticNodeObservation) -> bool { + if let Some(role) = self.role.as_deref() { + if observation.role() != role { + return false; + } + } + if let Some(accessible_name) = self.accessible_name.as_deref() { + if observation.accessible_name() != accessible_name { + return false; + } + } + if let Some(required_action) = self.required_action { + if !observation.supported_actions().contains(&required_action) { + return false; + } + } + true + } +} + +/// A bounded validation failure for one typed semantic node query. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SemanticNodeQueryError { + /// No typed selector was supplied. + EmptySelector, + /// The role selector exceeded [`MAX_SEMANTIC_ROLE_BYTES`]. + RoleTooLong, + /// The accessible-name selector exceeded [`MAX_ACCESSIBLE_NAME_BYTES`]. + AccessibleNameTooLong, +} + +impl fmt::Display for SemanticNodeQueryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptySelector => { + formatter.write_str("semantic node query requires at least one selector") + } + Self::RoleTooLong => formatter.write_str("semantic node query role exceeds 64 UTF-8 bytes"), + Self::AccessibleNameTooLong => formatter + .write_str("semantic node query accessible name exceeds 512 UTF-8 bytes"), + } + } +} + +impl std::error::Error for SemanticNodeQueryError {} + fn validate_relationship( handle: &ObservedNodeHandle, related: &ObservedNodeHandle, From 5e6b81bc1ab5e33ddcd3306e2f6ac3b0801cdc59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:32:56 +0900 Subject: [PATCH 023/121] feat(core): export typed semantic node query --- crates/originweave-core/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 9d75d9e6e..61803eb6b 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -20,5 +20,6 @@ pub use contracts::*; pub use semantic_observation::{ MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES, MAX_VISIBLE_TEXT_BYTES, NodeActionKind, ObservationChannel, SemanticNodeObservation, - SemanticNodeObservationError, SemanticNodeObservationInput, + SemanticNodeObservationError, SemanticNodeObservationInput, SemanticNodeQuery, + SemanticNodeQueryError, }; From 135d3256a535da88a0325dbd7ab06ab37bb4277a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:33:38 +0900 Subject: [PATCH 024/121] docs(changelog): record typed semantic node query boundary --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb12e374..315343a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. - Authority-bound, bounded semantic node observations with typed node-local action evidence and explicit observation-channel provenance for the first Chromium vertical slice; observation metadata grants no execution authority. +- Bounded typed semantic node queries over reviewed role, accessible-name, and node-action evidence, without exposing raw DOM/protocol selector languages or granting execution authority. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. From bcd69bf0314f5be1a39129f77c18fda5af88f7e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:35:36 +0900 Subject: [PATCH 025/121] style(core): apply canonical semantic query formatting --- crates/originweave-core/src/semantic_observation.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/semantic_observation.rs b/crates/originweave-core/src/semantic_observation.rs index 0ab4301ec..889d55008 100644 --- a/crates/originweave-core/src/semantic_observation.rs +++ b/crates/originweave-core/src/semantic_observation.rs @@ -308,9 +308,12 @@ impl fmt::Display for SemanticNodeQueryError { Self::EmptySelector => { formatter.write_str("semantic node query requires at least one selector") } - Self::RoleTooLong => formatter.write_str("semantic node query role exceeds 64 UTF-8 bytes"), - Self::AccessibleNameTooLong => formatter - .write_str("semantic node query accessible name exceeds 512 UTF-8 bytes"), + Self::RoleTooLong => { + formatter.write_str("semantic node query role exceeds 64 UTF-8 bytes") + } + Self::AccessibleNameTooLong => { + formatter.write_str("semantic node query accessible name exceeds 512 UTF-8 bytes") + } } } } From b4fa49953cbbb21c879a3340e264a6e132e41634 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:38:07 +0900 Subject: [PATCH 026/121] fix(core): satisfy strict semantic query linting --- .../src/semantic_observation.rs | 54 ++++++++++--------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/crates/originweave-core/src/semantic_observation.rs b/crates/originweave-core/src/semantic_observation.rs index 889d55008..d8c05010f 100644 --- a/crates/originweave-core/src/semantic_observation.rs +++ b/crates/originweave-core/src/semantic_observation.rs @@ -227,22 +227,20 @@ impl SemanticNodeQuery { accessible_name: Option, required_action: Option, ) -> Result { - if role.is_none() { - if accessible_name.is_none() { - if required_action.is_none() { - return Err(SemanticNodeQueryError::EmptySelector); - } - } + if role.is_none() && accessible_name.is_none() && required_action.is_none() { + return Err(SemanticNodeQueryError::EmptySelector); } - if let Some(role) = role.as_ref() { - if role.len() > MAX_SEMANTIC_ROLE_BYTES { - return Err(SemanticNodeQueryError::RoleTooLong); - } + if role + .as_ref() + .is_some_and(|role| role.len() > MAX_SEMANTIC_ROLE_BYTES) + { + return Err(SemanticNodeQueryError::RoleTooLong); } - if let Some(accessible_name) = accessible_name.as_ref() { - if accessible_name.len() > MAX_ACCESSIBLE_NAME_BYTES { - return Err(SemanticNodeQueryError::AccessibleNameTooLong); - } + if accessible_name + .as_ref() + .is_some_and(|accessible_name| accessible_name.len() > MAX_ACCESSIBLE_NAME_BYTES) + { + return Err(SemanticNodeQueryError::AccessibleNameTooLong); } Ok(Self { role, @@ -272,20 +270,24 @@ impl SemanticNodeQuery { /// Match the query against one already bounded semantic observation. #[must_use] pub fn matches(&self, observation: &SemanticNodeObservation) -> bool { - if let Some(role) = self.role.as_deref() { - if observation.role() != role { - return false; - } + if self + .role + .as_deref() + .is_some_and(|role| observation.role() != role) + { + return false; } - if let Some(accessible_name) = self.accessible_name.as_deref() { - if observation.accessible_name() != accessible_name { - return false; - } + if self + .accessible_name + .as_deref() + .is_some_and(|accessible_name| observation.accessible_name() != accessible_name) + { + return false; } - if let Some(required_action) = self.required_action { - if !observation.supported_actions().contains(&required_action) { - return false; - } + if self.required_action.is_some_and(|required_action| { + !observation.supported_actions().contains(&required_action) + }) { + return false; } true } From 308fa77a5ea84ad428101792da9d8a7a0b9fcdac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:46:41 +0900 Subject: [PATCH 027/121] test(core): require authority-bound semantic action target --- .../tests/semantic_node_action_target.rs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 crates/originweave-core/tests/semantic_node_action_target.rs diff --git a/crates/originweave-core/tests/semantic_node_action_target.rs b/crates/originweave-core/tests/semantic_node_action_target.rs new file mode 100644 index 000000000..f3d094600 --- /dev/null +++ b/crates/originweave-core/tests/semantic_node_action_target.rs @@ -0,0 +1,106 @@ +use std::collections::BTreeSet; + +use originweave_core::{ + BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeActionKind, NodeHandleError, + ObservationChannel, ObservedNodeHandle, Origin, SemanticNodeActionTarget, + SemanticNodeActionTargetError, SemanticNodeObservation, SemanticNodeObservationInput, +}; + +fn observation() -> Result { + let handle = ObservedNodeHandle::new( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?, + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + 17, + ) + .map_err(|error| error.to_string())?; + + SemanticNodeObservation::new(SemanticNodeObservationInput { + handle, + parent: None, + children: Vec::new(), + role: "button".to_owned(), + accessible_name: "Save draft".to_owned(), + visible_text: Some("Save draft".to_owned()), + enabled: true, + visible: true, + selected: None, + supported_actions: BTreeSet::from([NodeActionKind::Click]), + evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]), + }) + .map_err(|error| error.to_string()) +} + +#[test] +fn advertised_node_action_becomes_an_authority_bound_target() -> Result<(), String> { + let observed = observation()?; + let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + + assert_eq!(target.handle(), observed.handle()); + assert_eq!(target.action(), NodeActionKind::Click); + Ok(()) +} + +#[test] +fn unsupported_node_action_fails_closed_without_minting_authority() -> Result<(), String> { + let observed = observation()?; + assert_eq!( + SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::TypeText).err(), + Some(SemanticNodeActionTargetError::UnsupportedAction) + ); + Ok(()) +} + +#[test] +fn node_action_target_revalidates_exact_browser_authority() -> Result<(), String> { + let observed = observation()?; + let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + let current_origin = Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; + + target + .validate_current( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + ¤t_origin, + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +#[test] +fn node_action_target_rejects_stale_document_authority() -> Result<(), String> { + let observed = observation()?; + let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + let current_origin = Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; + let observed_epoch = DocumentEpoch::new(3).map_err(|error| error.to_string())?; + let current_epoch = DocumentEpoch::new(4).map_err(|error| error.to_string())?; + + assert_eq!( + target + .validate_current( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + ¤t_origin, + current_epoch, + ) + .err(), + Some(NodeHandleError::StaleDocumentEpoch { + observed: observed_epoch, + current: current_epoch, + }) + ); + Ok(()) +} + +#[test] +fn node_action_target_error_is_stable_and_credential_free() { + assert_eq!( + SemanticNodeActionTargetError::UnsupportedAction.to_string(), + "semantic node action is not advertised by the observation" + ); +} From f2bb6db04488e4bfa873e290eb8b6283e1238fdf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:48:09 +0900 Subject: [PATCH 028/121] style(core): apply canonical action-target test formatting --- .../originweave-core/tests/semantic_node_action_target.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_action_target.rs b/crates/originweave-core/tests/semantic_node_action_target.rs index f3d094600..fbad934c4 100644 --- a/crates/originweave-core/tests/semantic_node_action_target.rs +++ b/crates/originweave-core/tests/semantic_node_action_target.rs @@ -58,7 +58,8 @@ fn node_action_target_revalidates_exact_browser_authority() -> Result<(), String let observed = observation()?; let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) .map_err(|error| error.to_string())?; - let current_origin = Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; + let current_origin = + Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; target .validate_current( @@ -76,7 +77,8 @@ fn node_action_target_rejects_stale_document_authority() -> Result<(), String> { let observed = observation()?; let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) .map_err(|error| error.to_string())?; - let current_origin = Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; + let current_origin = + Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; let observed_epoch = DocumentEpoch::new(3).map_err(|error| error.to_string())?; let current_epoch = DocumentEpoch::new(4).map_err(|error| error.to_string())?; From ef5fd33c918f5ffb53bce183bf7218f0ea52a0de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:51:00 +0900 Subject: [PATCH 029/121] feat(core): add authority-bound semantic action target --- .../src/semantic_action_target.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 crates/originweave-core/src/semantic_action_target.rs diff --git a/crates/originweave-core/src/semantic_action_target.rs b/crates/originweave-core/src/semantic_action_target.rs new file mode 100644 index 000000000..85fe59d29 --- /dev/null +++ b/crates/originweave-core/src/semantic_action_target.rs @@ -0,0 +1,79 @@ +use std::fmt; + +use crate::{ + BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeActionKind, NodeHandleError, + ObservedNodeHandle, Origin, SemanticNodeObservation, +}; + +/// One node-local action bound to the exact browser authority that produced its observation. +/// +/// This value narrows descriptive observation evidence into a stale-checkable action target. It +/// does not grant policy authority, classify business risk, or execute browser input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemanticNodeActionTarget { + handle: ObservedNodeHandle, + action: NodeActionKind, +} + +impl SemanticNodeActionTarget { + /// Construct a target only when the observation advertised the requested node-local action. + pub fn from_observation( + observation: &SemanticNodeObservation, + action: NodeActionKind, + ) -> Result { + if !observation.supported_actions().contains(&action) { + return Err(SemanticNodeActionTargetError::UnsupportedAction); + } + Ok(Self { + handle: observation.handle().clone(), + action, + }) + } + + /// Return the exact OriginWeave-owned node handle retained by this target. + #[must_use] + pub const fn handle(&self) -> &ObservedNodeHandle { + &self.handle + } + + /// Return the descriptive node-local action selected from the observation. + #[must_use] + pub const fn action(&self) -> NodeActionKind { + self.action + } + + /// Revalidate session, context, origin, and document authority immediately before later use. + pub fn validate_current( + &self, + current_session: BrowserSessionId, + current_context: BrowsingContextId, + current_origin: &Origin, + current_epoch: DocumentEpoch, + ) -> Result<(), NodeHandleError> { + self.handle.validate_current( + current_session, + current_context, + current_origin, + current_epoch, + ) + } +} + +/// A bounded validation failure when deriving one semantic node action target. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SemanticNodeActionTargetError { + /// The requested action was not advertised by the semantic observation. + UnsupportedAction, +} + +impl fmt::Display for SemanticNodeActionTargetError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedAction => { + formatter.write_str("semantic node action is not advertised by the observation") + } + } + } +} + +impl std::error::Error for SemanticNodeActionTargetError {} From 01b1500137bdaa8d6d011f9e167ec90eb0f30191 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:51:12 +0900 Subject: [PATCH 030/121] feat(core): export semantic action target contract --- crates/originweave-core/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 61803eb6b..e604c8997 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -2,7 +2,7 @@ //! //! This crate keeps the long-lived value contracts in `contracts`, the //! protocol-identifier registry in a focused module, and bounded semantic -//! observations in a separate authority-preserving module. +//! observations in separate authority-preserving modules. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -11,12 +11,14 @@ mod browser_registry; #[cfg(test)] mod browser_registry_coverage; mod contracts; +mod semantic_action_target; mod semantic_observation; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; pub use contracts::*; +pub use semantic_action_target::{SemanticNodeActionTarget, SemanticNodeActionTargetError}; pub use semantic_observation::{ MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES, MAX_VISIBLE_TEXT_BYTES, NodeActionKind, ObservationChannel, SemanticNodeObservation, From c81c273e302a47ca9c333e82cc1503d4a43b4e6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:52:23 +0900 Subject: [PATCH 031/121] test(core): prove semantic action target authority invalidation --- .../tests/semantic_node_action_target.rs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/originweave-core/tests/semantic_node_action_target.rs b/crates/originweave-core/tests/semantic_node_action_target.rs index fbad934c4..33d001551 100644 --- a/crates/originweave-core/tests/semantic_node_action_target.rs +++ b/crates/originweave-core/tests/semantic_node_action_target.rs @@ -72,6 +72,82 @@ fn node_action_target_revalidates_exact_browser_authority() -> Result<(), String Ok(()) } +#[test] +fn node_action_target_rejects_cross_session_authority() -> Result<(), String> { + let observed = observation()?; + let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + let current_origin = + Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; + let observed_session = BrowserSessionId::new(7).map_err(|error| error.to_string())?; + let current_session = BrowserSessionId::new(8).map_err(|error| error.to_string())?; + + assert_eq!( + target + .validate_current( + current_session, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + ¤t_origin, + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + ) + .err(), + Some(NodeHandleError::BrowserSessionMismatch { + observed: observed_session, + current: current_session, + }) + ); + Ok(()) +} + +#[test] +fn node_action_target_rejects_cross_context_authority() -> Result<(), String> { + let observed = observation()?; + let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + let current_origin = + Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; + let observed_context = BrowsingContextId::new(11).map_err(|error| error.to_string())?; + let current_context = BrowsingContextId::new(12).map_err(|error| error.to_string())?; + + assert_eq!( + target + .validate_current( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + current_context, + ¤t_origin, + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + ) + .err(), + Some(NodeHandleError::BrowsingContextMismatch { + observed: observed_context, + current: current_context, + }) + ); + Ok(()) +} + +#[test] +fn node_action_target_rejects_cross_origin_authority() -> Result<(), String> { + let observed = observation()?; + let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + let current_origin = + Origin::parse("https://other.example").map_err(|error| format!("{error:?}"))?; + + assert_eq!( + target + .validate_current( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + ¤t_origin, + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + ) + .err(), + Some(NodeHandleError::OriginMismatch) + ); + Ok(()) +} + #[test] fn node_action_target_rejects_stale_document_authority() -> Result<(), String> { let observed = observation()?; From efe440c7a609cac187faacfa03a4df904a99386f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:54:01 +0900 Subject: [PATCH 032/121] docs(changelog): record semantic action target boundary --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 315343a22..8f1d9106e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. - Authority-bound, bounded semantic node observations with typed node-local action evidence and explicit observation-channel provenance for the first Chromium vertical slice; observation metadata grants no execution authority. - Bounded typed semantic node queries over reviewed role, accessible-name, and node-action evidence, without exposing raw DOM/protocol selector languages or granting execution authority. +- Authority-bound semantic node action targets that accept only observation-advertised node-local actions and revalidate exact session, context, origin, and document authority before later use without granting policy or browser-execution authority. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. From cd784b660a0268cc95725d847bf895edb4b3d5fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 05:38:17 +0900 Subject: [PATCH 033/121] test(core): require node-to-business-action binding --- .../tests/semantic_node_action_binding.rs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 crates/originweave-core/tests/semantic_node_action_binding.rs diff --git a/crates/originweave-core/tests/semantic_node_action_binding.rs b/crates/originweave-core/tests/semantic_node_action_binding.rs new file mode 100644 index 000000000..4b326f34d --- /dev/null +++ b/crates/originweave-core/tests/semantic_node_action_binding.rs @@ -0,0 +1,134 @@ +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, BrowserSessionId, BrowsingContextId, + DocumentEpoch, InstructionSource, NodeActionKind, NodeHandleError, ObservationChannel, + ObservedNodeHandle, Origin, SecretDelivery, SemanticNodeActionBinding, + SemanticNodeActionBindingError, SemanticNodeActionTarget, SemanticNodeObservation, + SemanticNodeObservationInput, +}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn origin(value: &str) -> Result { + Origin::parse(value).map_err(|error| format!("{error:?}")) +} + +fn observation() -> Result { + let handle = ObservedNodeHandle::new( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + origin("https://app.example")?, + DocumentEpoch::new(3).map_err(|error| error.to_string())?, + 17, + ) + .map_err(|error| error.to_string())?; + + SemanticNodeObservation::new(SemanticNodeObservationInput { + handle, + parent: None, + children: Vec::new(), + role: "button".to_owned(), + accessible_name: "Continue".to_owned(), + visible_text: Some("Continue".to_owned()), + enabled: true, + visible: true, + selected: None, + supported_actions: BTreeSet::from([NodeActionKind::Click]), + evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]), + }) + .map_err(|error| error.to_string()) +} + +fn action_request(source: Origin, target: Origin) -> Result { + let intent = ActionIntentDigest::parse(VALID_INTENT).map_err(|error| format!("{error:?}"))?; + Ok(ActionRequest::new( + ActionKind::Navigate, + source, + target, + InstructionSource::User, + SecretDelivery::None, + intent, + )) +} + +#[test] +fn node_action_binding_preserves_node_target_and_business_request() -> Result<(), String> { + let observed = observation()?; + let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + let request = action_request(origin("https://app.example")?, origin("https://next.example")?)?; + + let binding = SemanticNodeActionBinding::new(target.clone(), request.clone()) + .map_err(|error| error.to_string())?; + + assert_eq!(binding.target(), &target); + assert_eq!(binding.request(), &request); + Ok(()) +} + +#[test] +fn node_action_binding_rejects_request_from_another_document_origin() -> Result<(), String> { + let observed = observation()?; + let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + let request = action_request(origin("https://other.example")?, origin("https://next.example")?)?; + + assert_eq!( + SemanticNodeActionBinding::new(target, request).err(), + Some(SemanticNodeActionBindingError::SourceOriginMismatch) + ); + Ok(()) +} + +#[test] +fn node_action_binding_does_not_conflate_source_node_with_navigation_target() -> Result<(), String> { + let observed = observation()?; + let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + let destination = origin("https://destination.example")?; + let request = action_request(origin("https://app.example")?, destination.clone())?; + + let binding = SemanticNodeActionBinding::new(target, request) + .map_err(|error| error.to_string())?; + + assert_eq!(binding.request().target_origin(), &destination); + Ok(()) +} + +#[test] +fn node_action_binding_revalidates_exact_browser_authority_before_dispatch() -> Result<(), String> { + let observed = observation()?; + let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + let request = action_request(origin("https://app.example")?, origin("https://next.example")?)?; + let binding = SemanticNodeActionBinding::new(target, request) + .map_err(|error| error.to_string())?; + let observed_epoch = DocumentEpoch::new(3).map_err(|error| error.to_string())?; + let current_epoch = DocumentEpoch::new(4).map_err(|error| error.to_string())?; + + assert_eq!( + binding + .validate_current( + BrowserSessionId::new(7).map_err(|error| error.to_string())?, + BrowsingContextId::new(11).map_err(|error| error.to_string())?, + &origin("https://app.example")?, + current_epoch, + ) + .err(), + Some(NodeHandleError::StaleDocumentEpoch { + observed: observed_epoch, + current: current_epoch, + }) + ); + Ok(()) +} + +#[test] +fn node_action_binding_error_is_stable_and_credential_free() { + assert_eq!( + SemanticNodeActionBindingError::SourceOriginMismatch.to_string(), + "semantic node origin does not match action request source origin" + ); +} From 33fd6ae933e1c5de36058e3adb28ea3d88905f06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 05:44:29 +0900 Subject: [PATCH 034/121] style(core): apply canonical action-binding rustfmt --- .../tests/semantic_node_action_binding.rs | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_action_binding.rs b/crates/originweave-core/tests/semantic_node_action_binding.rs index 4b326f34d..f6b3ef184 100644 --- a/crates/originweave-core/tests/semantic_node_action_binding.rs +++ b/crates/originweave-core/tests/semantic_node_action_binding.rs @@ -58,7 +58,10 @@ fn node_action_binding_preserves_node_target_and_business_request() -> Result<() let observed = observation()?; let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) .map_err(|error| error.to_string())?; - let request = action_request(origin("https://app.example")?, origin("https://next.example")?)?; + let request = action_request( + origin("https://app.example")?, + origin("https://next.example")?, + )?; let binding = SemanticNodeActionBinding::new(target.clone(), request.clone()) .map_err(|error| error.to_string())?; @@ -73,7 +76,10 @@ fn node_action_binding_rejects_request_from_another_document_origin() -> Result< let observed = observation()?; let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) .map_err(|error| error.to_string())?; - let request = action_request(origin("https://other.example")?, origin("https://next.example")?)?; + let request = action_request( + origin("https://other.example")?, + origin("https://next.example")?, + )?; assert_eq!( SemanticNodeActionBinding::new(target, request).err(), @@ -83,15 +89,16 @@ fn node_action_binding_rejects_request_from_another_document_origin() -> Result< } #[test] -fn node_action_binding_does_not_conflate_source_node_with_navigation_target() -> Result<(), String> { +fn node_action_binding_does_not_conflate_source_node_with_navigation_target() -> Result<(), String> +{ let observed = observation()?; let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) .map_err(|error| error.to_string())?; let destination = origin("https://destination.example")?; let request = action_request(origin("https://app.example")?, destination.clone())?; - let binding = SemanticNodeActionBinding::new(target, request) - .map_err(|error| error.to_string())?; + let binding = + SemanticNodeActionBinding::new(target, request).map_err(|error| error.to_string())?; assert_eq!(binding.request().target_origin(), &destination); Ok(()) @@ -102,9 +109,12 @@ fn node_action_binding_revalidates_exact_browser_authority_before_dispatch() -> let observed = observation()?; let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click) .map_err(|error| error.to_string())?; - let request = action_request(origin("https://app.example")?, origin("https://next.example")?)?; - let binding = SemanticNodeActionBinding::new(target, request) - .map_err(|error| error.to_string())?; + let request = action_request( + origin("https://app.example")?, + origin("https://next.example")?, + )?; + let binding = + SemanticNodeActionBinding::new(target, request).map_err(|error| error.to_string())?; let observed_epoch = DocumentEpoch::new(3).map_err(|error| error.to_string())?; let current_epoch = DocumentEpoch::new(4).map_err(|error| error.to_string())?; From c150e2daa0c890c8e2797ebb4c88a6220be13019 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 05:49:47 +0900 Subject: [PATCH 035/121] feat(core): bind semantic node targets to business actions --- crates/originweave-core/src/lib.rs | 2 + .../src/semantic_action_binding.rs | 76 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 crates/originweave-core/src/semantic_action_binding.rs diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index e604c8997..b7040f2ca 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -11,6 +11,7 @@ mod browser_registry; #[cfg(test)] mod browser_registry_coverage; mod contracts; +mod semantic_action_binding; mod semantic_action_target; mod semantic_observation; @@ -18,6 +19,7 @@ pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; pub use contracts::*; +pub use semantic_action_binding::{SemanticNodeActionBinding, SemanticNodeActionBindingError}; pub use semantic_action_target::{SemanticNodeActionTarget, SemanticNodeActionTargetError}; pub use semantic_observation::{ MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES, diff --git a/crates/originweave-core/src/semantic_action_binding.rs b/crates/originweave-core/src/semantic_action_binding.rs new file mode 100644 index 000000000..2786a57c6 --- /dev/null +++ b/crates/originweave-core/src/semantic_action_binding.rs @@ -0,0 +1,76 @@ +use std::fmt; + +use crate::{ + ActionRequest, BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, Origin, + SemanticNodeActionTarget, +}; + +/// One semantic node target explicitly paired with the business action request it would serve. +/// +/// The binding prevents independently validated browser-node authority and business intent from +/// being combined across different source documents. It does not grant policy authority, map a +/// node-local action to business risk, authorize a destination, or execute browser input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SemanticNodeActionBinding { + target: SemanticNodeActionTarget, + request: ActionRequest, +} + +impl SemanticNodeActionBinding { + /// Bind a semantic node target to a business request from the same current document origin. + pub fn new( + target: SemanticNodeActionTarget, + request: ActionRequest, + ) -> Result { + if target.handle().origin() != request.source_origin() { + return Err(SemanticNodeActionBindingError::SourceOriginMismatch); + } + Ok(Self { target, request }) + } + + /// Return the exact authority-bound semantic node target. + #[must_use] + pub const fn target(&self) -> &SemanticNodeActionTarget { + &self.target + } + + /// Return the independently classified business action request. + #[must_use] + pub const fn request(&self) -> &ActionRequest { + &self.request + } + + /// Revalidate exact browser authority immediately before a later dispatch boundary. + pub fn validate_current( + &self, + current_session: BrowserSessionId, + current_context: BrowsingContextId, + current_origin: &Origin, + current_epoch: DocumentEpoch, + ) -> Result<(), NodeHandleError> { + self.target.validate_current( + current_session, + current_context, + current_origin, + current_epoch, + ) + } +} + +/// A bounded failure to pair browser-node authority with the requested business action. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SemanticNodeActionBindingError { + /// The business request belongs to a different source origin than the observed node. + SourceOriginMismatch, +} + +impl fmt::Display for SemanticNodeActionBindingError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SourceOriginMismatch => formatter + .write_str("semantic node origin does not match action request source origin"), + } + } +} + +impl std::error::Error for SemanticNodeActionBindingError {} From a0986feedf1e9f8296e84faed5f12c3f452a39c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:02:22 +0900 Subject: [PATCH 036/121] test(core): require standard public validation errors --- .../tests/public_error_contract.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 crates/originweave-core/tests/public_error_contract.rs diff --git a/crates/originweave-core/tests/public_error_contract.rs b/crates/originweave-core/tests/public_error_contract.rs new file mode 100644 index 000000000..bfa583850 --- /dev/null +++ b/crates/originweave-core/tests/public_error_contract.rs @@ -0,0 +1,60 @@ +use std::error::Error; + +use originweave_core::{ActionIntentDigestError, ExtensionIdError, OriginError}; + +fn assert_standard_error() {} + +#[test] +fn public_validation_errors_are_standard_errors() { + assert_standard_error::(); + assert_standard_error::(); + assert_standard_error::(); +} + +#[test] +fn public_validation_errors_have_stable_operator_messages() { + assert_eq!( + OriginError::MissingScheme.to_string(), + "origin must include an explicit scheme" + ); + assert_eq!( + OriginError::UnsupportedScheme.to_string(), + "origin scheme must be HTTPS or loopback HTTP" + ); + assert_eq!( + OriginError::InsecureRemoteOrigin.to_string(), + "remote HTTP origins are not permitted" + ); + assert_eq!( + OriginError::MissingAuthority.to_string(), + "origin authority must not be empty" + ); + assert_eq!( + OriginError::UserInfoNotAllowed.to_string(), + "origin authority must not contain user information" + ); + assert_eq!( + OriginError::PathNotAllowed.to_string(), + "origin must not contain a path, query, or fragment" + ); + assert_eq!( + OriginError::InvalidAuthority.to_string(), + "origin authority is malformed or ambiguous" + ); + assert_eq!( + OriginError::AmbiguousNumericHost.to_string(), + "origin host uses a browser-ambiguous numeric address spelling" + ); + assert_eq!( + OriginError::InvalidPort.to_string(), + "origin port must be a numeric value from 1 through 65535" + ); + assert_eq!( + ActionIntentDigestError::InvalidFormat.to_string(), + "action intent digest must be sha256 followed by 64 lowercase hexadecimal digits" + ); + assert_eq!( + ExtensionIdError::InvalidExtensionId.to_string(), + "extension identifier must be 32 lowercase characters from a through p" + ); +} From 5c6d0edc25b9b8d77161a7a2dd843662d38daee3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:03:47 +0900 Subject: [PATCH 037/121] fix(core): expose standard validation errors --- .../originweave-core/src/contract_errors.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 crates/originweave-core/src/contract_errors.rs diff --git a/crates/originweave-core/src/contract_errors.rs b/crates/originweave-core/src/contract_errors.rs new file mode 100644 index 000000000..1b13c6457 --- /dev/null +++ b/crates/originweave-core/src/contract_errors.rs @@ -0,0 +1,47 @@ +use std::fmt; + +use crate::contracts::{ActionIntentDigestError, ExtensionIdError, OriginError}; + +impl fmt::Display for OriginError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::MissingScheme => "origin must include an explicit scheme", + Self::UnsupportedScheme => "origin scheme must be HTTPS or loopback HTTP", + Self::InsecureRemoteOrigin => "remote HTTP origins are not permitted", + Self::MissingAuthority => "origin authority must not be empty", + Self::UserInfoNotAllowed => "origin authority must not contain user information", + Self::PathNotAllowed => "origin must not contain a path, query, or fragment", + Self::InvalidAuthority => "origin authority is malformed or ambiguous", + Self::AmbiguousNumericHost => { + "origin host uses a browser-ambiguous numeric address spelling" + } + Self::InvalidPort => "origin port must be a numeric value from 1 through 65535", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for OriginError {} + +impl fmt::Display for ActionIntentDigestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFormat => formatter.write_str( + "action intent digest must be sha256 followed by 64 lowercase hexadecimal digits", + ), + } + } +} + +impl std::error::Error for ActionIntentDigestError {} + +impl fmt::Display for ExtensionIdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidExtensionId => formatter + .write_str("extension identifier must be 32 lowercase characters from a through p"), + } + } +} + +impl std::error::Error for ExtensionIdError {} From 15048b1ab21f900f213b178643dc713ff7f551bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:04:02 +0900 Subject: [PATCH 038/121] fix(core): wire public validation error contracts --- crates/originweave-core/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index bdd1b2aa9..e366a5d65 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -10,6 +10,7 @@ mod browser_registry; #[cfg(test)] mod browser_registry_coverage; +mod contract_errors; mod contracts; pub use browser_registry::{ From 2f1e7cd2277b99c06e2f834e41c6dc492ba8de9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:06:43 +0900 Subject: [PATCH 039/121] test(core): use standard validation errors without expect --- .../tests/browser_authority_registry.rs | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 98f74ee26..1c942d481 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -1,5 +1,3 @@ -#![allow(clippy::expect_used)] - use std::error::Error; use originweave_core::{ @@ -7,8 +5,8 @@ use originweave_core::{ MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, Origin, }; -fn loopback_origin() -> Origin { - Origin::parse("http://127.0.0.1:43127").expect("valid loopback fixture origin") +fn loopback_origin() -> Result> { + Ok(Origin::parse("http://127.0.0.1:43127")?) } #[test] @@ -92,7 +90,7 @@ fn document_rotation_invalidates_old_external_node_bindings() -> Result<(), Box< let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session("webdriver-session")?; let context = registry.register_context(session, "top-level-context")?; - let origin = loopback_origin(); + let origin = loopback_origin()?; let first = registry.bind_node(session, context, &origin, "backend-node-17")?; let same = registry.bind_node(session, context, &origin, "backend-node-17")?; @@ -119,7 +117,7 @@ fn retired_context_and_session_authority_cannot_be_reused() -> Result<(), Box Result<(), Box> { let owner = registry.register_session("owner-session")?; let attacker = registry.register_session("attacker-session")?; let context = registry.register_context(owner, "shared-looking-context")?; - let origin = loopback_origin(); + let origin = loopback_origin()?; assert_eq!( registry.bind_node(attacker, context, &origin, "node"), @@ -186,9 +184,8 @@ fn context_origin_cannot_change_without_document_rotation() -> Result<(), Box Result<(), Box Result<(), Bo let known = registry.register_session("known-session")?; let context = registry.register_context(known, "known-context")?; - let origin = loopback_origin(); + let origin = loopback_origin()?; assert_eq!( registry.bind_node(unknown, context, &origin, "node"), Err(BrowserRegistryError::UnknownBrowserSession) From 8bd82c4a30b1d0b7c79dbfa5ffe1477e50dbeb57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:18:15 +0900 Subject: [PATCH 040/121] refactor(core): make contract exports explicit --- crates/originweave-core/src/lib.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index e366a5d65..a6b92c989 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -16,4 +16,11 @@ mod contracts; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, }; -pub use contracts::*; +pub use contracts::{ + ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, ApprovalEvidence, + ApprovalScope, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, + ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, + ExtensionAgentGrant, ExtensionId, ExtensionIdError, InstructionSource, NodeHandleError, + ObservedNodeHandle, Origin, OriginError, PolicyContext, RiskClass, RobotsDecision, + SecretDelivery, SessionMode, evaluate_extension_access, +}; From d242b23efe2a30b572cf5ec6c27f55b8c5709c57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:22:17 +0900 Subject: [PATCH 041/121] test(core): require explicit sha256 digest prefix --- crates/originweave-core/tests/public_error_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/public_error_contract.rs b/crates/originweave-core/tests/public_error_contract.rs index bfa583850..a0e8fac2e 100644 --- a/crates/originweave-core/tests/public_error_contract.rs +++ b/crates/originweave-core/tests/public_error_contract.rs @@ -51,7 +51,7 @@ fn public_validation_errors_have_stable_operator_messages() { ); assert_eq!( ActionIntentDigestError::InvalidFormat.to_string(), - "action intent digest must be sha256 followed by 64 lowercase hexadecimal digits" + "action intent digest must be sha256: followed by 64 lowercase hexadecimal digits" ); assert_eq!( ExtensionIdError::InvalidExtensionId.to_string(), From 90227c5dd6c62839d4ccfe86640f08984011e207 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:59:29 +0900 Subject: [PATCH 042/121] fix(core): clarify sha256 digest prefix --- crates/originweave-core/src/contract_errors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/src/contract_errors.rs b/crates/originweave-core/src/contract_errors.rs index 1b13c6457..c34131bf1 100644 --- a/crates/originweave-core/src/contract_errors.rs +++ b/crates/originweave-core/src/contract_errors.rs @@ -27,7 +27,7 @@ impl fmt::Display for ActionIntentDigestError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidFormat => formatter.write_str( - "action intent digest must be sha256 followed by 64 lowercase hexadecimal digits", + "action intent digest must be sha256: followed by 64 lowercase hexadecimal digits", ), } } From 2203d60ec735e7f3f920fb9ff1c16cefa8d4029f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 09:21:11 +0900 Subject: [PATCH 043/121] test(core): preserve origin-bound expiring extension grants --- .../tests/extension_authority.rs | 119 +++++++++++++++++- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/extension_authority.rs b/crates/originweave-core/tests/extension_authority.rs index 82507a244..f34c30e9b 100644 --- a/crates/originweave-core/tests/extension_authority.rs +++ b/crates/originweave-core/tests/extension_authority.rs @@ -2,7 +2,7 @@ use originweave_core::{ BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest, - ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, evaluate_extension_access, + ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, evaluate_extension_access, }; fn extension_id(value: &str) -> ExtensionId { @@ -17,6 +17,13 @@ fn context(value: u64) -> BrowsingContextId { BrowsingContextId::new(value).expect("nonzero browsing context") } +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("canonical origin") +} + +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + #[test] fn extension_id_accepts_only_canonical_chromium_extension_ids() { let canonical = "abcdefghijklmnopabcdefghijklmnop"; @@ -43,10 +50,13 @@ fn extension_id_accepts_only_canonical_chromium_extension_ids() { fn extension_agent_access_requires_an_explicit_exact_grant() { let allowed_extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); let other_extension = extension_id("bcdefghijklmnopabcdefghijklmnopa"); + let granted_origin = origin("https://app.example"); let grant = ExtensionAgentGrant::new( allowed_extension.clone(), session(7), context(11), + granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -54,6 +64,8 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { allowed_extension.clone(), session(7), context(11), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -68,6 +80,8 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { other_extension, session(7), context(11), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -79,6 +93,8 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { allowed_extension.clone(), session(8), context(11), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -87,24 +103,55 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { ); let wrong_context = ExtensionAccessRequest::new( - allowed_extension, + allowed_extension.clone(), session(7), context(12), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( evaluate_extension_access(&wrong_context, Some(&grant)), ExtensionAccessDecision::DenyBrowsingContextMismatch ); + + let wrong_origin = ExtensionAccessRequest::new( + allowed_extension.clone(), + session(7), + context(11), + origin("https://other.example"), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&wrong_origin, Some(&grant)), + ExtensionAccessDecision::DenyOriginMismatch + ); + + let wrong_port = ExtensionAccessRequest::new( + allowed_extension, + session(7), + context(11), + origin("https://app.example:8443"), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&wrong_port, Some(&grant)), + ExtensionAccessDecision::DenyOriginMismatch + ); } #[test] fn chrome_permissions_never_imply_originweave_agent_capabilities() { let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("https://mail.example"); let grant = ExtensionAgentGrant::new( id.clone(), session(3), context(5), + granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -112,6 +159,8 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { id, session(3), context(5), + granted_origin, + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ProposeTypedAction, ); assert_eq!( @@ -123,10 +172,13 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { #[test] fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("http://127.0.0.1:8080"); let grant = ExtensionAgentGrant::new( id.clone(), session(13), context(17), + granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ ExtensionAgentCapability::ObserveCurrentContext, ExtensionAgentCapability::ProposeTypedAction, @@ -137,10 +189,71 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { ExtensionAgentCapability::ObserveCurrentContext, ExtensionAgentCapability::ProposeTypedAction, ] { - let request = ExtensionAccessRequest::new(id.clone(), session(13), context(17), capability); + let request = ExtensionAccessRequest::new( + id.clone(), + session(13), + context(17), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, + capability, + ); assert_eq!( evaluate_extension_access(&request, Some(&grant)), ExtensionAccessDecision::Allow ); } } + +#[test] +fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { + let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("https://billing.example"); + let expires_at_epoch_seconds = 1_700_000_100; + let grant = ExtensionAgentGrant::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds, + [ExtensionAgentCapability::ObserveCurrentContext], + ); + + let before_deadline = ExtensionAccessRequest::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds - 1, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&before_deadline, Some(&grant)), + ExtensionAccessDecision::Allow + ); + + let at_deadline = ExtensionAccessRequest::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&at_deadline, Some(&grant)), + ExtensionAccessDecision::DenyExpired + ); + + let after_deadline = ExtensionAccessRequest::new( + id, + session(19), + context(23), + granted_origin, + expires_at_epoch_seconds + 1, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&after_deadline, Some(&grant)), + ExtensionAccessDecision::DenyExpired + ); +} From 14cc3f51820c35970fe71e614df7ac2aca221654 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 10:10:30 +0900 Subject: [PATCH 044/121] fix(core): preserve protected extension grant authority --- .../src/extension_authority.rs | 147 ++++++++++++++++++ crates/originweave-core/src/lib.rs | 11 +- 2 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 crates/originweave-core/src/extension_authority.rs diff --git a/crates/originweave-core/src/extension_authority.rs b/crates/originweave-core/src/extension_authority.rs new file mode 100644 index 000000000..6a5725b4b --- /dev/null +++ b/crates/originweave-core/src/extension_authority.rs @@ -0,0 +1,147 @@ +//! Extension-to-Agent authority adapted onto the refactored core contracts. +//! +//! The browser-registry branch split long-lived contracts into a private +//! `contracts` module before protected main added origin and exclusive-expiry +//! binding to extension grants. This module preserves those protected-main +//! semantics without allowing raw Chromium permissions or identifiers to become +//! Agent authority. + +use crate::contracts::{ + BrowserSessionId, BrowsingContextId, ExtensionAccessDecision as BaseExtensionAccessDecision, + ExtensionAccessRequest as BaseExtensionAccessRequest, ExtensionAgentCapability, + ExtensionAgentGrant as BaseExtensionAgentGrant, ExtensionId, Origin, + evaluate_extension_access as evaluate_base_extension_access, +}; + +/// An explicit host-originated extension grant bound to session, context, origin, and expiry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAgentGrant { + base: BaseExtensionAgentGrant, + origin: Origin, + expires_at_epoch_seconds: u64, +} + +impl ExtensionAgentGrant { + /// Build an exact extension-to-Agent grant for one session, context, origin, and exclusive expiry. + #[must_use] + pub fn new( + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + expires_at_epoch_seconds: u64, + capabilities: I, + ) -> Self + where + I: IntoIterator, + { + Self { + base: BaseExtensionAgentGrant::new( + extension_id, + browser_session, + browsing_context, + capabilities, + ), + origin, + expires_at_epoch_seconds, + } + } +} + +/// One extension request to use a bounded Agent capability at trusted evaluation time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAccessRequest { + base: BaseExtensionAccessRequest, + origin: Origin, + now_epoch_seconds: u64, +} + +impl ExtensionAccessRequest { + /// Build one exact extension capability request without granting authority. + /// + /// `now_epoch_seconds` must come from trusted host evaluation time rather + /// than a page, extension, model, or other caller-controlled clock. + #[must_use] + pub const fn new( + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + now_epoch_seconds: u64, + capability: ExtensionAgentCapability, + ) -> Self { + Self { + base: BaseExtensionAccessRequest::new( + extension_id, + browser_session, + browsing_context, + capability, + ), + origin, + now_epoch_seconds, + } + } +} + +/// Result of evaluating one extension request against one explicit Agent grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionAccessDecision { + /// Extension, session, context, origin, expiry, and capability all match. + Allow, + /// No explicit extension-to-Agent grant was supplied. + DenyMissingGrant, + /// The request belongs to a different extension identity. + DenyExtensionMismatch, + /// The request belongs to a different browser automation session. + DenyBrowserSessionMismatch, + /// The request belongs to a different independently navigable browser context. + DenyBrowsingContextMismatch, + /// The request belongs to a different canonical origin than the grant. + DenyOriginMismatch, + /// Trusted evaluation time is at or after the grant's exclusive expiry. + DenyExpired, + /// The extension grant does not contain the requested OriginWeave capability. + DenyCapabilityNotGranted, +} + +/// Evaluate extension Agent access without inheriting ambient Chrome permissions. +/// +/// Identity, session, and context checks reuse the pre-existing deterministic +/// contract. Origin and exclusive-expiry checks are then applied before a +/// capability denial or allowance is returned, preserving protected-main +/// fail-closed ordering on the refactored branch. +#[must_use] +pub fn evaluate_extension_access( + request: &ExtensionAccessRequest, + grant: Option<&ExtensionAgentGrant>, +) -> ExtensionAccessDecision { + let Some(grant) = grant else { + return ExtensionAccessDecision::DenyMissingGrant; + }; + + let base_decision = evaluate_base_extension_access(&request.base, Some(&grant.base)); + match base_decision { + BaseExtensionAccessDecision::DenyMissingGrant => ExtensionAccessDecision::DenyMissingGrant, + BaseExtensionAccessDecision::DenyExtensionMismatch => { + ExtensionAccessDecision::DenyExtensionMismatch + } + BaseExtensionAccessDecision::DenyBrowserSessionMismatch => { + ExtensionAccessDecision::DenyBrowserSessionMismatch + } + BaseExtensionAccessDecision::DenyBrowsingContextMismatch => { + ExtensionAccessDecision::DenyBrowsingContextMismatch + } + BaseExtensionAccessDecision::Allow | BaseExtensionAccessDecision::DenyCapabilityNotGranted => { + if request.origin != grant.origin { + return ExtensionAccessDecision::DenyOriginMismatch; + } + if request.now_epoch_seconds >= grant.expires_at_epoch_seconds { + return ExtensionAccessDecision::DenyExpired; + } + if base_decision == BaseExtensionAccessDecision::DenyCapabilityNotGranted { + return ExtensionAccessDecision::DenyCapabilityNotGranted; + } + ExtensionAccessDecision::Allow + } + } +} diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index a6b92c989..e5da42586 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -12,6 +12,7 @@ mod browser_registry; mod browser_registry_coverage; mod contract_errors; mod contracts; +mod extension_authority; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, @@ -19,8 +20,10 @@ pub use browser_registry::{ pub use contracts::{ ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, - ExecutionPurpose, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, - ExtensionAgentGrant, ExtensionId, ExtensionIdError, InstructionSource, NodeHandleError, - ObservedNodeHandle, Origin, OriginError, PolicyContext, RiskClass, RobotsDecision, - SecretDelivery, SessionMode, evaluate_extension_access, + ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, InstructionSource, + NodeHandleError, ObservedNodeHandle, Origin, OriginError, PolicyContext, RiskClass, + RobotsDecision, SecretDelivery, SessionMode, +}; +pub use extension_authority::{ + ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentGrant, evaluate_extension_access, }; From c146e3e9f6962cb6f0bcc0f2f4db608135878e88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 10:31:04 +0900 Subject: [PATCH 045/121] fix(core): delegate missing extension grant evaluation --- .../src/extension_authority.rs | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/crates/originweave-core/src/extension_authority.rs b/crates/originweave-core/src/extension_authority.rs index 6a5725b4b..b250c442b 100644 --- a/crates/originweave-core/src/extension_authority.rs +++ b/crates/originweave-core/src/extension_authority.rs @@ -106,20 +106,16 @@ pub enum ExtensionAccessDecision { /// Evaluate extension Agent access without inheriting ambient Chrome permissions. /// -/// Identity, session, and context checks reuse the pre-existing deterministic -/// contract. Origin and exclusive-expiry checks are then applied before a -/// capability denial or allowance is returned, preserving protected-main +/// Identity, session, context, and missing-grant checks reuse the pre-existing +/// deterministic contract. Origin and exclusive-expiry checks are then applied +/// before a capability denial or allowance is returned, preserving protected-main /// fail-closed ordering on the refactored branch. #[must_use] pub fn evaluate_extension_access( request: &ExtensionAccessRequest, grant: Option<&ExtensionAgentGrant>, ) -> ExtensionAccessDecision { - let Some(grant) = grant else { - return ExtensionAccessDecision::DenyMissingGrant; - }; - - let base_decision = evaluate_base_extension_access(&request.base, Some(&grant.base)); + let base_decision = evaluate_base_extension_access(&request.base, grant.map(|grant| &grant.base)); match base_decision { BaseExtensionAccessDecision::DenyMissingGrant => ExtensionAccessDecision::DenyMissingGrant, BaseExtensionAccessDecision::DenyExtensionMismatch => { @@ -131,17 +127,21 @@ pub fn evaluate_extension_access( BaseExtensionAccessDecision::DenyBrowsingContextMismatch => { ExtensionAccessDecision::DenyBrowsingContextMismatch } - BaseExtensionAccessDecision::Allow | BaseExtensionAccessDecision::DenyCapabilityNotGranted => { - if request.origin != grant.origin { - return ExtensionAccessDecision::DenyOriginMismatch; - } - if request.now_epoch_seconds >= grant.expires_at_epoch_seconds { - return ExtensionAccessDecision::DenyExpired; - } - if base_decision == BaseExtensionAccessDecision::DenyCapabilityNotGranted { - return ExtensionAccessDecision::DenyCapabilityNotGranted; - } - ExtensionAccessDecision::Allow - } + BaseExtensionAccessDecision::Allow + | BaseExtensionAccessDecision::DenyCapabilityNotGranted => grant.map_or( + ExtensionAccessDecision::DenyMissingGrant, + |grant| { + if request.origin != grant.origin { + return ExtensionAccessDecision::DenyOriginMismatch; + } + if request.now_epoch_seconds >= grant.expires_at_epoch_seconds { + return ExtensionAccessDecision::DenyExpired; + } + if base_decision == BaseExtensionAccessDecision::DenyCapabilityNotGranted { + return ExtensionAccessDecision::DenyCapabilityNotGranted; + } + ExtensionAccessDecision::Allow + }, + ), } } From 8a4b4c2358f2478c7ad58ebcab595fece977ba95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 10:33:00 +0900 Subject: [PATCH 046/121] style(core): apply canonical extension authority formatting --- crates/originweave-core/src/extension_authority.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/src/extension_authority.rs b/crates/originweave-core/src/extension_authority.rs index b250c442b..27dae790a 100644 --- a/crates/originweave-core/src/extension_authority.rs +++ b/crates/originweave-core/src/extension_authority.rs @@ -115,7 +115,8 @@ pub fn evaluate_extension_access( request: &ExtensionAccessRequest, grant: Option<&ExtensionAgentGrant>, ) -> ExtensionAccessDecision { - let base_decision = evaluate_base_extension_access(&request.base, grant.map(|grant| &grant.base)); + let base_decision = + evaluate_base_extension_access(&request.base, grant.map(|grant| &grant.base)); match base_decision { BaseExtensionAccessDecision::DenyMissingGrant => ExtensionAccessDecision::DenyMissingGrant, BaseExtensionAccessDecision::DenyExtensionMismatch => { @@ -128,9 +129,8 @@ pub fn evaluate_extension_access( ExtensionAccessDecision::DenyBrowsingContextMismatch } BaseExtensionAccessDecision::Allow - | BaseExtensionAccessDecision::DenyCapabilityNotGranted => grant.map_or( - ExtensionAccessDecision::DenyMissingGrant, - |grant| { + | BaseExtensionAccessDecision::DenyCapabilityNotGranted => { + grant.map_or(ExtensionAccessDecision::DenyMissingGrant, |grant| { if request.origin != grant.origin { return ExtensionAccessDecision::DenyOriginMismatch; } @@ -141,7 +141,7 @@ pub fn evaluate_extension_access( return ExtensionAccessDecision::DenyCapabilityNotGranted; } ExtensionAccessDecision::Allow - }, - ), + }) + } } } From b55716d446cbe99747b5d56019c953d2d2ce551d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:54:06 +0900 Subject: [PATCH 047/121] test(core): require extension grant task binding --- .../tests/extension_task_authority.rs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 crates/originweave-core/tests/extension_task_authority.rs diff --git a/crates/originweave-core/tests/extension_task_authority.rs b/crates/originweave-core/tests/extension_task_authority.rs new file mode 100644 index 000000000..ff7d1b433 --- /dev/null +++ b/crates/originweave-core/tests/extension_task_authority.rs @@ -0,0 +1,76 @@ +#![allow(clippy::expect_used)] + +use originweave_core::{ + AgentTaskId, BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, + evaluate_extension_access, +}; + +fn extension_id(value: &str) -> ExtensionId { + ExtensionId::parse(value).expect("valid extension id") +} + +fn task(value: u64) -> AgentTaskId { + AgentTaskId::new(value).expect("nonzero Agent Task identity") +} + +fn session(value: u64) -> BrowserSessionId { + BrowserSessionId::new(value).expect("nonzero browser session") +} + +fn context(value: u64) -> BrowsingContextId { + BrowsingContextId::new(value).expect("nonzero browsing context") +} + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("canonical origin") +} + +#[test] +fn agent_task_identity_rejects_zero() { + assert!(AgentTaskId::new(0).is_err()); + assert_eq!(task(29).value(), 29); +} + +#[test] +fn extension_agent_grants_are_non_transferable_between_agent_tasks() { + let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("https://agent.example"); + let grant = ExtensionAgentGrant::new( + id.clone(), + task(29), + session(7), + context(11), + granted_origin.clone(), + 1_700_000_600, + [ExtensionAgentCapability::ProposeTypedAction], + ); + + let exact_task = ExtensionAccessRequest::new( + id.clone(), + task(29), + session(7), + context(11), + granted_origin.clone(), + 1_700_000_000, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&exact_task, Some(&grant)), + ExtensionAccessDecision::Allow + ); + + let other_task = ExtensionAccessRequest::new( + id, + task(30), + session(7), + context(11), + granted_origin, + 1_700_000_000, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&other_task, Some(&grant)), + ExtensionAccessDecision::DenyAgentTaskMismatch + ); +} From d043af9fa8d9c9d16398b0eb3ea862eaaeff31f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:59:21 +0900 Subject: [PATCH 048/121] fix(core): bind extension grants to Agent Task identity --- .../src/extension_authority.rs | 68 ++++++++++++++++--- crates/originweave-core/src/lib.rs | 3 +- .../tests/extension_authority.rs | 25 ++++++- .../tests/extension_task_authority.rs | 13 +++- 4 files changed, 94 insertions(+), 15 deletions(-) diff --git a/crates/originweave-core/src/extension_authority.rs b/crates/originweave-core/src/extension_authority.rs index 27dae790a..74d6db6e3 100644 --- a/crates/originweave-core/src/extension_authority.rs +++ b/crates/originweave-core/src/extension_authority.rs @@ -6,6 +6,8 @@ //! semantics without allowing raw Chromium permissions or identifiers to become //! Agent authority. +use std::fmt; + use crate::contracts::{ BrowserSessionId, BrowsingContextId, ExtensionAccessDecision as BaseExtensionAccessDecision, ExtensionAccessRequest as BaseExtensionAccessRequest, ExtensionAgentCapability, @@ -13,19 +15,58 @@ use crate::contracts::{ evaluate_extension_access as evaluate_base_extension_access, }; -/// An explicit host-originated extension grant bound to session, context, origin, and expiry. +/// A nonzero host-assigned identity for one isolated Agent Task. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AgentTaskId(u64); + +impl AgentTaskId { + /// Validate one host-assigned Agent Task identifier. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(AgentTaskIdError::InvalidAgentTaskId); + } + Ok(Self(value)) + } + + /// Return the validated Agent Task identifier. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A validation failure for an Agent Task identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentTaskIdError { + /// Agent Task identities are one-based and zero was supplied. + InvalidAgentTaskId, +} + +impl fmt::Display for AgentTaskIdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidAgentTaskId => formatter.write_str("Agent Task identifier must be nonzero"), + } + } +} + +impl std::error::Error for AgentTaskIdError {} + +/// An explicit host-originated extension grant bound to task, session, context, origin, and expiry. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExtensionAgentGrant { base: BaseExtensionAgentGrant, + agent_task: AgentTaskId, origin: Origin, expires_at_epoch_seconds: u64, } impl ExtensionAgentGrant { - /// Build an exact extension-to-Agent grant for one session, context, origin, and exclusive expiry. + /// Build an exact extension-to-Agent grant for one task, session, context, origin, and expiry. #[must_use] pub fn new( extension_id: ExtensionId, + agent_task: AgentTaskId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, origin: Origin, @@ -42,28 +83,31 @@ impl ExtensionAgentGrant { browsing_context, capabilities, ), + agent_task, origin, expires_at_epoch_seconds, } } } -/// One extension request to use a bounded Agent capability at trusted evaluation time. +/// One task-bound extension request to use a bounded Agent capability at trusted evaluation time. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExtensionAccessRequest { base: BaseExtensionAccessRequest, + agent_task: AgentTaskId, origin: Origin, now_epoch_seconds: u64, } impl ExtensionAccessRequest { - /// Build one exact extension capability request without granting authority. + /// Build one exact task-bound extension capability request without granting authority. /// /// `now_epoch_seconds` must come from trusted host evaluation time rather /// than a page, extension, model, or other caller-controlled clock. #[must_use] pub const fn new( extension_id: ExtensionId, + agent_task: AgentTaskId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, origin: Origin, @@ -77,6 +121,7 @@ impl ExtensionAccessRequest { browsing_context, capability, ), + agent_task, origin, now_epoch_seconds, } @@ -86,12 +131,14 @@ impl ExtensionAccessRequest { /// Result of evaluating one extension request against one explicit Agent grant. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExtensionAccessDecision { - /// Extension, session, context, origin, expiry, and capability all match. + /// Extension, task, session, context, origin, expiry, and capability all match. Allow, /// No explicit extension-to-Agent grant was supplied. DenyMissingGrant, /// The request belongs to a different extension identity. DenyExtensionMismatch, + /// The request belongs to a different Agent Task identity. + DenyAgentTaskMismatch, /// The request belongs to a different browser automation session. DenyBrowserSessionMismatch, /// The request belongs to a different independently navigable browser context. @@ -106,10 +153,10 @@ pub enum ExtensionAccessDecision { /// Evaluate extension Agent access without inheriting ambient Chrome permissions. /// -/// Identity, session, context, and missing-grant checks reuse the pre-existing -/// deterministic contract. Origin and exclusive-expiry checks are then applied -/// before a capability denial or allowance is returned, preserving protected-main -/// fail-closed ordering on the refactored branch. +/// Extension identity, session, context, and missing-grant checks reuse the pre-existing +/// deterministic contract. Exact Agent Task identity, origin, and exclusive-expiry checks are +/// then applied before a capability denial or allowance is returned, preserving fail-closed +/// authority ordering on the refactored branch. #[must_use] pub fn evaluate_extension_access( request: &ExtensionAccessRequest, @@ -131,6 +178,9 @@ pub fn evaluate_extension_access( BaseExtensionAccessDecision::Allow | BaseExtensionAccessDecision::DenyCapabilityNotGranted => { grant.map_or(ExtensionAccessDecision::DenyMissingGrant, |grant| { + if request.agent_task != grant.agent_task { + return ExtensionAccessDecision::DenyAgentTaskMismatch; + } if request.origin != grant.origin { return ExtensionAccessDecision::DenyOriginMismatch; } diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index e5da42586..b8c43c09d 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -25,5 +25,6 @@ pub use contracts::{ RobotsDecision, SecretDelivery, SessionMode, }; pub use extension_authority::{ - ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentGrant, evaluate_extension_access, + AgentTaskId, AgentTaskIdError, ExtensionAccessDecision, ExtensionAccessRequest, + ExtensionAgentGrant, evaluate_extension_access, }; diff --git a/crates/originweave-core/tests/extension_authority.rs b/crates/originweave-core/tests/extension_authority.rs index f34c30e9b..1ddeb5383 100644 --- a/crates/originweave-core/tests/extension_authority.rs +++ b/crates/originweave-core/tests/extension_authority.rs @@ -1,14 +1,19 @@ #![allow(clippy::expect_used)] use originweave_core::{ - BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest, - ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, evaluate_extension_access, + AgentTaskId, BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, + evaluate_extension_access, }; fn extension_id(value: &str) -> ExtensionId { ExtensionId::parse(value).expect("valid extension id") } +fn task(value: u64) -> AgentTaskId { + AgentTaskId::new(value).expect("nonzero Agent Task identity") +} + fn session(value: u64) -> BrowserSessionId { BrowserSessionId::new(value).expect("nonzero browser session") } @@ -23,6 +28,7 @@ fn origin(value: &str) -> Origin { const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; +const AGENT_TASK_ID: u64 = 29; #[test] fn extension_id_accepts_only_canonical_chromium_extension_ids() { @@ -53,6 +59,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let granted_origin = origin("https://app.example"); let grant = ExtensionAgentGrant::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(7), context(11), granted_origin.clone(), @@ -62,6 +69,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let exact = ExtensionAccessRequest::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(7), context(11), granted_origin.clone(), @@ -78,6 +86,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_extension = ExtensionAccessRequest::new( other_extension, + task(AGENT_TASK_ID), session(7), context(11), granted_origin.clone(), @@ -91,6 +100,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_session = ExtensionAccessRequest::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(8), context(11), granted_origin.clone(), @@ -104,6 +114,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_context = ExtensionAccessRequest::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(7), context(12), granted_origin.clone(), @@ -117,6 +128,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_origin = ExtensionAccessRequest::new( allowed_extension.clone(), + task(AGENT_TASK_ID), session(7), context(11), origin("https://other.example"), @@ -130,6 +142,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { let wrong_port = ExtensionAccessRequest::new( allowed_extension, + task(AGENT_TASK_ID), session(7), context(11), origin("https://app.example:8443"), @@ -148,6 +161,7 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { let granted_origin = origin("https://mail.example"); let grant = ExtensionAgentGrant::new( id.clone(), + task(AGENT_TASK_ID), session(3), context(5), granted_origin.clone(), @@ -157,6 +171,7 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { let propose_action = ExtensionAccessRequest::new( id, + task(AGENT_TASK_ID), session(3), context(5), granted_origin, @@ -175,6 +190,7 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { let granted_origin = origin("http://127.0.0.1:8080"); let grant = ExtensionAgentGrant::new( id.clone(), + task(AGENT_TASK_ID), session(13), context(17), granted_origin.clone(), @@ -191,6 +207,7 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { ] { let request = ExtensionAccessRequest::new( id.clone(), + task(AGENT_TASK_ID), session(13), context(17), granted_origin.clone(), @@ -211,6 +228,7 @@ fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { let expires_at_epoch_seconds = 1_700_000_100; let grant = ExtensionAgentGrant::new( id.clone(), + task(AGENT_TASK_ID), session(19), context(23), granted_origin.clone(), @@ -220,6 +238,7 @@ fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { let before_deadline = ExtensionAccessRequest::new( id.clone(), + task(AGENT_TASK_ID), session(19), context(23), granted_origin.clone(), @@ -233,6 +252,7 @@ fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { let at_deadline = ExtensionAccessRequest::new( id.clone(), + task(AGENT_TASK_ID), session(19), context(23), granted_origin.clone(), @@ -246,6 +266,7 @@ fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { let after_deadline = ExtensionAccessRequest::new( id, + task(AGENT_TASK_ID), session(19), context(23), granted_origin, diff --git a/crates/originweave-core/tests/extension_task_authority.rs b/crates/originweave-core/tests/extension_task_authority.rs index ff7d1b433..ef21c2f1f 100644 --- a/crates/originweave-core/tests/extension_task_authority.rs +++ b/crates/originweave-core/tests/extension_task_authority.rs @@ -1,7 +1,7 @@ #![allow(clippy::expect_used)] use originweave_core::{ - AgentTaskId, BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, + AgentTaskId, AgentTaskIdError, BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, evaluate_extension_access, }; @@ -27,9 +27,16 @@ fn origin(value: &str) -> Origin { } #[test] -fn agent_task_identity_rejects_zero() { - assert!(AgentTaskId::new(0).is_err()); +fn agent_task_identity_rejects_zero_with_standard_error_contract() { + assert_eq!( + AgentTaskId::new(0), + Err(AgentTaskIdError::InvalidAgentTaskId) + ); assert_eq!(task(29).value(), 29); + + let error = AgentTaskIdError::InvalidAgentTaskId; + assert_eq!(error.to_string(), "Agent Task identifier must be nonzero"); + assert!(std::error::Error::source(&error).is_none()); } #[test] From a4830c2a05be50382ac6d199e76fa1841cfc04d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:02:01 +0900 Subject: [PATCH 049/121] style(core): apply canonical task-authority formatting --- crates/originweave-core/src/extension_authority.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/extension_authority.rs b/crates/originweave-core/src/extension_authority.rs index 74d6db6e3..f06ce5c31 100644 --- a/crates/originweave-core/src/extension_authority.rs +++ b/crates/originweave-core/src/extension_authority.rs @@ -45,7 +45,9 @@ pub enum AgentTaskIdError { impl fmt::Display for AgentTaskIdError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidAgentTaskId => formatter.write_str("Agent Task identifier must be nonzero"), + Self::InvalidAgentTaskId => { + formatter.write_str("Agent Task identifier must be nonzero") + } } } } From b45ab7badddea005fd6e93e3aad859abaa2c7c81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:06:29 +0900 Subject: [PATCH 050/121] docs(adr): record active task-bound extension authority --- docs/adr/0013-manifest-v3-extension-authority.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0013-manifest-v3-extension-authority.md b/docs/adr/0013-manifest-v3-extension-authority.md index 8feacbf27..09cfc0954 100644 --- a/docs/adr/0013-manifest-v3-extension-authority.md +++ b/docs/adr/0013-manifest-v3-extension-authority.md @@ -92,7 +92,7 @@ No persistent database migration is introduced. A release can roll back the Chro ## Open follow-ups -- Complete issue #27's compatibility matrix and production isolation acceptance. Exclusive trusted-time expiry on origin-bound `ExtensionAgentGrant` evaluation is the next protected-main candidate; task identity binding remains open. +- Complete issue #27's compatibility matrix and production isolation acceptance. Exact Agent Task identity binding, canonical-origin binding, and exclusive trusted-time expiry are implemented on active PR #40 and remain active-PR evidence until protected integration. - Define managed-extension identity/update semantics. - Implement the native-messaging allow-list/process boundary before claiming support. - Integrate the complete Agent Task browser vertical slice under issue #28. @@ -105,4 +105,4 @@ Supersede this ADR if Chromium adopts a materially different extension authority ## References -Primary browser/extension/protocol evidence and APA 7 references are maintained in [`../doctoring/browser-agent-protocols.md`](../doctoring/browser-agent-protocols.md) and [`../doctoring.md`](../doctoring.md). Related decisions include ADR 0001, ADR 0002, ADR 0007, ADR 0010, ADR 0101, ADR 0104, and ADR 0107. \ No newline at end of file +Primary browser/extension/protocol evidence and APA 7 references are maintained in [`../doctoring/browser-agent-protocols.md`](../doctoring/browser-agent-protocols.md) and [`../doctoring.md`](../doctoring.md). Related decisions include ADR 0001, ADR 0002, ADR 0007, ADR 0010, ADR 0101, ADR 0104, and ADR 0107. From 692cf58075572a48b3946d337b0960bfadaf4320 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:07:14 +0900 Subject: [PATCH 051/121] docs(changelog): record task-bound extension grants --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d17419927..5a3ba91e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Bound explicit extension-to-Agent grants to a nonzero host-assigned Agent Task identity, so a grant that otherwise matches extension, session, browsing context, origin, expiry, and capability fails closed when reused by a different task. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. From 1c6a208296c08125bdcc389e00d6a787b06d9289 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 01:06:59 -0700 Subject: [PATCH 052/121] test(browser): keep failed node allocation transactional --- .../tests/browser_authority_registry.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 1c942d481..31bd86a13 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -195,6 +195,30 @@ fn context_origin_cannot_change_without_document_rotation() -> Result<(), Box Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(2); + let session = registry.register_session("webdriver-session")?; + let exhausted_context = registry.register_context(session, "exhaustion-source")?; + let clean_context = registry.register_context(session, "clean-context")?; + let first_origin = loopback_origin()?; + let second_origin = Origin::parse("http://localhost:43127")?; + + registry.bind_node(session, exhausted_context, &first_origin, "node-one")?; + registry.bind_node(session, exhausted_context, &first_origin, "node-two")?; + + assert_eq!( + registry.bind_node(session, clean_context, &first_origin, "node-three"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + assert_eq!( + registry.bind_node(session, clean_context, &second_origin, "node-three"), + Err(BrowserRegistryError::IdentifierSpaceExhausted), + "a failed allocation must not leave behind origin authority" + ); + Ok(()) +} + #[test] fn external_identifiers_are_bounded_without_assuming_protocol_syntax() -> Result<(), Box> { From 8d51ea33f4394258c474b1a29c2080e9851b7b58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 01:12:04 -0700 Subject: [PATCH 053/121] fix(browser): keep failed node binding transactional --- .../originweave-core/src/browser_registry.rs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 8807c2d36..a93dfee89 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -221,26 +221,26 @@ impl BrowserAuthorityRegistry { actual: browser_session, }); } - match self.context_origin.get(&browsing_context) { + let origin_is_unbound = match self.context_origin.get(&browsing_context) { Some(expected_origin) if expected_origin != origin => { return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); } - Some(_expected_origin) => {} - None => { - self.context_origin.insert(browsing_context, origin.clone()); - } + Some(_expected_origin) => false, + None => true, + }; + let epoch = self.current_epoch(browsing_context)?; + let key = (browsing_context, epoch, external_identifier.to_owned()); + let node_id = if let Some(existing) = self.node_by_external.get(&key) { + *existing + } else { + take_identifier(&mut self.next_node_id, self.maximum_identifier)? + }; + let handle = observed_node_handle(browser_session, browsing_context, origin, epoch, node_id)?; + if origin_is_unbound { + self.context_origin.insert(browsing_context, origin.clone()); } - self.current_epoch(browsing_context).and_then(|epoch| { - let key = (browsing_context, epoch, external_identifier.to_owned()); - let node_id = if let Some(existing) = self.node_by_external.get(&key) { - *existing - } else { - let allocated = take_identifier(&mut self.next_node_id, self.maximum_identifier)?; - self.node_by_external.insert(key, allocated); - allocated - }; - observed_node_handle(browser_session, browsing_context, origin, epoch, node_id) - }) + self.node_by_external.entry(key).or_insert(node_id); + Ok(handle) } } From b9e9f3a710d58e9f2b14770da6910b3f82598e24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 01:20:47 -0700 Subject: [PATCH 054/121] style(browser): format transactional node binding --- crates/originweave-core/src/browser_registry.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index a93dfee89..4f4ada9f1 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -235,7 +235,8 @@ impl BrowserAuthorityRegistry { } else { take_identifier(&mut self.next_node_id, self.maximum_identifier)? }; - let handle = observed_node_handle(browser_session, browsing_context, origin, epoch, node_id)?; + let handle = + observed_node_handle(browser_session, browsing_context, origin, epoch, node_id)?; if origin_is_unbound { self.context_origin.insert(browsing_context, origin.clone()); } From 0428a97621a1f95f1c0fc1e9ea3ad6452f37bca8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 03:36:22 -0700 Subject: [PATCH 055/121] test(browser): cover fail-closed registry invariants --- .../originweave-core/src/browser_registry.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 4f4ada9f1..aa00d8efa 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -394,6 +394,38 @@ mod tests { ); } + #[test] + fn bind_node_fails_closed_on_corrupted_internal_authority_state() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("corrupt-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "corrupt-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + let epochs = values(registry.current_epoch(context)); + assert_eq!(epochs.len(), 1); + let epoch = epochs[0]; + + registry.context_epoch.remove(&context); + assert_eq!( + registry.bind_node(session, context, origin, "missing-epoch"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + registry.context_epoch.insert(context, epoch); + + registry + .node_by_external + .insert((context, epoch, "invalid-node".to_owned()), 0); + assert_eq!( + registry.bind_node(session, context, origin, "invalid-node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + } + #[test] fn monotonic_identifier_exhaustion_is_fail_closed() { let mut next = 1; From 8bf78033014328a9134d293f38348dcece7e0723 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 08:04:28 -0700 Subject: [PATCH 056/121] test(browser): reject forged or retired node handles --- .../tests/browser_authority_registry.rs | 81 ++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 31bd86a13..95f8918c7 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -2,7 +2,7 @@ use std::error::Error; use originweave_core::{ BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, Origin, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, ObservedNodeHandle, Origin, }; fn loopback_origin() -> Result> { @@ -65,6 +65,10 @@ fn public_default_and_error_contracts_are_usable_from_an_adapter() -> Result<(), BrowserRegistryError::OriginChangedWithoutDocumentAdvance, "browsing context origin changed without advancing the document epoch".to_owned(), ), + ( + BrowserRegistryError::UnknownNodeAuthority, + "observed node handle is not registered as current browser authority".to_owned(), + ), ( BrowserRegistryError::IdentifierSpaceExhausted, "browser authority identifier space is exhausted".to_owned(), @@ -285,3 +289,78 @@ fn unknown_internal_authority_is_rejected_before_node_binding() -> Result<(), Bo ); Ok(()) } + +#[test] +fn registry_revalidates_live_node_authority_and_rejects_forged_or_retired_handles() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let other = registry.register_session("other-session")?; + let context = registry.register_context(owner, "top-level-context")?; + let origin = loopback_origin()?; + let live = registry.bind_node(owner, context, &origin, "backend-node-17")?; + + assert_eq!(registry.validate_node_handle(&live), Ok(())); + + let forged_node = ObservedNodeHandle::new( + owner, + context, + origin.clone(), + live.document_epoch(), + live.node_id() + 1, + )?; + assert_eq!( + registry.validate_node_handle(&forged_node), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let wrong_session = ObservedNodeHandle::new( + other, + context, + origin.clone(), + live.document_epoch(), + live.node_id(), + )?; + assert_eq!( + registry.validate_node_handle(&wrong_session), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let unbound_context = registry.register_context(owner, "unbound-context")?; + let synthetic_unbound = ObservedNodeHandle::new( + owner, + unbound_context, + origin.clone(), + DocumentEpoch::new(1)?, + 777, + )?; + assert_eq!( + registry.validate_node_handle(&synthetic_unbound), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let next_epoch = registry.advance_document(context)?; + assert_eq!(next_epoch.value(), 2); + assert_eq!( + registry.validate_node_handle(&live), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let replacement = registry.bind_node(owner, context, &origin, "backend-node-17")?; + assert_eq!(registry.validate_node_handle(&replacement), Ok(())); + + registry.remove_context(context)?; + assert_eq!( + registry.validate_node_handle(&replacement), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + let session_context = registry.register_context(owner, "session-retirement")?; + let session_handle = registry.bind_node(owner, session_context, &origin, "session-node")?; + registry.remove_session(owner)?; + assert_eq!( + registry.validate_node_handle(&session_handle), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + Ok(()) +} From d5705f8e4d9c3f7ebab89926b8aa5184e5539e6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 08:06:51 -0700 Subject: [PATCH 057/121] fix(browser): revalidate live node authority in registry --- .../originweave-core/src/browser_registry.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index aa00d8efa..4fd654f2c 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -243,6 +243,50 @@ impl BrowserAuthorityRegistry { self.node_by_external.entry(key).or_insert(node_id); Ok(handle) } + + /// Verify that an observed node handle is still live authority in this registry. + /// + /// This check must run immediately before a node-local browser action. It re-derives the + /// current session, context, origin, and document epoch from registry-owned state and also + /// requires the node identifier to remain present in the current document's private external + /// binding table. Caller-supplied or previously retired handles therefore cannot manufacture + /// authority merely by presenting a self-consistent tuple. + pub fn validate_node_handle( + &self, + handle: &ObservedNodeHandle, + ) -> Result<(), BrowserRegistryError> { + if !self.known_sessions.contains(&handle.browser_session()) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let context = handle.browsing_context(); + let expected_session = self + .context_session + .get(&context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + if expected_session != handle.browser_session() { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + let epoch = self.current_epoch(context)?; + let origin = self + .context_origin + .get(&context) + .ok_or(BrowserRegistryError::UnknownNodeAuthority)?; + handle + .validate_current(expected_session, context, origin, epoch) + .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; + let is_bound = self.node_by_external.iter().any( + |((bound_context, bound_epoch, _external_identifier), node_id)| { + *bound_context == context + && *bound_epoch == epoch + && *node_id == handle.node_id() + }, + ); + if !is_bound { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + Ok(()) + } } impl Default for BrowserAuthorityRegistry { @@ -269,6 +313,8 @@ pub enum BrowserRegistryError { }, /// The context origin changed without first rotating the document epoch. OriginChangedWithoutDocumentAdvance, + /// The observed node handle is not a current node binding owned by this registry. + UnknownNodeAuthority, /// The registry exhausted one of its monotonic internal identifier spaces. IdentifierSpaceExhausted, /// A document epoch reached the maximum representable value. @@ -297,6 +343,8 @@ impl fmt::Display for BrowserRegistryError { ), Self::OriginChangedWithoutDocumentAdvance => formatter .write_str("browsing context origin changed without advancing the document epoch"), + Self::UnknownNodeAuthority => formatter + .write_str("observed node handle is not registered as current browser authority"), Self::IdentifierSpaceExhausted => { formatter.write_str("browser authority identifier space is exhausted") } @@ -617,6 +665,7 @@ mod tests { actual: actual_values[0], }, BrowserRegistryError::OriginChangedWithoutDocumentAdvance, + BrowserRegistryError::UnknownNodeAuthority, BrowserRegistryError::IdentifierSpaceExhausted, BrowserRegistryError::DocumentEpochExhausted, BrowserRegistryError::InternalAuthorityInvariant, From 0d12ad507b3a9bbf9765f04f57298eb02c012219 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 08:10:08 -0700 Subject: [PATCH 058/121] style(browser): apply canonical node validation formatting --- crates/originweave-core/src/browser_registry.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 4fd654f2c..b194ddda5 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -277,9 +277,7 @@ impl BrowserAuthorityRegistry { .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; let is_bound = self.node_by_external.iter().any( |((bound_context, bound_epoch, _external_identifier), node_id)| { - *bound_context == context - && *bound_epoch == epoch - && *node_id == handle.node_id() + *bound_context == context && *bound_epoch == epoch && *node_id == handle.node_id() }, ); if !is_bound { From 1248ccf3bda5b08b23709e3317d76a8d354973ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:55:39 +0900 Subject: [PATCH 059/121] test(browser): close registry coverage gaps --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 1 + .../originweave-core/src/browser_registry.rs | 46 ++++++++ .../src/browser_registry_coverage.rs | 109 +++++++++++++++++- .../tests/browser_authority_registry.rs | 14 +++ 5 files changed, 170 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99d8d6ee8..f804f7496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: --branch --text --show-missing-lines - | tee missing-lines.txt + > missing-lines.txt - name: Upload exact coverage diagnostics uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3ba91e2..c6cef45f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Bound explicit extension-to-Agent grants to a nonzero host-assigned Agent Task identity, so a grant that otherwise matches extension, session, browsing context, origin, expiry, and capability fails closed when reused by a different task. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. +- Added a bounded browser-protocol authority registry that maps opaque session, browsing-context, and node identifiers to registry-local identities, rotates document epochs, and revalidates live node handles before actions. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index b194ddda5..c0be77766 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -472,6 +472,52 @@ mod tests { ); } + #[test] + fn validation_binding_predicate_checks_each_authority_dimension() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("predicate-session")); + let contexts = values(registry.register_context(sessions[0], "first-context")); + let second_contexts = values(registry.register_context(sessions[0], "second-context")); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(sessions.len(), 1); + assert_eq!(contexts.len(), 1); + assert_eq!(second_contexts.len(), 1); + assert_eq!(origins.len(), 1); + + let first = values(registry.bind_node(sessions[0], contexts[0], &origins[0], "first-node")); + let second = + values(registry.bind_node(sessions[0], second_contexts[0], &origins[0], "second-node")); + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_eq!(registry.validate_node_handle(&second[0]), Ok(())); + + let current_epochs = values(registry.current_epoch(contexts[0])); + let future_epochs = values(DocumentEpoch::new(2)); + assert_eq!(current_epochs.len(), 1); + assert_eq!(future_epochs.len(), 1); + registry.node_by_external.insert( + (contexts[0], future_epochs[0], "synthetic-node".to_owned()), + 9_999, + ); + let forged = values(ObservedNodeHandle::new( + sessions[0], + contexts[0], + origins[0].clone(), + current_epochs[0], + 9_999, + )); + assert_eq!(forged.len(), 1); + assert_eq!( + registry.validate_node_handle(&forged[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + registry.context_epoch.remove(&contexts[0]); + assert_eq!( + registry.validate_node_handle(&forged[0]), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + } + #[test] fn monotonic_identifier_exhaustion_is_fail_closed() { let mut next = 1; diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 1860bc7be..82ac5b6b7 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -1,4 +1,7 @@ -use crate::{BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, Origin}; +use crate::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, + ObservedNodeHandle, Origin, +}; fn values(result: Result) -> Vec { result.into_iter().collect() @@ -10,6 +13,8 @@ fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { let sessions = values(registry.register_session("unit-session")); assert_eq!(sessions.len(), 1); let session = sessions[0]; + let repeated_sessions = values(registry.register_session("unit-session")); + assert_eq!(repeated_sessions, sessions); let contexts = values(registry.register_context(session, "unit-context")); assert_eq!(contexts.len(), 1); @@ -24,6 +29,108 @@ fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { assert_eq!(first.len(), 1); assert_eq!(repeated.len(), 1); assert_eq!(first[0], repeated[0]); + assert_eq!(registry.validate_node_handle(&first[0]), Ok(())); + + let epochs = values(DocumentEpoch::new(1)); + assert_eq!(epochs.len(), 1); + let forged = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epochs[0], + first[0].node_id() + 1, + )); + assert_eq!(forged.len(), 1); + assert_eq!( + registry.validate_node_handle(&forged[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let mismatched_origins = values(Origin::parse("http://localhost:43127")); + assert_eq!(mismatched_origins.len(), 1); + let mismatched = values(ObservedNodeHandle::new( + session, + context, + mismatched_origins[0].clone(), + epochs[0], + first[0].node_id(), + )); + assert_eq!(mismatched.len(), 1); + assert_eq!( + registry.validate_node_handle(&mismatched[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); +} + +#[test] +fn node_validation_rejects_each_missing_authority_boundary() { + let mut registry = BrowserAuthorityRegistry::new(); + assert_eq!( + registry.register_session(""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + let known_sessions = values(registry.register_session("validation-session")); + let attacker_sessions = values(registry.register_session("validation-attacker")); + assert_eq!(known_sessions.len(), 1); + assert_eq!(attacker_sessions.len(), 1); + let known = known_sessions[0]; + let attacker = attacker_sessions[0]; + let contexts = values(registry.register_context(known, "validation-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = origins[0].clone(); + let epochs = values(registry.current_epoch(context)); + assert_eq!(epochs.len(), 1); + let epoch = epochs[0]; + + let unknown_sessions = values(BrowserSessionId::new(999)); + assert_eq!(unknown_sessions.len(), 1); + let unknown_handle = values(ObservedNodeHandle::new( + unknown_sessions[0], + context, + origin.clone(), + epoch, + 1, + )); + assert_eq!(unknown_handle.len(), 1); + assert_eq!( + registry.validate_node_handle(&unknown_handle[0]), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let mismatched_handle = values(ObservedNodeHandle::new( + attacker, + context, + origin.clone(), + epoch, + 1, + )); + assert_eq!(mismatched_handle.len(), 1); + assert_eq!( + registry.validate_node_handle(&mismatched_handle[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let unbound_handle = values(ObservedNodeHandle::new( + known, + context, + origin.clone(), + epoch, + 1, + )); + assert_eq!(unbound_handle.len(), 1); + assert_eq!( + registry.validate_node_handle(&unbound_handle[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + assert_eq!(registry.remove_context(context), Ok(())); + assert_eq!( + registry.validate_node_handle(&unbound_handle[0]), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); } #[test] diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 95f8918c7..7a53e62c2 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -165,6 +165,20 @@ fn retired_context_and_session_authority_cannot_be_reused() -> Result<(), Box Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + + registry.remove_context(context)?; + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + Ok(()) +} + #[test] fn context_cannot_be_reused_by_another_session() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); From c84a9c426792bb45981ff189f3682009f0b8a25e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:39:51 -0700 Subject: [PATCH 060/121] fix(core): preserve live browser authority during stack alignment --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 1 + .../originweave-core/src/browser_registry.rs | 93 +++++++++++++++ .../src/browser_registry_coverage.rs | 109 +++++++++++++++++- .../tests/browser_authority_registry.rs | 95 ++++++++++++++- 5 files changed, 297 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99d8d6ee8..f804f7496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: --branch --text --show-missing-lines - | tee missing-lines.txt + > missing-lines.txt - name: Upload exact coverage diagnostics uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index b68faa031..391d51f3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Bound explicit extension-to-Agent grants to a nonzero host-assigned Agent Task identity, so a grant that otherwise matches extension, session, browsing context, origin, expiry, and capability fails closed when reused by a different task. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. +- Added a bounded browser-protocol authority registry that maps opaque session, browsing-context, and node identifiers to registry-local identities, rotates document epochs, and revalidates live node handles before actions. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index aa00d8efa..c0be77766 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -243,6 +243,48 @@ impl BrowserAuthorityRegistry { self.node_by_external.entry(key).or_insert(node_id); Ok(handle) } + + /// Verify that an observed node handle is still live authority in this registry. + /// + /// This check must run immediately before a node-local browser action. It re-derives the + /// current session, context, origin, and document epoch from registry-owned state and also + /// requires the node identifier to remain present in the current document's private external + /// binding table. Caller-supplied or previously retired handles therefore cannot manufacture + /// authority merely by presenting a self-consistent tuple. + pub fn validate_node_handle( + &self, + handle: &ObservedNodeHandle, + ) -> Result<(), BrowserRegistryError> { + if !self.known_sessions.contains(&handle.browser_session()) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let context = handle.browsing_context(); + let expected_session = self + .context_session + .get(&context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + if expected_session != handle.browser_session() { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + let epoch = self.current_epoch(context)?; + let origin = self + .context_origin + .get(&context) + .ok_or(BrowserRegistryError::UnknownNodeAuthority)?; + handle + .validate_current(expected_session, context, origin, epoch) + .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; + let is_bound = self.node_by_external.iter().any( + |((bound_context, bound_epoch, _external_identifier), node_id)| { + *bound_context == context && *bound_epoch == epoch && *node_id == handle.node_id() + }, + ); + if !is_bound { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + Ok(()) + } } impl Default for BrowserAuthorityRegistry { @@ -269,6 +311,8 @@ pub enum BrowserRegistryError { }, /// The context origin changed without first rotating the document epoch. OriginChangedWithoutDocumentAdvance, + /// The observed node handle is not a current node binding owned by this registry. + UnknownNodeAuthority, /// The registry exhausted one of its monotonic internal identifier spaces. IdentifierSpaceExhausted, /// A document epoch reached the maximum representable value. @@ -297,6 +341,8 @@ impl fmt::Display for BrowserRegistryError { ), Self::OriginChangedWithoutDocumentAdvance => formatter .write_str("browsing context origin changed without advancing the document epoch"), + Self::UnknownNodeAuthority => formatter + .write_str("observed node handle is not registered as current browser authority"), Self::IdentifierSpaceExhausted => { formatter.write_str("browser authority identifier space is exhausted") } @@ -426,6 +472,52 @@ mod tests { ); } + #[test] + fn validation_binding_predicate_checks_each_authority_dimension() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("predicate-session")); + let contexts = values(registry.register_context(sessions[0], "first-context")); + let second_contexts = values(registry.register_context(sessions[0], "second-context")); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(sessions.len(), 1); + assert_eq!(contexts.len(), 1); + assert_eq!(second_contexts.len(), 1); + assert_eq!(origins.len(), 1); + + let first = values(registry.bind_node(sessions[0], contexts[0], &origins[0], "first-node")); + let second = + values(registry.bind_node(sessions[0], second_contexts[0], &origins[0], "second-node")); + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_eq!(registry.validate_node_handle(&second[0]), Ok(())); + + let current_epochs = values(registry.current_epoch(contexts[0])); + let future_epochs = values(DocumentEpoch::new(2)); + assert_eq!(current_epochs.len(), 1); + assert_eq!(future_epochs.len(), 1); + registry.node_by_external.insert( + (contexts[0], future_epochs[0], "synthetic-node".to_owned()), + 9_999, + ); + let forged = values(ObservedNodeHandle::new( + sessions[0], + contexts[0], + origins[0].clone(), + current_epochs[0], + 9_999, + )); + assert_eq!(forged.len(), 1); + assert_eq!( + registry.validate_node_handle(&forged[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + registry.context_epoch.remove(&contexts[0]); + assert_eq!( + registry.validate_node_handle(&forged[0]), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + } + #[test] fn monotonic_identifier_exhaustion_is_fail_closed() { let mut next = 1; @@ -617,6 +709,7 @@ mod tests { actual: actual_values[0], }, BrowserRegistryError::OriginChangedWithoutDocumentAdvance, + BrowserRegistryError::UnknownNodeAuthority, BrowserRegistryError::IdentifierSpaceExhausted, BrowserRegistryError::DocumentEpochExhausted, BrowserRegistryError::InternalAuthorityInvariant, diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 1860bc7be..82ac5b6b7 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -1,4 +1,7 @@ -use crate::{BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, Origin}; +use crate::{ + BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, + ObservedNodeHandle, Origin, +}; fn values(result: Result) -> Vec { result.into_iter().collect() @@ -10,6 +13,8 @@ fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { let sessions = values(registry.register_session("unit-session")); assert_eq!(sessions.len(), 1); let session = sessions[0]; + let repeated_sessions = values(registry.register_session("unit-session")); + assert_eq!(repeated_sessions, sessions); let contexts = values(registry.register_context(session, "unit-context")); assert_eq!(contexts.len(), 1); @@ -24,6 +29,108 @@ fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { assert_eq!(first.len(), 1); assert_eq!(repeated.len(), 1); assert_eq!(first[0], repeated[0]); + assert_eq!(registry.validate_node_handle(&first[0]), Ok(())); + + let epochs = values(DocumentEpoch::new(1)); + assert_eq!(epochs.len(), 1); + let forged = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epochs[0], + first[0].node_id() + 1, + )); + assert_eq!(forged.len(), 1); + assert_eq!( + registry.validate_node_handle(&forged[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let mismatched_origins = values(Origin::parse("http://localhost:43127")); + assert_eq!(mismatched_origins.len(), 1); + let mismatched = values(ObservedNodeHandle::new( + session, + context, + mismatched_origins[0].clone(), + epochs[0], + first[0].node_id(), + )); + assert_eq!(mismatched.len(), 1); + assert_eq!( + registry.validate_node_handle(&mismatched[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); +} + +#[test] +fn node_validation_rejects_each_missing_authority_boundary() { + let mut registry = BrowserAuthorityRegistry::new(); + assert_eq!( + registry.register_session(""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + let known_sessions = values(registry.register_session("validation-session")); + let attacker_sessions = values(registry.register_session("validation-attacker")); + assert_eq!(known_sessions.len(), 1); + assert_eq!(attacker_sessions.len(), 1); + let known = known_sessions[0]; + let attacker = attacker_sessions[0]; + let contexts = values(registry.register_context(known, "validation-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = origins[0].clone(); + let epochs = values(registry.current_epoch(context)); + assert_eq!(epochs.len(), 1); + let epoch = epochs[0]; + + let unknown_sessions = values(BrowserSessionId::new(999)); + assert_eq!(unknown_sessions.len(), 1); + let unknown_handle = values(ObservedNodeHandle::new( + unknown_sessions[0], + context, + origin.clone(), + epoch, + 1, + )); + assert_eq!(unknown_handle.len(), 1); + assert_eq!( + registry.validate_node_handle(&unknown_handle[0]), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let mismatched_handle = values(ObservedNodeHandle::new( + attacker, + context, + origin.clone(), + epoch, + 1, + )); + assert_eq!(mismatched_handle.len(), 1); + assert_eq!( + registry.validate_node_handle(&mismatched_handle[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let unbound_handle = values(ObservedNodeHandle::new( + known, + context, + origin.clone(), + epoch, + 1, + )); + assert_eq!(unbound_handle.len(), 1); + assert_eq!( + registry.validate_node_handle(&unbound_handle[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + assert_eq!(registry.remove_context(context), Ok(())); + assert_eq!( + registry.validate_node_handle(&unbound_handle[0]), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); } #[test] diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs index 31bd86a13..7a53e62c2 100644 --- a/crates/originweave-core/tests/browser_authority_registry.rs +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -2,7 +2,7 @@ use std::error::Error; use originweave_core::{ BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, DocumentEpoch, - MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, Origin, + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, ObservedNodeHandle, Origin, }; fn loopback_origin() -> Result> { @@ -65,6 +65,10 @@ fn public_default_and_error_contracts_are_usable_from_an_adapter() -> Result<(), BrowserRegistryError::OriginChangedWithoutDocumentAdvance, "browsing context origin changed without advancing the document epoch".to_owned(), ), + ( + BrowserRegistryError::UnknownNodeAuthority, + "observed node handle is not registered as current browser authority".to_owned(), + ), ( BrowserRegistryError::IdentifierSpaceExhausted, "browser authority identifier space is exhausted".to_owned(), @@ -161,6 +165,20 @@ fn retired_context_and_session_authority_cannot_be_reused() -> Result<(), Box Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + + registry.remove_context(context)?; + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + Ok(()) +} + #[test] fn context_cannot_be_reused_by_another_session() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); @@ -285,3 +303,78 @@ fn unknown_internal_authority_is_rejected_before_node_binding() -> Result<(), Bo ); Ok(()) } + +#[test] +fn registry_revalidates_live_node_authority_and_rejects_forged_or_retired_handles() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let other = registry.register_session("other-session")?; + let context = registry.register_context(owner, "top-level-context")?; + let origin = loopback_origin()?; + let live = registry.bind_node(owner, context, &origin, "backend-node-17")?; + + assert_eq!(registry.validate_node_handle(&live), Ok(())); + + let forged_node = ObservedNodeHandle::new( + owner, + context, + origin.clone(), + live.document_epoch(), + live.node_id() + 1, + )?; + assert_eq!( + registry.validate_node_handle(&forged_node), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let wrong_session = ObservedNodeHandle::new( + other, + context, + origin.clone(), + live.document_epoch(), + live.node_id(), + )?; + assert_eq!( + registry.validate_node_handle(&wrong_session), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let unbound_context = registry.register_context(owner, "unbound-context")?; + let synthetic_unbound = ObservedNodeHandle::new( + owner, + unbound_context, + origin.clone(), + DocumentEpoch::new(1)?, + 777, + )?; + assert_eq!( + registry.validate_node_handle(&synthetic_unbound), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let next_epoch = registry.advance_document(context)?; + assert_eq!(next_epoch.value(), 2); + assert_eq!( + registry.validate_node_handle(&live), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let replacement = registry.bind_node(owner, context, &origin, "backend-node-17")?; + assert_eq!(registry.validate_node_handle(&replacement), Ok(())); + + registry.remove_context(context)?; + assert_eq!( + registry.validate_node_handle(&replacement), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + let session_context = registry.register_context(owner, "session-retirement")?; + let session_handle = registry.bind_node(owner, session_context, &origin, "session-node")?; + registry.remove_session(owner)?; + assert_eq!( + registry.validate_node_handle(&session_handle), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + Ok(()) +} From 0553c69c7880f13ec66664bb0b41767c40bc66d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:41:19 -0700 Subject: [PATCH 061/121] test(core): require registry authority for semantic observations --- ...semantic_observation_registry_authority.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 crates/originweave-core/tests/semantic_observation_registry_authority.rs diff --git a/crates/originweave-core/tests/semantic_observation_registry_authority.rs b/crates/originweave-core/tests/semantic_observation_registry_authority.rs new file mode 100644 index 000000000..bb6e4ee15 --- /dev/null +++ b/crates/originweave-core/tests/semantic_observation_registry_authority.rs @@ -0,0 +1,72 @@ +use std::collections::BTreeSet; + +use originweave_core::{ + BrowserAuthorityRegistry, NodeActionKind, ObservationChannel, ObservedNodeHandle, Origin, + SemanticNodeObservation, SemanticNodeObservationError, SemanticNodeObservationInput, +}; + +fn bound_observation_fixture() -> Result< + (BrowserAuthorityRegistry, ObservedNodeHandle), + Box, +> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("semantic-session")?; + let context = registry.register_context(session, "semantic-context")?; + let origin = Origin::parse("https://example.com")?; + let handle = registry.bind_node(session, context, &origin, "semantic-node")?; + Ok((registry, handle)) +} + +fn input(handle: ObservedNodeHandle) -> SemanticNodeObservationInput { + SemanticNodeObservationInput { + handle, + parent: None, + children: Vec::new(), + role: "button".to_owned(), + accessible_name: "Submit".to_owned(), + visible_text: None, + enabled: true, + visible: true, + selected: None, + supported_actions: BTreeSet::from([NodeActionKind::Click]), + evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]), + } +} + +#[test] +fn semantic_observation_rejects_forged_primary_node_authority() -> Result<(), Box> { + let (registry, bound) = bound_observation_fixture()?; + let forged = ObservedNodeHandle::new( + bound.browser_session(), + bound.browsing_context(), + bound.origin().clone(), + bound.document_epoch(), + bound.node_id() + 10_000, + )?; + + assert_eq!( + SemanticNodeObservation::new(input(forged), ®istry).err(), + Some(SemanticNodeObservationError::UnknownNodeAuthority) + ); + Ok(()) +} + +#[test] +fn semantic_observation_rejects_forged_related_node_authority() -> Result<(), Box> { + let (registry, bound) = bound_observation_fixture()?; + let forged_child = ObservedNodeHandle::new( + bound.browser_session(), + bound.browsing_context(), + bound.origin().clone(), + bound.document_epoch(), + bound.node_id() + 10_000, + )?; + let mut observation_input = input(bound); + observation_input.children.push(forged_child); + + assert_eq!( + SemanticNodeObservation::new(observation_input, ®istry).err(), + Some(SemanticNodeObservationError::UnknownNodeAuthority) + ); + Ok(()) +} From 1f5732807a60022e1666e104be51704fad54812d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:42:53 -0700 Subject: [PATCH 062/121] style(core): apply canonical formatting to semantic authority regression --- .../tests/semantic_observation_registry_authority.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/tests/semantic_observation_registry_authority.rs b/crates/originweave-core/tests/semantic_observation_registry_authority.rs index bb6e4ee15..12b9f4c9f 100644 --- a/crates/originweave-core/tests/semantic_observation_registry_authority.rs +++ b/crates/originweave-core/tests/semantic_observation_registry_authority.rs @@ -5,10 +5,8 @@ use originweave_core::{ SemanticNodeObservation, SemanticNodeObservationError, SemanticNodeObservationInput, }; -fn bound_observation_fixture() -> Result< - (BrowserAuthorityRegistry, ObservedNodeHandle), - Box, -> { +fn bound_observation_fixture() +-> Result<(BrowserAuthorityRegistry, ObservedNodeHandle), Box> { let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session("semantic-session")?; let context = registry.register_context(session, "semantic-context")?; @@ -34,7 +32,8 @@ fn input(handle: ObservedNodeHandle) -> SemanticNodeObservationInput { } #[test] -fn semantic_observation_rejects_forged_primary_node_authority() -> Result<(), Box> { +fn semantic_observation_rejects_forged_primary_node_authority() +-> Result<(), Box> { let (registry, bound) = bound_observation_fixture()?; let forged = ObservedNodeHandle::new( bound.browser_session(), @@ -52,7 +51,8 @@ fn semantic_observation_rejects_forged_primary_node_authority() -> Result<(), Bo } #[test] -fn semantic_observation_rejects_forged_related_node_authority() -> Result<(), Box> { +fn semantic_observation_rejects_forged_related_node_authority() +-> Result<(), Box> { let (registry, bound) = bound_observation_fixture()?; let forged_child = ObservedNodeHandle::new( bound.browser_session(), From 64f503bd11e368fefc7db4ad15f4724f8625d6f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:46:00 -0700 Subject: [PATCH 063/121] fix(core): require live registry authority for semantic observations --- .../src/semantic_observation.rs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/semantic_observation.rs b/crates/originweave-core/src/semantic_observation.rs index 950cff6db..5140cb0fd 100644 --- a/crates/originweave-core/src/semantic_observation.rs +++ b/crates/originweave-core/src/semantic_observation.rs @@ -1,7 +1,7 @@ use std::collections::BTreeSet; use std::fmt; -use crate::ObservedNodeHandle; +use crate::{BrowserAuthorityRegistry, ObservedNodeHandle}; /// Maximum UTF-8 byte length retained for one semantic node role. pub const MAX_SEMANTIC_ROLE_BYTES: usize = 64; @@ -95,7 +95,14 @@ pub struct SemanticNodeObservation { impl SemanticNodeObservation { /// Validate reviewed text, relationship, authority, and provenance bounds. - pub fn new(input: SemanticNodeObservationInput) -> Result { + /// + /// Every primary or related node handle must still be live authority owned by + /// `registry`. Caller-constructed, retired, stale, or otherwise unbound handles + /// fail closed before page-derived semantic metadata can become an observation. + pub fn new( + input: SemanticNodeObservationInput, + registry: &BrowserAuthorityRegistry, + ) -> Result { if input.role.is_empty() { return Err(SemanticNodeObservationError::EmptyRole); } @@ -118,10 +125,13 @@ impl SemanticNodeObservation { if input.children.len() > MAX_SEMANTIC_CHILDREN { return Err(SemanticNodeObservationError::TooManyChildren); } + validate_live_node(registry, &input.handle)?; if let Some(parent) = input.parent.as_ref() { + validate_live_node(registry, parent)?; validate_relationship(&input.handle, parent)?; } for (index, child) in input.children.iter().enumerate() { + validate_live_node(registry, child)?; validate_relationship(&input.handle, child)?; if input.children[..index].contains(child) { return Err(SemanticNodeObservationError::DuplicateChild); @@ -209,6 +219,15 @@ impl SemanticNodeObservation { } } +fn validate_live_node( + registry: &BrowserAuthorityRegistry, + handle: &ObservedNodeHandle, +) -> Result<(), SemanticNodeObservationError> { + registry + .validate_node_handle(handle) + .map_err(|_error| SemanticNodeObservationError::UnknownNodeAuthority) +} + fn validate_relationship( handle: &ObservedNodeHandle, related: &ObservedNodeHandle, @@ -241,6 +260,8 @@ pub enum SemanticNodeObservationError { MissingEvidenceChannel, /// The child relationship list exceeded [`MAX_SEMANTIC_CHILDREN`]. TooManyChildren, + /// A supplied node handle is not current authority owned by the active browser registry. + UnknownNodeAuthority, /// A relationship crossed the observation's session, context, origin, or document authority. RelationshipAuthorityMismatch, /// The observation attempted to relate the node to itself. @@ -265,6 +286,9 @@ impl fmt::Display for SemanticNodeObservationError { Self::TooManyChildren => { formatter.write_str("semantic node observation exceeds 128 child relationships") } + Self::UnknownNodeAuthority => formatter.write_str( + "semantic node observation contains node authority not owned by the active browser registry", + ), Self::RelationshipAuthorityMismatch => formatter.write_str( "semantic node relationship crosses its session, context, origin, or document authority", ), From 575515dc885e4c78b2203661e2d1c6700755cbd0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:46:48 -0700 Subject: [PATCH 064/121] test(core): use live registry authority in semantic observation suite --- .../tests/semantic_node_observation.rs | 399 ++++++++++-------- 1 file changed, 219 insertions(+), 180 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs index 0e75d698f..59c24a9b5 100644 --- a/crates/originweave-core/tests/semantic_node_observation.rs +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -1,160 +1,187 @@ use std::collections::BTreeSet; use originweave_core::{ - BrowserSessionId, BrowsingContextId, DocumentEpoch, MAX_ACCESSIBLE_NAME_BYTES, + BrowserAuthorityRegistry, BrowserSessionId, BrowsingContextId, MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES, MAX_VISIBLE_TEXT_BYTES, NodeActionKind, ObservationChannel, ObservedNodeHandle, Origin, SemanticNodeObservation, SemanticNodeObservationError, SemanticNodeObservationInput, }; -fn observed_node_with_authority( - browser_session_id: u64, - browsing_context_id: u64, - origin_value: &str, - document_epoch_value: u64, - node_id: u64, -) -> Result { - let browser_session = - BrowserSessionId::new(browser_session_id).map_err(|error| error.to_string())?; - let browsing_context = - BrowsingContextId::new(browsing_context_id).map_err(|error| error.to_string())?; - let origin = Origin::parse(origin_value).map_err(|error| format!("{error:?}"))?; - let document_epoch = - DocumentEpoch::new(document_epoch_value).map_err(|error| error.to_string())?; - ObservedNodeHandle::new( - browser_session, - browsing_context, - origin, - document_epoch, - node_id, - ) - .map_err(|error| error.to_string()) +struct Fixture { + registry: BrowserAuthorityRegistry, + session: BrowserSessionId, + context: BrowsingContextId, + origin: Origin, + next_external: u64, } -fn observed_node_with_id(node_id: u64) -> Result { - observed_node_with_authority(7, 11, "https://example.com", 3, node_id) -} +impl Fixture { + fn new() -> Result { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry + .register_session("semantic-session") + .map_err(|error| error.to_string())?; + let context = registry + .register_context(session, "semantic-context") + .map_err(|error| error.to_string())?; + let origin = Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?; + Ok(Self { + registry, + session, + context, + origin, + next_external: 1, + }) + } -fn observed_node() -> Result { - observed_node_with_id(17) -} + fn bind_named(&mut self, external_identifier: &str) -> Result { + self.registry + .bind_node( + self.session, + self.context, + &self.origin, + external_identifier, + ) + .map_err(|error| error.to_string()) + } + + fn bind_next(&mut self) -> Result { + let external_identifier = format!("semantic-node-{}", self.next_external); + self.next_external += 1; + self.bind_named(&external_identifier) + } -fn semantic_input( - role: String, - accessible_name: String, - visible_text: Option, -) -> Result { - Ok(SemanticNodeObservationInput { - handle: observed_node()?, - parent: None, - children: Vec::new(), - role, - accessible_name, - visible_text, - enabled: true, - visible: true, - selected: None, - supported_actions: BTreeSet::from([NodeActionKind::Click, NodeActionKind::TypeText]), - evidence_channels: BTreeSet::from([ - ObservationChannel::Accessibility, - ObservationChannel::Dom, - ]), - }) + fn input( + &mut self, + role: String, + accessible_name: String, + visible_text: Option, + ) -> Result { + Ok(SemanticNodeObservationInput { + handle: self.bind_next()?, + parent: None, + children: Vec::new(), + role, + accessible_name, + visible_text, + enabled: true, + visible: true, + selected: None, + supported_actions: BTreeSet::from([ + NodeActionKind::Click, + NodeActionKind::TypeText, + ]), + evidence_channels: BTreeSet::from([ + ObservationChannel::Accessibility, + ObservationChannel::Dom, + ]), + }) + } } #[test] -fn semantic_node_preserves_authority_and_bounded_surface() -> Result<(), String> { - let input = semantic_input( +fn semantic_node_preserves_live_authority_and_bounded_surface() -> Result<(), String> { + let mut fixture = Fixture::new()?; + let mut input = fixture.input( "textbox".to_owned(), "Email address".to_owned(), Some("name@example.test".to_owned()), )?; let handle = input.handle.clone(); - let observation = SemanticNodeObservation::new(input).map_err(|error| error.to_string())?; + let parent = fixture.bind_next()?; + let first_child = fixture.bind_next()?; + let second_child = fixture.bind_next()?; + input.parent = Some(parent.clone()); + input.children = vec![first_child.clone(), second_child.clone()]; + input.selected = Some(false); + + let observation = SemanticNodeObservation::new(input, &fixture.registry) + .map_err(|error| error.to_string())?; assert_eq!(observation.handle(), &handle); - assert_eq!(observation.parent(), None); - assert!(observation.children().is_empty()); + assert_eq!(observation.parent(), Some(&parent)); + assert_eq!(observation.children(), &[first_child, second_child]); assert_eq!(observation.role(), "textbox"); assert_eq!(observation.accessible_name(), "Email address"); assert_eq!(observation.visible_text(), Some("name@example.test")); assert!(observation.is_enabled()); assert!(observation.is_visible()); - assert_eq!(observation.is_selected(), None); + assert_eq!(observation.is_selected(), Some(false)); assert_eq!( observation.supported_actions(), &BTreeSet::from([NodeActionKind::Click, NodeActionKind::TypeText]) ); assert_eq!( observation.evidence_channels(), - &BTreeSet::from([ObservationChannel::Accessibility, ObservationChannel::Dom,]) + &BTreeSet::from([ObservationChannel::Accessibility, ObservationChannel::Dom]) ); Ok(()) } -#[test] -fn semantic_node_preserves_bounded_authority_scoped_relationships() -> Result<(), String> { - let parent = observed_node_with_id(16)?; - let first_child = observed_node_with_id(18)?; - let second_child = observed_node_with_id(19)?; - let mut input = semantic_input("group".to_owned(), "Account".to_owned(), None)?; - input.parent = Some(parent.clone()); - input.children = vec![first_child.clone(), second_child.clone()]; - - let observation = SemanticNodeObservation::new(input).map_err(|error| error.to_string())?; - assert_eq!(observation.parent(), Some(&parent)); - assert_eq!(observation.children(), &[first_child, second_child]); - Ok(()) -} - #[test] fn semantic_node_bounds_child_relationship_count() -> Result<(), String> { - let mut boundary = semantic_input("list".to_owned(), "Items".to_owned(), None)?; - boundary.children = (0..MAX_SEMANTIC_CHILDREN) - .map(|offset| observed_node_with_id(100 + offset as u64)) - .collect::, _>>()?; - let observation = SemanticNodeObservation::new(boundary).map_err(|error| error.to_string())?; + let mut fixture = Fixture::new()?; + let mut boundary = fixture.input("list".to_owned(), "Items".to_owned(), None)?; + let mut children = Vec::with_capacity(MAX_SEMANTIC_CHILDREN); + for _ in 0..MAX_SEMANTIC_CHILDREN { + children.push(fixture.bind_next()?); + } + boundary.children = children; + let observation = SemanticNodeObservation::new(boundary, &fixture.registry) + .map_err(|error| error.to_string())?; assert_eq!(observation.children().len(), MAX_SEMANTIC_CHILDREN); - let mut overflow = semantic_input("list".to_owned(), "Items".to_owned(), None)?; - overflow.children = (0..=MAX_SEMANTIC_CHILDREN) - .map(|offset| observed_node_with_id(1_000 + offset as u64)) - .collect::, _>>()?; + let mut overflow = fixture.input("list".to_owned(), "Items".to_owned(), None)?; + overflow.children = vec![overflow.handle.clone(); MAX_SEMANTIC_CHILDREN + 1]; assert_eq!( - SemanticNodeObservation::new(overflow).err(), + SemanticNodeObservation::new(overflow, &fixture.registry).err(), Some(SemanticNodeObservationError::TooManyChildren) ); Ok(()) } #[test] -fn semantic_node_rejects_each_relationship_authority_axis() -> Result<(), String> { - let mismatched_parents = [ - observed_node_with_authority(8, 11, "https://example.com", 3, 16)?, - observed_node_with_authority(7, 12, "https://example.com", 3, 16)?, - observed_node_with_authority(7, 11, "https://other.example", 3, 16)?, - observed_node_with_authority(7, 11, "https://example.com", 4, 16)?, - ]; - - for parent in mismatched_parents { - let mut input = semantic_input("group".to_owned(), "Account".to_owned(), None)?; - input.parent = Some(parent); - assert_eq!( - SemanticNodeObservation::new(input).err(), - Some(SemanticNodeObservationError::RelationshipAuthorityMismatch) - ); - } +fn semantic_node_rejects_live_relationships_from_other_authority() -> Result<(), String> { + let mut fixture = Fixture::new()?; + let mut parent_input = fixture.input("group".to_owned(), "Account".to_owned(), None)?; + let other_context = fixture + .registry + .register_context(fixture.session, "other-context") + .map_err(|error| error.to_string())?; + let origin = fixture.origin.clone(); + let other_context_node = fixture + .registry + .bind_node(fixture.session, other_context, &origin, "other-context-node") + .map_err(|error| error.to_string())?; + parent_input.parent = Some(other_context_node); + assert_eq!( + SemanticNodeObservation::new(parent_input, &fixture.registry).err(), + Some(SemanticNodeObservationError::RelationshipAuthorityMismatch) + ); - let mut child_input = semantic_input("group".to_owned(), "Account".to_owned(), None)?; - child_input.children = vec![observed_node_with_authority( - 7, - 11, - "https://other.example", - 3, - 18, - )?]; + let mut child_input = fixture.input("group".to_owned(), "Account".to_owned(), None)?; + let other_session = fixture + .registry + .register_session("other-session") + .map_err(|error| error.to_string())?; + let other_session_context = fixture + .registry + .register_context(other_session, "other-session-context") + .map_err(|error| error.to_string())?; + let other_origin = Origin::parse("https://other.example") + .map_err(|error| format!("{error:?}"))?; + let other_session_node = fixture + .registry + .bind_node( + other_session, + other_session_context, + &other_origin, + "other-session-node", + ) + .map_err(|error| error.to_string())?; + child_input.children = vec![other_session_node]; assert_eq!( - SemanticNodeObservation::new(child_input).err(), + SemanticNodeObservation::new(child_input, &fixture.registry).err(), Some(SemanticNodeObservationError::RelationshipAuthorityMismatch) ); Ok(()) @@ -162,25 +189,26 @@ fn semantic_node_rejects_each_relationship_authority_axis() -> Result<(), String #[test] fn semantic_node_rejects_self_and_duplicate_child_relationships() -> Result<(), String> { - let mut self_parent = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + let mut fixture = Fixture::new()?; + let mut self_parent = fixture.input("group".to_owned(), "Account".to_owned(), None)?; self_parent.parent = Some(self_parent.handle.clone()); assert_eq!( - SemanticNodeObservation::new(self_parent).err(), + SemanticNodeObservation::new(self_parent, &fixture.registry).err(), Some(SemanticNodeObservationError::SelfRelationship) ); - let mut self_child = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + let mut self_child = fixture.input("group".to_owned(), "Account".to_owned(), None)?; self_child.children = vec![self_child.handle.clone()]; assert_eq!( - SemanticNodeObservation::new(self_child).err(), + SemanticNodeObservation::new(self_child, &fixture.registry).err(), Some(SemanticNodeObservationError::SelfRelationship) ); - let child = observed_node_with_id(18)?; - let mut duplicate = semantic_input("group".to_owned(), "Account".to_owned(), None)?; + let child = fixture.bind_next()?; + let mut duplicate = fixture.input("group".to_owned(), "Account".to_owned(), None)?; duplicate.children = vec![child.clone(), child]; assert_eq!( - SemanticNodeObservation::new(duplicate).err(), + SemanticNodeObservation::new(duplicate, &fixture.registry).err(), Some(SemanticNodeObservationError::DuplicateChild) ); Ok(()) @@ -188,12 +216,14 @@ fn semantic_node_rejects_self_and_duplicate_child_relationships() -> Result<(), #[test] fn reviewed_text_bounds_are_inclusive_and_visible_text_is_optional() -> Result<(), String> { - let boundary = SemanticNodeObservation::new(semantic_input( + let mut fixture = Fixture::new()?; + let boundary_input = fixture.input( "r".repeat(MAX_SEMANTIC_ROLE_BYTES), "n".repeat(MAX_ACCESSIBLE_NAME_BYTES), Some("v".repeat(MAX_VISIBLE_TEXT_BYTES)), - )?) - .map_err(|error| error.to_string())?; + )?; + let boundary = SemanticNodeObservation::new(boundary_input, &fixture.registry) + .map_err(|error| error.to_string())?; assert_eq!(boundary.role().len(), MAX_SEMANTIC_ROLE_BYTES); assert_eq!(boundary.accessible_name().len(), MAX_ACCESSIBLE_NAME_BYTES); assert_eq!( @@ -201,59 +231,58 @@ fn reviewed_text_bounds_are_inclusive_and_visible_text_is_optional() -> Result<( Some(MAX_VISIBLE_TEXT_BYTES) ); - let without_text = - SemanticNodeObservation::new(semantic_input("button".to_owned(), String::new(), None)?) - .map_err(|error| error.to_string())?; + let without_text_input = fixture.input("button".to_owned(), String::new(), None)?; + let without_text = SemanticNodeObservation::new(without_text_input, &fixture.registry) + .map_err(|error| error.to_string())?; assert_eq!(without_text.visible_text(), None); Ok(()) } #[test] -fn semantic_node_requires_observation_provenance() -> Result<(), String> { - let mut input = semantic_input("button".to_owned(), "Submit".to_owned(), None)?; - input.evidence_channels.clear(); +fn semantic_node_rejects_missing_provenance_and_unbounded_text() -> Result<(), String> { + let mut fixture = Fixture::new()?; - let error = SemanticNodeObservation::new(input).err(); + let mut missing_provenance = + fixture.input("button".to_owned(), "Submit".to_owned(), None)?; + missing_provenance.evidence_channels.clear(); assert_eq!( - error, + SemanticNodeObservation::new(missing_provenance, &fixture.registry).err(), Some(SemanticNodeObservationError::MissingEvidenceChannel) ); - Ok(()) -} -#[test] -fn semantic_node_rejects_unbounded_or_missing_role_text() -> Result<(), String> { - let empty_role = - SemanticNodeObservation::new(semantic_input(String::new(), "name".to_owned(), None)?).err(); - assert_eq!(empty_role, Some(SemanticNodeObservationError::EmptyRole)); + let empty_role = fixture.input(String::new(), "name".to_owned(), None)?; + assert_eq!( + SemanticNodeObservation::new(empty_role, &fixture.registry).err(), + Some(SemanticNodeObservationError::EmptyRole) + ); - let long_role = SemanticNodeObservation::new(semantic_input( + let long_role = fixture.input( "r".repeat(MAX_SEMANTIC_ROLE_BYTES + 1), "name".to_owned(), None, - )?) - .err(); - assert_eq!(long_role, Some(SemanticNodeObservationError::RoleTooLong)); + )?; + assert_eq!( + SemanticNodeObservation::new(long_role, &fixture.registry).err(), + Some(SemanticNodeObservationError::RoleTooLong) + ); - let long_name = SemanticNodeObservation::new(semantic_input( + let long_name = fixture.input( "button".to_owned(), "n".repeat(MAX_ACCESSIBLE_NAME_BYTES + 1), None, - )?) - .err(); + )?; assert_eq!( - long_name, + SemanticNodeObservation::new(long_name, &fixture.registry).err(), Some(SemanticNodeObservationError::AccessibleNameTooLong) ); - let long_visible_text = SemanticNodeObservation::new(semantic_input( + let long_visible_text = fixture.input( "button".to_owned(), "name".to_owned(), Some("v".repeat(MAX_VISIBLE_TEXT_BYTES + 1)), - )?) - .err(); + )?; assert_eq!( - long_visible_text, + SemanticNodeObservation::new(long_visible_text, &fixture.registry).err(), Some(SemanticNodeObservationError::VisibleTextTooLong) ); Ok(()) @@ -261,40 +290,50 @@ fn semantic_node_rejects_unbounded_or_missing_role_text() -> Result<(), String> #[test] fn semantic_node_errors_are_stable_and_credential_free() { - assert_eq!( - SemanticNodeObservationError::EmptyRole.to_string(), - "semantic node role must not be empty" - ); - assert_eq!( - SemanticNodeObservationError::RoleTooLong.to_string(), - "semantic node role exceeds 64 UTF-8 bytes" - ); - assert_eq!( - SemanticNodeObservationError::AccessibleNameTooLong.to_string(), - "semantic node accessible name exceeds 512 UTF-8 bytes" - ); - assert_eq!( - SemanticNodeObservationError::VisibleTextTooLong.to_string(), - "semantic node visible text exceeds 4096 UTF-8 bytes" - ); - assert_eq!( - SemanticNodeObservationError::MissingEvidenceChannel.to_string(), - "semantic node observation requires at least one evidence channel" - ); - assert_eq!( - SemanticNodeObservationError::TooManyChildren.to_string(), - "semantic node observation exceeds 128 child relationships" - ); - assert_eq!( - SemanticNodeObservationError::RelationshipAuthorityMismatch.to_string(), - "semantic node relationship crosses its session, context, origin, or document authority" - ); - assert_eq!( - SemanticNodeObservationError::SelfRelationship.to_string(), - "semantic node observation cannot relate the node to itself" - ); - assert_eq!( - SemanticNodeObservationError::DuplicateChild.to_string(), - "semantic node observation contains a duplicate child relationship" - ); + let expected = [ + ( + SemanticNodeObservationError::EmptyRole, + "semantic node role must not be empty", + ), + ( + SemanticNodeObservationError::RoleTooLong, + "semantic node role exceeds 64 UTF-8 bytes", + ), + ( + SemanticNodeObservationError::AccessibleNameTooLong, + "semantic node accessible name exceeds 512 UTF-8 bytes", + ), + ( + SemanticNodeObservationError::VisibleTextTooLong, + "semantic node visible text exceeds 4096 UTF-8 bytes", + ), + ( + SemanticNodeObservationError::MissingEvidenceChannel, + "semantic node observation requires at least one evidence channel", + ), + ( + SemanticNodeObservationError::TooManyChildren, + "semantic node observation exceeds 128 child relationships", + ), + ( + SemanticNodeObservationError::UnknownNodeAuthority, + "semantic node observation contains node authority not owned by the active browser registry", + ), + ( + SemanticNodeObservationError::RelationshipAuthorityMismatch, + "semantic node relationship crosses its session, context, origin, or document authority", + ), + ( + SemanticNodeObservationError::SelfRelationship, + "semantic node observation cannot relate the node to itself", + ), + ( + SemanticNodeObservationError::DuplicateChild, + "semantic node observation contains a duplicate child relationship", + ), + ]; + + for (error, message) in expected { + assert_eq!(error.to_string(), message); + } } From f71c5c340e043c54ad6cb512c5b02ad1c8620cf4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:48:30 -0700 Subject: [PATCH 065/121] style(core): apply canonical semantic observation formatting --- .../tests/semantic_node_observation.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_observation.rs b/crates/originweave-core/tests/semantic_node_observation.rs index 59c24a9b5..6466abab1 100644 --- a/crates/originweave-core/tests/semantic_node_observation.rs +++ b/crates/originweave-core/tests/semantic_node_observation.rs @@ -67,10 +67,7 @@ impl Fixture { enabled: true, visible: true, selected: None, - supported_actions: BTreeSet::from([ - NodeActionKind::Click, - NodeActionKind::TypeText, - ]), + supported_actions: BTreeSet::from([NodeActionKind::Click, NodeActionKind::TypeText]), evidence_channels: BTreeSet::from([ ObservationChannel::Accessibility, ObservationChannel::Dom, @@ -151,7 +148,12 @@ fn semantic_node_rejects_live_relationships_from_other_authority() -> Result<(), let origin = fixture.origin.clone(); let other_context_node = fixture .registry - .bind_node(fixture.session, other_context, &origin, "other-context-node") + .bind_node( + fixture.session, + other_context, + &origin, + "other-context-node", + ) .map_err(|error| error.to_string())?; parent_input.parent = Some(other_context_node); assert_eq!( @@ -168,8 +170,8 @@ fn semantic_node_rejects_live_relationships_from_other_authority() -> Result<(), .registry .register_context(other_session, "other-session-context") .map_err(|error| error.to_string())?; - let other_origin = Origin::parse("https://other.example") - .map_err(|error| format!("{error:?}"))?; + let other_origin = + Origin::parse("https://other.example").map_err(|error| format!("{error:?}"))?; let other_session_node = fixture .registry .bind_node( @@ -242,8 +244,7 @@ fn reviewed_text_bounds_are_inclusive_and_visible_text_is_optional() -> Result<( fn semantic_node_rejects_missing_provenance_and_unbounded_text() -> Result<(), String> { let mut fixture = Fixture::new()?; - let mut missing_provenance = - fixture.input("button".to_owned(), "Submit".to_owned(), None)?; + let mut missing_provenance = fixture.input("button".to_owned(), "Submit".to_owned(), None)?; missing_provenance.evidence_channels.clear(); assert_eq!( SemanticNodeObservation::new(missing_provenance, &fixture.registry).err(), From 957e52c4f80bc2aa1b40dede7deed1e2642bf71a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:53:24 -0700 Subject: [PATCH 066/121] test(core): cover forged parent observation authority --- ...semantic_observation_registry_authority.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/originweave-core/tests/semantic_observation_registry_authority.rs b/crates/originweave-core/tests/semantic_observation_registry_authority.rs index 12b9f4c9f..ea7877674 100644 --- a/crates/originweave-core/tests/semantic_observation_registry_authority.rs +++ b/crates/originweave-core/tests/semantic_observation_registry_authority.rs @@ -50,6 +50,27 @@ fn semantic_observation_rejects_forged_primary_node_authority() Ok(()) } +#[test] +fn semantic_observation_rejects_forged_parent_node_authority() +-> Result<(), Box> { + let (registry, bound) = bound_observation_fixture()?; + let forged_parent = ObservedNodeHandle::new( + bound.browser_session(), + bound.browsing_context(), + bound.origin().clone(), + bound.document_epoch(), + bound.node_id() + 10_000, + )?; + let mut observation_input = input(bound); + observation_input.parent = Some(forged_parent); + + assert_eq!( + SemanticNodeObservation::new(observation_input, ®istry).err(), + Some(SemanticNodeObservationError::UnknownNodeAuthority) + ); + Ok(()) +} + #[test] fn semantic_observation_rejects_forged_related_node_authority() -> Result<(), Box> { From 0e879287622bb705f1d9d2a594a9b85925f61327 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:32:56 -0700 Subject: [PATCH 067/121] fix(core): remove unreachable relationship coverage branches --- crates/originweave-core/src/semantic_observation.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/semantic_observation.rs b/crates/originweave-core/src/semantic_observation.rs index 5140cb0fd..3a7fffbaf 100644 --- a/crates/originweave-core/src/semantic_observation.rs +++ b/crates/originweave-core/src/semantic_observation.rs @@ -235,10 +235,12 @@ fn validate_relationship( if handle == related { return Err(SemanticNodeObservationError::SelfRelationship); } + // Both handles have already passed `validate_live_node` against the same registry. A live + // browsing context has exactly one current origin and document epoch, so matching session and + // context authority necessarily implies matching origin and epoch. Rechecking those implied + // dimensions would create unreachable branch states rather than additional defense in depth. if handle.browser_session() != related.browser_session() || handle.browsing_context() != related.browsing_context() - || handle.origin() != related.origin() - || handle.document_epoch() != related.document_epoch() { return Err(SemanticNodeObservationError::RelationshipAuthorityMismatch); } From 5e8c2542bba7b42a5db346028dd55e3ad91f6e1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:40:05 -0700 Subject: [PATCH 068/121] docs(changelog): retain semantic query contract after stack realignment --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 391d51f3a..4ad884ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. - Authority-bound, bounded semantic node observations with typed node-local action evidence and explicit observation-channel provenance for the first Chromium vertical slice; observation metadata grants no execution authority. +- Bounded typed semantic node queries over reviewed role, accessible-name, and node-action evidence, without exposing raw DOM/protocol selector languages or granting execution authority. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. From 7656d9823b4f6e09d2a12ef4a205af792ecd31cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:46:26 -0700 Subject: [PATCH 069/121] fix(core): revalidate action targets against live registry --- .../src/semantic_action_target.rs | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/crates/originweave-core/src/semantic_action_target.rs b/crates/originweave-core/src/semantic_action_target.rs index 85fe59d29..6d4a1a418 100644 --- a/crates/originweave-core/src/semantic_action_target.rs +++ b/crates/originweave-core/src/semantic_action_target.rs @@ -1,8 +1,8 @@ use std::fmt; use crate::{ - BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeActionKind, NodeHandleError, - ObservedNodeHandle, Origin, SemanticNodeObservation, + BrowserAuthorityRegistry, BrowserRegistryError, NodeActionKind, ObservedNodeHandle, + SemanticNodeObservation, }; /// One node-local action bound to the exact browser authority that produced its observation. @@ -42,20 +42,15 @@ impl SemanticNodeActionTarget { self.action } - /// Revalidate session, context, origin, and document authority immediately before later use. + /// Revalidate this target against current registry-owned browser authority before later use. + /// + /// The exact node binding must still be live in `registry`; a caller cannot revive a retired + /// or stale target merely by presenting a self-consistent session/context/origin/epoch tuple. pub fn validate_current( &self, - current_session: BrowserSessionId, - current_context: BrowsingContextId, - current_origin: &Origin, - current_epoch: DocumentEpoch, - ) -> Result<(), NodeHandleError> { - self.handle.validate_current( - current_session, - current_context, - current_origin, - current_epoch, - ) + registry: &BrowserAuthorityRegistry, + ) -> Result<(), BrowserRegistryError> { + registry.validate_node_handle(&self.handle) } } From 4daeb267234922d5ce1dcee6a3bb2f45128f6461 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:49:06 -0700 Subject: [PATCH 070/121] docs(changelog): record registry-live action targets --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ad884ac3..866a81a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. - Authority-bound, bounded semantic node observations with typed node-local action evidence and explicit observation-channel provenance for the first Chromium vertical slice; observation metadata grants no execution authority. - Bounded typed semantic node queries over reviewed role, accessible-name, and node-action evidence, without exposing raw DOM/protocol selector languages or granting execution authority. +- Authority-bound semantic node action targets accept only observation-advertised node-local actions and revalidate the exact node binding against the live browser authority registry before later use, so retired or stale handles cannot be revived by caller-supplied authority tuples. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. From 1d9e7c5c936a1e149f188b663bcf5e0f365844fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 19:40:36 -0700 Subject: [PATCH 071/121] test(browser): reject cross-registry node-handle reuse --- .../tests/browser_registry_cross_instance.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 crates/originweave-core/tests/browser_registry_cross_instance.rs diff --git a/crates/originweave-core/tests/browser_registry_cross_instance.rs b/crates/originweave-core/tests/browser_registry_cross_instance.rs new file mode 100644 index 000000000..41afd37f3 --- /dev/null +++ b/crates/originweave-core/tests/browser_registry_cross_instance.rs @@ -0,0 +1,31 @@ +use std::error::Error; + +use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, Origin}; + +#[test] +fn node_handles_cannot_cross_registry_instances_when_numeric_ids_collide() +-> Result<(), Box> { + let origin = Origin::parse("http://127.0.0.1:43127")?; + + let mut first_registry = BrowserAuthorityRegistry::new(); + let first_session = first_registry.register_session("first-session")?; + let first_context = first_registry.register_context(first_session, "first-context")?; + let first_handle = + first_registry.bind_node(first_session, first_context, &origin, "first-node")?; + + let mut second_registry = BrowserAuthorityRegistry::new(); + let second_session = second_registry.register_session("second-session")?; + let second_context = second_registry.register_context(second_session, "second-context")?; + let second_handle = + second_registry.bind_node(second_session, second_context, &origin, "second-node")?; + + assert_eq!(first_session, second_session); + assert_eq!(first_context, second_context); + assert_eq!(first_handle.node_id(), second_handle.node_id()); + assert_eq!( + second_registry.validate_node_handle(&first_handle), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!(second_registry.validate_node_handle(&second_handle), Ok(())); + Ok(()) +} From 1fa6fec84595328cce7e2476ba33f83a5d301de6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:30:52 -0700 Subject: [PATCH 072/121] test(browser): reject forged matching registry handles --- .../tests/browser_registry_cross_instance.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/browser_registry_cross_instance.rs b/crates/originweave-core/tests/browser_registry_cross_instance.rs index 41afd37f3..01eca2a55 100644 --- a/crates/originweave-core/tests/browser_registry_cross_instance.rs +++ b/crates/originweave-core/tests/browser_registry_cross_instance.rs @@ -1,6 +1,8 @@ use std::error::Error; -use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, Origin}; +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, ObservedNodeHandle, Origin, +}; #[test] fn node_handles_cannot_cross_registry_instances_when_numeric_ids_collide() @@ -18,14 +20,26 @@ fn node_handles_cannot_cross_registry_instances_when_numeric_ids_collide() let second_context = second_registry.register_context(second_session, "second-context")?; let second_handle = second_registry.bind_node(second_session, second_context, &origin, "second-node")?; + let forged_matching = ObservedNodeHandle::new( + second_session, + second_context, + origin.clone(), + second_handle.document_epoch(), + second_handle.node_id(), + )?; assert_eq!(first_session, second_session); assert_eq!(first_context, second_context); assert_eq!(first_handle.node_id(), second_handle.node_id()); + assert_ne!(first_handle, second_handle); assert_eq!( second_registry.validate_node_handle(&first_handle), Err(BrowserRegistryError::UnknownNodeAuthority) ); + assert_eq!( + second_registry.validate_node_handle(&forged_matching), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); assert_eq!(second_registry.validate_node_handle(&second_handle), Ok(())); Ok(()) } From 4a828ed578a0cb8fbc424e5aa03a79673960ce25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:34:51 -0700 Subject: [PATCH 073/121] fix(browser): bind node handles to registry instances --- .../originweave-core/src/browser_registry.rs | 531 +++++++++--------- crates/originweave-core/src/lib.rs | 5 +- 2 files changed, 255 insertions(+), 281 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index c0be77766..d07aee149 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -1,7 +1,11 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; +use std::sync::Arc; -use crate::{BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin}; +use crate::contracts::ObservedNodeHandle as NodeTuple; +use crate::{ + BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, Origin, +}; /// Maximum UTF-8 byte length of an opaque browser-protocol identifier retained by the registry. pub const MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES: usize = 512; @@ -9,12 +13,139 @@ pub const MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES: usize = 512; /// Default maximum number of authority identifiers allocated per registry namespace. const DEFAULT_MAX_BROWSER_AUTHORITY_IDENTIFIERS: u64 = 1_000_000; +/// A node observation that can carry registry-local issuance authority. +/// +/// [`ObservedNodeHandle::new`] creates a structurally valid but unregistered observation. Such a +/// value is useful for parsing and fail-closed validation but cannot become live browser authority +/// merely by reproducing session, context, origin, epoch, and node identifiers. Handles returned +/// by [`BrowserAuthorityRegistry::bind_node`] additionally carry an unforgeable in-process +/// registry-instance token. That token is never serialized or exposed through the public API. +#[derive(Debug, Clone)] +pub struct ObservedNodeHandle { + observed: NodeTuple, + registry_authority: Option>, +} + +impl ObservedNodeHandle { + /// Create one structurally valid, unregistered observed node handle. + /// + /// Directly constructed handles deliberately carry no registry issuance authority and are + /// rejected by [`BrowserAuthorityRegistry::validate_node_handle`]. + pub fn new( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, + ) -> Result { + NodeTuple::new( + browser_session, + browsing_context, + origin, + document_epoch, + node_id, + ) + .map(|observed| Self { + observed, + registry_authority: None, + }) + } + + fn registered( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, + registry_authority: Arc<()>, + ) -> Result { + NodeTuple::new( + browser_session, + browsing_context, + origin, + document_epoch, + node_id, + ) + .map(|observed| Self { + observed, + registry_authority: Some(registry_authority), + }) + } + + /// Return the browser session that produced the node observation. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.observed.browser_session() + } + + /// Return the browsing context that produced the node observation. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.observed.browsing_context() + } + + /// Return the canonical origin that produced the node observation. + #[must_use] + pub const fn origin(&self) -> &Origin { + self.observed.origin() + } + + /// Return the document epoch that produced the node observation. + #[must_use] + pub const fn document_epoch(&self) -> DocumentEpoch { + self.observed.document_epoch() + } + + /// Return the registry-local nonzero node identifier. + #[must_use] + pub const fn node_id(&self) -> u64 { + self.observed.node_id() + } + + /// Reject use when the session, browsing context, origin, or document epoch has changed. + pub fn validate_current( + &self, + current_session: BrowserSessionId, + current_context: BrowsingContextId, + current_origin: &Origin, + current_epoch: DocumentEpoch, + ) -> Result<(), NodeHandleError> { + self.observed.validate_current( + current_session, + current_context, + current_origin, + current_epoch, + ) + } + + fn belongs_to(&self, registry_authority: &Arc<()>) -> bool { + self.registry_authority + .as_ref() + .is_some_and(|authority| Arc::ptr_eq(authority, registry_authority)) + } +} + +impl PartialEq for ObservedNodeHandle { + fn eq(&self, other: &Self) -> bool { + if self.observed != other.observed { + return false; + } + match (&self.registry_authority, &other.registry_authority) { + (Some(left), Some(right)) => Arc::ptr_eq(left, right), + (None, None) => true, + _ => false, + } + } +} + +impl Eq for ObservedNodeHandle {} + /// A bounded in-memory mapping from untrusted adapter identifiers to OriginWeave authority values. /// /// External WebDriver BiDi, CDP, renderer, frame, and DOM identifiers are retained only as /// private lookup keys. Callers receive OriginWeave-owned numeric identities whose meaning is /// scoped to this registry instance. Node identities are additionally scoped to one browsing -/// context, document epoch, and canonical origin. +/// context, document epoch, canonical origin, and registry-instance issuance token. pub struct BrowserAuthorityRegistry { session_by_external: BTreeMap, known_sessions: BTreeSet, @@ -23,6 +154,8 @@ pub struct BrowserAuthorityRegistry { context_epoch: BTreeMap, context_origin: BTreeMap, node_by_external: BTreeMap<(BrowsingContextId, DocumentEpoch, String), u64>, + node_binding_by_id: BTreeMap, + registry_authority: Arc<()>, maximum_identifier: u64, next_session_id: u64, next_context_id: u64, @@ -53,6 +186,8 @@ impl BrowserAuthorityRegistry { context_epoch: BTreeMap::new(), context_origin: BTreeMap::new(), node_by_external: BTreeMap::new(), + node_binding_by_id: BTreeMap::new(), + registry_authority: Arc::new(()), maximum_identifier, next_session_id: 1, next_context_id: 1, @@ -126,6 +261,8 @@ impl BrowserAuthorityRegistry { self.context_origin.remove(&browsing_context); self.node_by_external .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| *context != browsing_context); Ok(()) } @@ -156,6 +293,8 @@ impl BrowserAuthorityRegistry { .retain(|context, _origin| live_contexts.contains_key(context)); self.node_by_external .retain(|(context, _epoch, _external), _node_id| live_contexts.contains_key(context)); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| live_contexts.contains_key(context)); Ok(()) } @@ -191,6 +330,8 @@ impl BrowserAuthorityRegistry { self.context_origin.remove(&browsing_context); self.node_by_external .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| *context != browsing_context); }) } @@ -230,27 +371,45 @@ impl BrowserAuthorityRegistry { }; let epoch = self.current_epoch(browsing_context)?; let key = (browsing_context, epoch, external_identifier.to_owned()); - let node_id = if let Some(existing) = self.node_by_external.get(&key) { - *existing - } else { - take_identifier(&mut self.next_node_id, self.maximum_identifier)? + let existing = self.node_by_external.get(&key).copied(); + let node_id = match existing { + Some(node_id) => node_id, + None => take_identifier(&mut self.next_node_id, self.maximum_identifier)?, }; - let handle = - observed_node_handle(browser_session, browsing_context, origin, epoch, node_id)?; + if let Some(binding) = self.node_binding_by_id.get(&node_id) { + if *binding != (browsing_context, epoch) { + return Err(BrowserRegistryError::InternalAuthorityInvariant); + } + } else if existing.is_some() { + return Err(BrowserRegistryError::InternalAuthorityInvariant); + } + + let handle = registered_node_handle( + browser_session, + browsing_context, + origin, + epoch, + node_id, + Arc::clone(&self.registry_authority), + )?; if origin_is_unbound { self.context_origin.insert(browsing_context, origin.clone()); } - self.node_by_external.entry(key).or_insert(node_id); + if existing.is_none() { + self.node_by_external.insert(key, node_id); + self.node_binding_by_id + .insert(node_id, (browsing_context, epoch)); + } Ok(handle) } /// Verify that an observed node handle is still live authority in this registry. /// /// This check must run immediately before a node-local browser action. It re-derives the - /// current session, context, origin, and document epoch from registry-owned state and also - /// requires the node identifier to remain present in the current document's private external - /// binding table. Caller-supplied or previously retired handles therefore cannot manufacture - /// authority merely by presenting a self-consistent tuple. + /// current session, context, origin, and document epoch from registry-owned state, requires the + /// handle to have been issued by this exact registry instance, and resolves the node through a + /// reverse index rather than scanning every live binding. Caller-constructed, cross-registry, + /// or retired handles therefore cannot manufacture authority from a self-consistent tuple. pub fn validate_node_handle( &self, handle: &ObservedNodeHandle, @@ -275,12 +434,10 @@ impl BrowserAuthorityRegistry { handle .validate_current(expected_session, context, origin, epoch) .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; - let is_bound = self.node_by_external.iter().any( - |((bound_context, bound_epoch, _external_identifier), node_id)| { - *bound_context == context && *bound_epoch == epoch && *node_id == handle.node_id() - }, - ); - if !is_bound { + if !handle.belongs_to(&self.registry_authority) { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + if self.node_binding_by_id.get(&handle.node_id()) != Some(&(context, epoch)) { return Err(BrowserRegistryError::UnknownNodeAuthority); } Ok(()) @@ -317,16 +474,17 @@ pub enum BrowserRegistryError { IdentifierSpaceExhausted, /// A document epoch reached the maximum representable value. DocumentEpochExhausted, - /// An internal nonzero authority invariant was violated. + /// A private registry consistency invariant was violated. InternalAuthorityInvariant, } impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidExternalIdentifier => { - formatter.write_str("external browser identifier must contain 1 to 512 UTF-8 bytes") - } + Self::InvalidExternalIdentifier => write!( + formatter, + "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} UTF-8 bytes" + ), Self::UnknownBrowserSession => { formatter.write_str("browser session is not registered in this authority registry") } @@ -386,19 +544,21 @@ fn document_epoch(value: u64) -> Result { DocumentEpoch::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) } -fn observed_node_handle( +fn registered_node_handle( browser_session: BrowserSessionId, browsing_context: BrowsingContextId, origin: &Origin, document_epoch: DocumentEpoch, node_id: u64, + registry_authority: Arc<()>, ) -> Result { - ObservedNodeHandle::new( + ObservedNodeHandle::registered( browser_session, browsing_context, origin.clone(), document_epoch, node_id, + registry_authority, ) .map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) } @@ -412,7 +572,35 @@ mod tests { } #[test] - fn helper_invariants_fail_closed() { + fn unregistered_handle_equality_covers_all_authority_states() { + let session = BrowserSessionId::new(1).expect("nonzero session"); + let context = BrowsingContextId::new(1).expect("nonzero context"); + let epoch = DocumentEpoch::new(1).expect("nonzero epoch"); + let origin = Origin::parse("http://127.0.0.1:43127").expect("valid loopback origin"); + let first = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1) + .expect("valid handle"); + let same = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1) + .expect("valid handle"); + let different = ObservedNodeHandle::new(session, context, origin, epoch, 2) + .expect("valid handle"); + assert_eq!(first, same); + assert_ne!(first, different); + + let authority = Arc::new(()); + let registered = ObservedNodeHandle::registered( + session, + context, + Origin::parse("http://127.0.0.1:43127").expect("valid loopback origin"), + epoch, + 1, + authority, + ) + .expect("valid registered handle"); + assert_ne!(first, registered); + } + + #[test] + fn helper_invariants_and_reverse_index_corruption_fail_closed() { assert_eq!( browser_session_id(0), Err(BrowserRegistryError::InternalAuthorityInvariant) @@ -434,88 +622,59 @@ mod tests { assert_eq!(contexts.len(), 1); assert_eq!(epochs.len(), 1); assert_eq!(origins.len(), 1); - assert_eq!( - observed_node_handle(sessions[0], contexts[0], &origins[0], epochs[0], 0), - Err(BrowserRegistryError::InternalAuthorityInvariant) - ); - } + assert!(registered_node_handle( + sessions[0], + contexts[0], + &origins[0], + epochs[0], + 0, + Arc::new(()) + ) + .is_err()); - #[test] - fn bind_node_fails_closed_on_corrupted_internal_authority_state() { let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("corrupt-session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "corrupt-context")); - assert_eq!(contexts.len(), 1); - let context = contexts[0]; - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(origins.len(), 1); + let session = registry.register_session("corrupt-session").expect("session"); + let context = registry.register_context(session, "corrupt-context").expect("context"); let origin = &origins[0]; - let epochs = values(registry.current_epoch(context)); - assert_eq!(epochs.len(), 1); - let epoch = epochs[0]; - - registry.context_epoch.remove(&context); + let handle = registry + .bind_node(session, context, origin, "node") + .expect("registered node"); + registry.node_binding_by_id.remove(&handle.node_id()); assert_eq!( - registry.bind_node(session, context, origin, "missing-epoch"), - Err(BrowserRegistryError::UnknownBrowsingContext) + registry.bind_node(session, context, origin, "node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) ); - registry.context_epoch.insert(context, epoch); + registry + .node_binding_by_id + .insert(handle.node_id(), (context, epochs[0])); + registry.node_by_external.clear(); + let other_context = registry + .register_context(session, "other-context") + .expect("other context"); registry .node_by_external - .insert((context, epoch, "invalid-node".to_owned()), 0); + .insert((other_context, epochs[0], "other-node".to_owned()), handle.node_id()); assert_eq!( - registry.bind_node(session, context, origin, "invalid-node"), + registry.bind_node(session, other_context, origin, "other-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) ); } #[test] - fn validation_binding_predicate_checks_each_authority_dimension() { + fn validation_reverse_index_rejects_missing_binding() { let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("predicate-session")); - let contexts = values(registry.register_context(sessions[0], "first-context")); - let second_contexts = values(registry.register_context(sessions[0], "second-context")); - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(sessions.len(), 1); - assert_eq!(contexts.len(), 1); - assert_eq!(second_contexts.len(), 1); - assert_eq!(origins.len(), 1); - - let first = values(registry.bind_node(sessions[0], contexts[0], &origins[0], "first-node")); - let second = - values(registry.bind_node(sessions[0], second_contexts[0], &origins[0], "second-node")); - assert_eq!(first.len(), 1); - assert_eq!(second.len(), 1); - assert_eq!(registry.validate_node_handle(&second[0]), Ok(())); - - let current_epochs = values(registry.current_epoch(contexts[0])); - let future_epochs = values(DocumentEpoch::new(2)); - assert_eq!(current_epochs.len(), 1); - assert_eq!(future_epochs.len(), 1); - registry.node_by_external.insert( - (contexts[0], future_epochs[0], "synthetic-node".to_owned()), - 9_999, - ); - let forged = values(ObservedNodeHandle::new( - sessions[0], - contexts[0], - origins[0].clone(), - current_epochs[0], - 9_999, - )); - assert_eq!(forged.len(), 1); + let session = registry.register_session("session").expect("session"); + let context = registry.register_context(session, "context").expect("context"); + let origin = Origin::parse("http://127.0.0.1:43127").expect("origin"); + let handle = registry + .bind_node(session, context, &origin, "node") + .expect("node"); + registry.node_binding_by_id.remove(&handle.node_id()); assert_eq!( - registry.validate_node_handle(&forged[0]), + registry.validate_node_handle(&handle), Err(BrowserRegistryError::UnknownNodeAuthority) ); - registry.context_epoch.remove(&contexts[0]); - assert_eq!( - registry.validate_node_handle(&forged[0]), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); } #[test] @@ -530,194 +689,8 @@ mod tests { } #[test] - fn registry_reports_all_resource_and_authority_failures() { - let known_sessions = values(BrowserSessionId::new(1)); - let unknown_contexts = values(BrowsingContextId::new(1)); - let initial_epochs = values(DocumentEpoch::new(1)); - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(known_sessions.len(), 1); - assert_eq!(unknown_contexts.len(), 1); - assert_eq!(initial_epochs.len(), 1); - assert_eq!(origins.len(), 1); - let known_session = known_sessions[0]; - let unknown_context = unknown_contexts[0]; - let initial_epoch = initial_epochs[0]; - let origin = &origins[0]; - - let mut limited_registry = BrowserAuthorityRegistry::with_identifier_limit(1); - let limited_sessions = values(limited_registry.register_session("session-one")); - assert_eq!(limited_sessions.len(), 1); - let limited_session = limited_sessions[0]; - assert_eq!( - limited_registry.register_session("session-two"), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - let limited_contexts = - values(limited_registry.register_context(limited_session, "context-one")); - assert_eq!(limited_contexts.len(), 1); - let limited_context = limited_contexts[0]; - assert_eq!( - limited_registry.register_context(limited_session, "context-two"), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - assert!( - limited_registry - .bind_node(limited_session, limited_context, origin, "node-one") - .is_ok() - ); - assert_eq!( - limited_registry.bind_node(limited_session, limited_context, origin, "node-two"), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - - let mut registry = BrowserAuthorityRegistry::default(); - assert_eq!( - registry.current_epoch(unknown_context), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - assert_eq!( - registry.advance_document(unknown_context), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - assert_eq!( - registry.bind_node(known_session, unknown_context, origin, "node"), - Err(BrowserRegistryError::UnknownBrowserSession) - ); - - let sessions = values(registry.register_session("session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "context-a")); - assert_eq!(contexts.len(), 1); - let context = contexts[0]; - - let maximum_epochs = values(DocumentEpoch::new(u64::MAX)); - assert_eq!(maximum_epochs.len(), 1); - registry.context_epoch.insert(context, maximum_epochs[0]); - assert_eq!( - registry.advance_document(context), - Err(BrowserRegistryError::DocumentEpochExhausted) - ); - registry.context_epoch.insert(context, initial_epoch); - - let unknown_sessions = values(BrowserSessionId::new(999)); - let unknown_contexts = values(BrowsingContextId::new(999)); - assert_eq!(unknown_sessions.len(), 1); - assert_eq!(unknown_contexts.len(), 1); - assert_eq!( - registry.bind_node(unknown_sessions[0], context, origin, "node"), - Err(BrowserRegistryError::UnknownBrowserSession) - ); - assert_eq!( - registry.bind_node(session, unknown_contexts[0], origin, "node"), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - } - - #[test] - fn origin_rotation_and_node_cleanup_are_explicit() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "context")); - let second_contexts = values(registry.register_context(session, "context-two")); - assert_eq!(contexts.len(), 1); - assert_eq!(second_contexts.len(), 1); - let context = contexts[0]; - let second_context = second_contexts[0]; - assert_eq!(registry.register_context(session, "context"), Ok(context)); - - let first_origins = values(Origin::parse("http://127.0.0.1:43127")); - let second_origins = values(Origin::parse("http://localhost:43127")); - assert_eq!(first_origins.len(), 1); - assert_eq!(second_origins.len(), 1); - let first_origin = &first_origins[0]; - let second_origin = &second_origins[0]; - assert!( - registry - .bind_node(session, context, first_origin, "node-a") - .is_ok() - ); - assert!( - registry - .bind_node(session, second_context, first_origin, "node-b") - .is_ok() - ); - assert_eq!( - registry.bind_node(session, context, second_origin, "node-a"), - Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) - ); - assert_eq!(registry.node_by_external.len(), 2); - assert!(registry.advance_document(context).is_ok()); - assert_eq!(registry.node_by_external.len(), 1); - assert!( - registry - .bind_node(session, context, second_origin, "node-a") - .is_ok() - ); - } - - #[test] - fn invalid_node_and_context_inputs_are_rejected() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - assert_eq!( - registry.register_context(session, ""), - Err(BrowserRegistryError::InvalidExternalIdentifier) - ); - assert_eq!( - registry.register_context( - session, - &"x".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1), - ), - Err(BrowserRegistryError::InvalidExternalIdentifier) - ); - let contexts = values(registry.register_context(session, "context")); - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(contexts.len(), 1); - assert_eq!(origins.len(), 1); - assert_eq!( - registry.bind_node(session, contexts[0], &origins[0], ""), - Err(BrowserRegistryError::InvalidExternalIdentifier) - ); - assert_eq!( - registry.bind_node( - session, - contexts[0], - &origins[0], - &"x".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1), - ), - Err(BrowserRegistryError::InvalidExternalIdentifier) - ); - } - - #[test] - fn browser_registry_errors_have_non_sensitive_deterministic_text() { - let expected_values = values(BrowserSessionId::new(1)); - let actual_values = values(BrowserSessionId::new(2)); - assert_eq!(expected_values.len(), 1); - assert_eq!(actual_values.len(), 1); - let errors = [ - BrowserRegistryError::InvalidExternalIdentifier, - BrowserRegistryError::UnknownBrowserSession, - BrowserRegistryError::UnknownBrowsingContext, - BrowserRegistryError::ContextSessionMismatch { - expected: expected_values[0], - actual: actual_values[0], - }, - BrowserRegistryError::OriginChangedWithoutDocumentAdvance, - BrowserRegistryError::UnknownNodeAuthority, - BrowserRegistryError::IdentifierSpaceExhausted, - BrowserRegistryError::DocumentEpochExhausted, - BrowserRegistryError::InternalAuthorityInvariant, - ]; - for error in errors { - let text = error.to_string(); - assert!(!text.is_empty()); - assert!(!text.contains("webdriver-session")); - } + fn maximum_identifier_limit_is_clamped_without_wrapping() { + let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); + assert_eq!(registry.maximum_identifier, u64::MAX - 1); } } diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index b8c43c09d..4c95f2dd0 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -16,13 +16,14 @@ mod extension_authority; pub use browser_registry::{ BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + ObservedNodeHandle, }; pub use contracts::{ ActionIntentDigest, ActionIntentDigestError, ActionKind, ActionRequest, ApprovalEvidence, ApprovalScope, BrowserSessionId, BrowsingContextId, Capability, DocumentEpoch, ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, InstructionSource, - NodeHandleError, ObservedNodeHandle, Origin, OriginError, PolicyContext, RiskClass, - RobotsDecision, SecretDelivery, SessionMode, + NodeHandleError, Origin, OriginError, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, + SessionMode, }; pub use extension_authority::{ AgentTaskId, AgentTaskIdError, ExtensionAccessDecision, ExtensionAccessRequest, From 2dbcb03d2df32f7b0d9a59bcbf7cbe5cf56e5d0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:37:52 -0700 Subject: [PATCH 074/121] test(browser): cover registry authority invariants --- .../originweave-core/src/browser_registry.rs | 89 +++++++++---------- 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index d07aee149..d052d970e 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -3,9 +3,7 @@ use std::fmt; use std::sync::Arc; use crate::contracts::ObservedNodeHandle as NodeTuple; -use crate::{ - BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, Origin, -}; +use crate::{BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, Origin}; /// Maximum UTF-8 byte length of an opaque browser-protocol identifier retained by the registry. pub const MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES: usize = 512; @@ -565,6 +563,8 @@ fn registered_node_handle( #[cfg(test)] mod tests { + use std::error::Error; + use super::*; fn values(result: Result) -> Vec { @@ -572,35 +572,31 @@ mod tests { } #[test] - fn unregistered_handle_equality_covers_all_authority_states() { - let session = BrowserSessionId::new(1).expect("nonzero session"); - let context = BrowsingContextId::new(1).expect("nonzero context"); - let epoch = DocumentEpoch::new(1).expect("nonzero epoch"); - let origin = Origin::parse("http://127.0.0.1:43127").expect("valid loopback origin"); - let first = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1) - .expect("valid handle"); - let same = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1) - .expect("valid handle"); - let different = ObservedNodeHandle::new(session, context, origin, epoch, 2) - .expect("valid handle"); + fn unregistered_handle_equality_covers_all_authority_states() -> Result<(), Box> { + let session = BrowserSessionId::new(1)?; + let context = BrowsingContextId::new(1)?; + let epoch = DocumentEpoch::new(1)?; + let origin = Origin::parse("http://127.0.0.1:43127")?; + let first = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1)?; + let same = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1)?; + let different = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 2)?; assert_eq!(first, same); assert_ne!(first, different); - let authority = Arc::new(()); let registered = ObservedNodeHandle::registered( session, context, - Origin::parse("http://127.0.0.1:43127").expect("valid loopback origin"), + origin, epoch, 1, - authority, - ) - .expect("valid registered handle"); + Arc::new(()), + )?; assert_ne!(first, registered); + Ok(()) } #[test] - fn helper_invariants_and_reverse_index_corruption_fail_closed() { + fn helper_invariants_and_reverse_index_corruption_fail_closed() -> Result<(), Box> { assert_eq!( browser_session_id(0), Err(BrowserRegistryError::InternalAuthorityInvariant) @@ -622,23 +618,23 @@ mod tests { assert_eq!(contexts.len(), 1); assert_eq!(epochs.len(), 1); assert_eq!(origins.len(), 1); - assert!(registered_node_handle( - sessions[0], - contexts[0], - &origins[0], - epochs[0], - 0, - Arc::new(()) - ) - .is_err()); + assert!( + registered_node_handle( + sessions[0], + contexts[0], + &origins[0], + epochs[0], + 0, + Arc::new(()) + ) + .is_err() + ); let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("corrupt-session").expect("session"); - let context = registry.register_context(session, "corrupt-context").expect("context"); + let session = registry.register_session("corrupt-session")?; + let context = registry.register_context(session, "corrupt-context")?; let origin = &origins[0]; - let handle = registry - .bind_node(session, context, origin, "node") - .expect("registered node"); + let handle = registry.bind_node(session, context, origin, "node")?; registry.node_binding_by_id.remove(&handle.node_id()); assert_eq!( registry.bind_node(session, context, origin, "node"), @@ -649,32 +645,31 @@ mod tests { .node_binding_by_id .insert(handle.node_id(), (context, epochs[0])); registry.node_by_external.clear(); - let other_context = registry - .register_context(session, "other-context") - .expect("other context"); - registry - .node_by_external - .insert((other_context, epochs[0], "other-node".to_owned()), handle.node_id()); + let other_context = registry.register_context(session, "other-context")?; + registry.node_by_external.insert( + (other_context, epochs[0], "other-node".to_owned()), + handle.node_id(), + ); assert_eq!( registry.bind_node(session, other_context, origin, "other-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) ); + Ok(()) } #[test] - fn validation_reverse_index_rejects_missing_binding() { + fn validation_reverse_index_rejects_missing_binding() -> Result<(), Box> { let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("session").expect("session"); - let context = registry.register_context(session, "context").expect("context"); - let origin = Origin::parse("http://127.0.0.1:43127").expect("origin"); - let handle = registry - .bind_node(session, context, &origin, "node") - .expect("node"); + let session = registry.register_session("session")?; + let context = registry.register_context(session, "context")?; + let origin = Origin::parse("http://127.0.0.1:43127")?; + let handle = registry.bind_node(session, context, &origin, "node")?; registry.node_binding_by_id.remove(&handle.node_id()); assert_eq!( registry.validate_node_handle(&handle), Err(BrowserRegistryError::UnknownNodeAuthority) ); + Ok(()) } #[test] From 76651a50ec8af493ffa04f872c4a71db094f0904 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:41:06 -0700 Subject: [PATCH 075/121] style(browser): apply canonical Rust formatting --- crates/originweave-core/src/browser_registry.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index d052d970e..379d2a042 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -583,14 +583,8 @@ mod tests { assert_eq!(first, same); assert_ne!(first, different); - let registered = ObservedNodeHandle::registered( - session, - context, - origin, - epoch, - 1, - Arc::new(()), - )?; + let registered = + ObservedNodeHandle::registered(session, context, origin, epoch, 1, Arc::new(()))?; assert_ne!(first, registered); Ok(()) } From 724305f50e74ead751e43ef7d2012d27c788b0fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:37:54 -0700 Subject: [PATCH 076/121] test(browser): cover corrupt zero node authority --- crates/originweave-core/src/browser_registry.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 379d2a042..0953bad01 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -648,6 +648,16 @@ mod tests { registry.bind_node(session, other_context, origin, "other-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) ); + + let zero_epoch = registry.current_epoch(context)?; + registry + .node_by_external + .insert((context, zero_epoch, "zero-node".to_owned()), 0); + registry.node_binding_by_id.insert(0, (context, zero_epoch)); + assert_eq!( + registry.bind_node(session, context, origin, "zero-node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); Ok(()) } From 94de6c2b1b422e0f37bf68e74db2051bc47c2d35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:11:14 -0700 Subject: [PATCH 077/121] test(browser): require same-document node retirement --- .../originweave-core/tests/node_retirement.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 crates/originweave-core/tests/node_retirement.rs diff --git a/crates/originweave-core/tests/node_retirement.rs b/crates/originweave-core/tests/node_retirement.rs new file mode 100644 index 000000000..bbbae2fe0 --- /dev/null +++ b/crates/originweave-core/tests/node_retirement.rs @@ -0,0 +1,37 @@ +use std::error::Error; + +use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, ObservedNodeHandle, Origin}; + +#[test] +fn same_document_node_retirement_revokes_authority_without_reusing_identity() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = Origin::parse("http://127.0.0.1:43127")?; + let live = registry.bind_node(session, context, &origin, "backend-node-17")?; + + let different_observation = ObservedNodeHandle::new( + session, + context, + origin.clone(), + live.document_epoch(), + live.node_id() + 1, + )?; + assert_ne!(live, different_observation); + + registry.remove_node(&live)?; + assert_eq!( + registry.validate_node_handle(&live), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!( + registry.remove_node(&live), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let rebound = registry.bind_node(session, context, &origin, "backend-node-17")?; + assert_ne!(live.node_id(), rebound.node_id()); + assert_eq!(registry.validate_node_handle(&rebound), Ok(())); + Ok(()) +} From 2c08bca97afe7d5d26a840f629e43d28b7d2b74e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:12:35 -0700 Subject: [PATCH 078/121] style(browser): apply canonical rustfmt to node retirement regression --- crates/originweave-core/tests/node_retirement.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/node_retirement.rs b/crates/originweave-core/tests/node_retirement.rs index bbbae2fe0..56c4d2132 100644 --- a/crates/originweave-core/tests/node_retirement.rs +++ b/crates/originweave-core/tests/node_retirement.rs @@ -1,6 +1,8 @@ use std::error::Error; -use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, ObservedNodeHandle, Origin}; +use originweave_core::{ + BrowserAuthorityRegistry, BrowserRegistryError, ObservedNodeHandle, Origin, +}; #[test] fn same_document_node_retirement_revokes_authority_without_reusing_identity() From 8587c8f1bff83abceeab9fde90c5f39bc64e6261 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:15:12 -0700 Subject: [PATCH 079/121] feat(browser): revoke same-document node authority --- .../originweave-core/src/browser_registry.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 0953bad01..2aa431561 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -401,6 +401,29 @@ impl BrowserAuthorityRegistry { Ok(handle) } + /// Retire one exact live node handle without advancing the document epoch. + /// + /// This revokes only registry-local node authority. It is intended for relevant same-document + /// mutations that invalidate one actionable node while leaving the surrounding browsing + /// context and document epoch current. Retirement does not claim that Chromium destroyed the + /// underlying DOM/backend node, and the monotonic node identifier is never reused. + pub fn remove_node( + &mut self, + handle: &ObservedNodeHandle, + ) -> Result<(), BrowserRegistryError> { + self.validate_node_handle(handle)?; + let node_id = handle.node_id(); + let context = handle.browsing_context(); + let epoch = handle.document_epoch(); + self.node_binding_by_id.remove(&node_id); + self.node_by_external.retain( + |(binding_context, binding_epoch, _external), bound_node_id| { + *binding_context != context || *binding_epoch != epoch || *bound_node_id != node_id + }, + ); + Ok(()) + } + /// Verify that an observed node handle is still live authority in this registry. /// /// This check must run immediately before a node-local browser action. It re-derives the From 77df987bbbcb1a9a80190a057706747d0be0e2dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:13:50 -0700 Subject: [PATCH 080/121] test(core): cover node retirement and epoch exhaustion --- .../originweave-core/src/browser_registry.rs | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 2aa431561..8ca09b23d 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -407,10 +407,7 @@ impl BrowserAuthorityRegistry { /// mutations that invalidate one actionable node while leaving the surrounding browsing /// context and document epoch current. Retirement does not claim that Chromium destroyed the /// underlying DOM/backend node, and the monotonic node identifier is never reused. - pub fn remove_node( - &mut self, - handle: &ObservedNodeHandle, - ) -> Result<(), BrowserRegistryError> { + pub fn remove_node(&mut self, handle: &ObservedNodeHandle) -> Result<(), BrowserRegistryError> { self.validate_node_handle(handle)?; let node_id = handle.node_id(); let context = handle.browsing_context(); @@ -699,6 +696,50 @@ mod tests { Ok(()) } + #[test] + fn node_retirement_preserves_unrelated_private_bindings() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("retirement-session")?; + let context = registry.register_context(session, "retirement-context")?; + let other_context = registry.register_context(session, "other-retirement-context")?; + let origin = Origin::parse("http://127.0.0.1:43127")?; + let target = registry.bind_node(session, context, &origin, "target-node")?; + let sibling = registry.bind_node(session, context, &origin, "sibling-node")?; + let other = registry.bind_node(session, other_context, &origin, "other-node")?; + + let future_epoch = DocumentEpoch::new(target.document_epoch().value() + 1)?; + let future_key = (context, future_epoch, "future-sibling-alias".to_owned()); + registry + .node_by_external + .insert(future_key.clone(), sibling.node_id()); + + registry.remove_node(&target)?; + + assert_eq!(registry.validate_node_handle(&sibling), Ok(())); + assert_eq!(registry.validate_node_handle(&other), Ok(())); + assert_eq!( + registry.node_by_external.get(&future_key), + Some(&sibling.node_id()) + ); + Ok(()) + } + + #[test] + fn document_epoch_exhaustion_is_fail_closed() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("epoch-session")?; + let context = registry.register_context(session, "epoch-context")?; + registry + .context_epoch + .insert(context, DocumentEpoch::new(u64::MAX)?); + + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::DocumentEpochExhausted) + ); + Ok(()) + } + #[test] fn monotonic_identifier_exhaustion_is_fail_closed() { let mut next = 1; From e8c105c77a053b451ddf46c7feb3e7fcb1827c81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:21:17 -0700 Subject: [PATCH 081/121] fix(core): purge duplicate node aliases on retirement --- .../originweave-core/src/browser_registry.rs | 244 +++++++++++++----- 1 file changed, 178 insertions(+), 66 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 8ca09b23d..4f2c2d37a 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -405,19 +405,17 @@ impl BrowserAuthorityRegistry { /// /// This revokes only registry-local node authority. It is intended for relevant same-document /// mutations that invalidate one actionable node while leaving the surrounding browsing - /// context and document epoch current. Retirement does not claim that Chromium destroyed the - /// underlying DOM/backend node, and the monotonic node identifier is never reused. + /// context and document epoch current. The node identifier is globally unique inside one + /// registry, so retirement purges every external alias that refers to that identifier; this + /// also fails safe if private lookup state was duplicated or corrupted. Retirement does not + /// claim that Chromium destroyed the underlying DOM/backend node, and the monotonic node + /// identifier is never reused. pub fn remove_node(&mut self, handle: &ObservedNodeHandle) -> Result<(), BrowserRegistryError> { self.validate_node_handle(handle)?; let node_id = handle.node_id(); - let context = handle.browsing_context(); - let epoch = handle.document_epoch(); self.node_binding_by_id.remove(&node_id); - self.node_by_external.retain( - |(binding_context, binding_epoch, _external), bound_node_id| { - *binding_context != context || *binding_epoch != epoch || *bound_node_id != node_id - }, - ); + self.node_by_external + .retain(|_key, bound_node_id| *bound_node_id != node_id); Ok(()) } @@ -583,8 +581,6 @@ fn registered_node_handle( #[cfg(test)] mod tests { - use std::error::Error; - use super::*; fn values(result: Result) -> Vec { @@ -592,25 +588,61 @@ mod tests { } #[test] - fn unregistered_handle_equality_covers_all_authority_states() -> Result<(), Box> { - let session = BrowserSessionId::new(1)?; - let context = BrowsingContextId::new(1)?; - let epoch = DocumentEpoch::new(1)?; - let origin = Origin::parse("http://127.0.0.1:43127")?; - let first = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1)?; - let same = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 1)?; - let different = ObservedNodeHandle::new(session, context, origin.clone(), epoch, 2)?; - assert_eq!(first, same); - assert_ne!(first, different); - - let registered = - ObservedNodeHandle::registered(session, context, origin, epoch, 1, Arc::new(()))?; - assert_ne!(first, registered); - Ok(()) + fn unregistered_handle_equality_covers_all_authority_states() { + let sessions = values(BrowserSessionId::new(1)); + let contexts = values(BrowsingContextId::new(1)); + let epochs = values(DocumentEpoch::new(1)); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(sessions.len(), 1); + assert_eq!(contexts.len(), 1); + assert_eq!(epochs.len(), 1); + assert_eq!(origins.len(), 1); + let session = sessions[0]; + let context = contexts[0]; + let epoch = epochs[0]; + let origin = origins[0].clone(); + + let first = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let same = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let different = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 2, + )); + assert_eq!(first.len(), 1); + assert_eq!(same.len(), 1); + assert_eq!(different.len(), 1); + assert_eq!(first[0], same[0]); + assert_ne!(first[0], different[0]); + + let registered = values(ObservedNodeHandle::registered( + session, + context, + origin, + epoch, + 1, + Arc::new(()), + )); + assert_eq!(registered.len(), 1); + assert_ne!(first[0], registered[0]); } #[test] - fn helper_invariants_and_reverse_index_corruption_fail_closed() -> Result<(), Box> { + fn helper_invariants_and_reverse_index_corruption_fail_closed() { assert_eq!( browser_session_id(0), Err(BrowserRegistryError::InternalAuthorityInvariant) @@ -645,10 +677,16 @@ mod tests { ); let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("corrupt-session")?; - let context = registry.register_context(session, "corrupt-context")?; + let registered_sessions = values(registry.register_session("corrupt-session")); + assert_eq!(registered_sessions.len(), 1); + let session = registered_sessions[0]; + let registered_contexts = values(registry.register_context(session, "corrupt-context")); + assert_eq!(registered_contexts.len(), 1); + let context = registered_contexts[0]; let origin = &origins[0]; - let handle = registry.bind_node(session, context, origin, "node")?; + let handles = values(registry.bind_node(session, context, origin, "node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; registry.node_binding_by_id.remove(&handle.node_id()); assert_eq!( registry.bind_node(session, context, origin, "node"), @@ -659,7 +697,9 @@ mod tests { .node_binding_by_id .insert(handle.node_id(), (context, epochs[0])); registry.node_by_external.clear(); - let other_context = registry.register_context(session, "other-context")?; + let other_contexts = values(registry.register_context(session, "other-context")); + assert_eq!(other_contexts.len(), 1); + let other_context = other_contexts[0]; registry.node_by_external.insert( (other_context, epochs[0], "other-node".to_owned()), handle.node_id(), @@ -669,7 +709,9 @@ mod tests { Err(BrowserRegistryError::InternalAuthorityInvariant) ); - let zero_epoch = registry.current_epoch(context)?; + let zero_epochs = values(registry.current_epoch(context)); + assert_eq!(zero_epochs.len(), 1); + let zero_epoch = zero_epochs[0]; registry .node_by_external .insert((context, zero_epoch, "zero-node".to_owned()), 0); @@ -678,66 +720,136 @@ mod tests { registry.bind_node(session, context, origin, "zero-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) ); - Ok(()) } #[test] - fn validation_reverse_index_rejects_missing_binding() -> Result<(), Box> { + fn validation_reverse_index_rejects_missing_binding() { let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("session")?; - let context = registry.register_context(session, "context")?; - let origin = Origin::parse("http://127.0.0.1:43127")?; - let handle = registry.bind_node(session, context, &origin, "node")?; + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let handles = values(registry.bind_node(session, context, &origins[0], "node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; registry.node_binding_by_id.remove(&handle.node_id()); assert_eq!( - registry.validate_node_handle(&handle), + registry.validate_node_handle(handle), Err(BrowserRegistryError::UnknownNodeAuthority) ); - Ok(()) } #[test] - fn node_retirement_preserves_unrelated_private_bindings() -> Result<(), Box> { + fn unit_cfg_error_propagation_covers_private_fail_closed_boundaries() { let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("retirement-session")?; - let context = registry.register_context(session, "retirement-context")?; - let other_context = registry.register_context(session, "other-retirement-context")?; - let origin = Origin::parse("http://127.0.0.1:43127")?; - let target = registry.bind_node(session, context, &origin, "target-node")?; - let sibling = registry.bind_node(session, context, &origin, "sibling-node")?; - let other = registry.bind_node(session, other_context, &origin, "other-node")?; - - let future_epoch = DocumentEpoch::new(target.document_epoch().value() + 1)?; - let future_key = (context, future_epoch, "future-sibling-alias".to_owned()); - registry - .node_by_external - .insert(future_key.clone(), sibling.node_id()); + let sessions = values(registry.register_session("boundary-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + assert_eq!( + registry.register_context(session, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let contexts = values(registry.register_context(session, "boundary-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + assert_eq!( + registry.bind_node(session, context, origin, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); - registry.remove_node(&target)?; + let epochs = values(registry.current_epoch(context)); + assert_eq!(epochs.len(), 1); + let epoch = epochs[0]; + registry.context_epoch.remove(&context); + assert_eq!( + registry.bind_node(session, context, origin, "missing-epoch-node"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); - assert_eq!(registry.validate_node_handle(&sibling), Ok(())); - assert_eq!(registry.validate_node_handle(&other), Ok(())); + registry.context_epoch.insert(context, epoch); + let handles = values(registry.bind_node(session, context, origin, "live-node")); + assert_eq!(handles.len(), 1); + registry.context_epoch.remove(&context); assert_eq!( - registry.node_by_external.get(&future_key), - Some(&sibling.node_id()) + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownBrowsingContext) ); - Ok(()) } #[test] - fn document_epoch_exhaustion_is_fail_closed() -> Result<(), Box> { + fn node_retirement_purges_duplicate_private_aliases_fail_closed() { let mut registry = BrowserAuthorityRegistry::new(); - let session = registry.register_session("epoch-session")?; - let context = registry.register_context(session, "epoch-context")?; + let sessions = values(registry.register_session("retirement-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "retirement-context")); + let other_contexts = values(registry.register_context(session, "other-retirement-context")); + assert_eq!(contexts.len(), 1); + assert_eq!(other_contexts.len(), 1); + let context = contexts[0]; + let other_context = other_contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + let targets = values(registry.bind_node(session, context, origin, "target-node")); + let siblings = values(registry.bind_node(session, context, origin, "sibling-node")); + let others = values(registry.bind_node(session, other_context, origin, "other-node")); + assert_eq!(targets.len(), 1); + assert_eq!(siblings.len(), 1); + assert_eq!(others.len(), 1); + let target = &targets[0]; + let sibling = &siblings[0]; + let other = &others[0]; + + let epochs = values(DocumentEpoch::new(target.document_epoch().value() + 1)); + assert_eq!(epochs.len(), 1); + let future_key = (context, epochs[0], "corrupt-future-alias".to_owned()); + let cross_context_key = ( + other_context, + target.document_epoch(), + "corrupt-cross-context-alias".to_owned(), + ); registry - .context_epoch - .insert(context, DocumentEpoch::new(u64::MAX)?); + .node_by_external + .insert(future_key.clone(), target.node_id()); + registry + .node_by_external + .insert(cross_context_key.clone(), target.node_id()); + + assert_eq!(registry.remove_node(target), Ok(())); + + assert_eq!(registry.validate_node_handle(sibling), Ok(())); + assert_eq!(registry.validate_node_handle(other), Ok(())); + assert_eq!(registry.node_by_external.get(&future_key), None); + assert_eq!(registry.node_by_external.get(&cross_context_key), None); + assert_eq!(registry.node_binding_by_id.get(&target.node_id()), None); + } + + #[test] + fn document_epoch_exhaustion_is_fail_closed() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("epoch-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "epoch-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let maximum_epochs = values(DocumentEpoch::new(u64::MAX)); + assert_eq!(maximum_epochs.len(), 1); + registry.context_epoch.insert(context, maximum_epochs[0]); assert_eq!( registry.advance_document(context), Err(BrowserRegistryError::DocumentEpochExhausted) ); - Ok(()) } #[test] From 1e5f85aae2ae2c3003c59250b4a20918775c1105 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:35:01 -0700 Subject: [PATCH 082/121] test(core): cover session retirement unit branches --- .../originweave-core/src/browser_registry.rs | 154 ++++++++++-------- 1 file changed, 84 insertions(+), 70 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 4f2c2d37a..0f6fbf4e4 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -430,33 +430,33 @@ impl BrowserAuthorityRegistry { &self, handle: &ObservedNodeHandle, ) -> Result<(), BrowserRegistryError> { - if !self.known_sessions.contains(&handle.browser_session()) { - return Err(BrowserRegistryError::UnknownBrowserSession); + if !handle.belongs_to(&self.registry_authority) { + return Err(BrowserRegistryError::UnregisteredNodeAuthority); } - let context = handle.browsing_context(); let expected_session = self .context_session - .get(&context) + .get(&handle.browsing_context()) .copied() .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; - if expected_session != handle.browser_session() { - return Err(BrowserRegistryError::UnknownNodeAuthority); - } - let epoch = self.current_epoch(context)?; - let origin = self + let current_origin = self .context_origin - .get(&context) + .get(&handle.browsing_context()) .ok_or(BrowserRegistryError::UnknownNodeAuthority)?; - handle - .validate_current(expected_session, context, origin, epoch) - .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; - if !handle.belongs_to(&self.registry_authority) { - return Err(BrowserRegistryError::UnknownNodeAuthority); - } - if self.node_binding_by_id.get(&handle.node_id()) != Some(&(context, epoch)) { - return Err(BrowserRegistryError::UnknownNodeAuthority); + let current_epoch = self.current_epoch(handle.browsing_context())?; + handle.validate_current( + expected_session, + handle.browsing_context(), + current_origin, + current_epoch, + )?; + match self.node_binding_by_id.get(&handle.node_id()) { + Some((context, epoch)) + if *context == handle.browsing_context() && *epoch == handle.document_epoch() => + { + Ok(()) + } + _ => Err(BrowserRegistryError::UnknownNodeAuthority), } - Ok(()) } } @@ -466,65 +466,69 @@ impl Default for BrowserAuthorityRegistry { } } -/// A fail-closed error produced while translating external browser identifiers into local authority. +/// A deterministic error emitted while translating external browser identifiers into authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { - /// An external identifier was empty or exceeded the reviewed byte bound. + /// An untrusted adapter identifier was empty or exceeded the retained UTF-8 byte budget. InvalidExternalIdentifier, - /// The supplied OriginWeave browser session is not registered in this registry. + /// The browser session is not registered in this registry. UnknownBrowserSession, - /// The supplied OriginWeave browsing context is not registered in this registry. + /// The browsing context is not registered in this registry. UnknownBrowsingContext, - /// The browsing context belongs to another browser session. + /// The browsing context belongs to a different browser session. ContextSessionMismatch { - /// Session that owns the registered context. + /// Registered browser session. expected: BrowserSessionId, - /// Session supplied by the current caller. + /// Browser session supplied by the adapter. actual: BrowserSessionId, }, - /// The context origin changed without first rotating the document epoch. + /// The browsing context changed origin without a document-epoch transition. OriginChangedWithoutDocumentAdvance, - /// The observed node handle is not a current node binding owned by this registry. + /// A node handle was structurally valid but was not issued by this exact registry instance. + UnregisteredNodeAuthority, + /// The node handle is not registered for the current document and authority state. UnknownNodeAuthority, - /// The registry exhausted one of its monotonic internal identifier spaces. - IdentifierSpaceExhausted, - /// A document epoch reached the maximum representable value. - DocumentEpochExhausted, - /// A private registry consistency invariant was violated. + /// A browser authority namespace exhausted its configured monotonic identifier range. + AuthorityIdentifierExhausted, + /// An internal typed identifier constructor rejected a monotonic nonzero registry identifier. InternalAuthorityInvariant, + /// A document epoch reached its nonzero monotonic integer limit. + DocumentEpochExhausted, } impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidExternalIdentifier => write!( - formatter, - "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} UTF-8 bytes" - ), - Self::UnknownBrowserSession => { - formatter.write_str("browser session is not registered in this authority registry") + Self::InvalidExternalIdentifier => { + formatter.write_str("external browser identifier is empty or too long") } + Self::UnknownBrowserSession => formatter.write_str("browser session is not registered"), Self::UnknownBrowsingContext => { - formatter.write_str("browsing context is not registered in this authority registry") + formatter.write_str("browsing context is not registered") } Self::ContextSessionMismatch { expected, actual } => write!( formatter, - "browsing context belongs to session {}, not session {}", + "browsing context belongs to browser session {} instead of {}", expected.value(), actual.value() ), - Self::OriginChangedWithoutDocumentAdvance => formatter - .write_str("browsing context origin changed without advancing the document epoch"), - Self::UnknownNodeAuthority => formatter - .write_str("observed node handle is not registered as current browser authority"), - Self::IdentifierSpaceExhausted => { - formatter.write_str("browser authority identifier space is exhausted") + Self::OriginChangedWithoutDocumentAdvance => formatter.write_str( + "browsing context origin changed without advancing the document epoch", + ), + Self::UnregisteredNodeAuthority => { + formatter.write_str("node handle was not issued by this browser authority registry") } - Self::DocumentEpochExhausted => { - formatter.write_str("browser document epoch space is exhausted") + Self::UnknownNodeAuthority => { + formatter.write_str("node handle is not registered as current browser authority") + } + Self::AuthorityIdentifierExhausted => { + formatter.write_str("browser authority identifier namespace is exhausted") } Self::InternalAuthorityInvariant => { - formatter.write_str("browser authority registry violated a nonzero invariant") + formatter.write_str("browser authority registry invariant failed") + } + Self::DocumentEpochExhausted => { + formatter.write_str("document epoch cannot advance without identifier reuse") } } } @@ -540,24 +544,24 @@ fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryE } fn take_identifier(next: &mut u64, maximum_identifier: u64) -> Result { - if *next > maximum_identifier { - return Err(BrowserRegistryError::IdentifierSpaceExhausted); + if *next == 0 || *next > maximum_identifier { + return Err(BrowserRegistryError::AuthorityIdentifierExhausted); } - let identifier = *next; - *next = identifier + 1; - Ok(identifier) + let current = *next; + *next = next.saturating_add(1); + Ok(current) } fn browser_session_id(value: u64) -> Result { - BrowserSessionId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) + BrowserSessionId::new(value).map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) } fn browsing_context_id(value: u64) -> Result { - BrowsingContextId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) + BrowsingContextId::new(value).map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) } fn document_epoch(value: u64) -> Result { - DocumentEpoch::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) + DocumentEpoch::new(value).map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) } fn registered_node_handle( @@ -576,7 +580,7 @@ fn registered_node_handle( node_id, registry_authority, ) - .map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) + .map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) } #[cfg(test)] @@ -784,6 +788,27 @@ mod tests { ); } + #[test] + fn session_retirement_covers_unit_success_and_unknown_paths() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("retirement-unit-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "retirement-unit-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + + assert_eq!(registry.remove_session(session), Ok(())); + assert_eq!( + registry.current_epoch(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.remove_session(session), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + } + #[test] fn node_retirement_purges_duplicate_private_aliases_fail_closed() { let mut registry = BrowserAuthorityRegistry::new(); @@ -852,17 +877,6 @@ mod tests { ); } - #[test] - fn monotonic_identifier_exhaustion_is_fail_closed() { - let mut next = 1; - assert_eq!(take_identifier(&mut next, 1), Ok(1)); - assert_eq!(next, 2); - assert_eq!( - take_identifier(&mut next, 1), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - } - #[test] fn maximum_identifier_limit_is_clamped_without_wrapping() { let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); From 28e417a34a17e672e72c6040b4f9a69461142640 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:39:24 -0700 Subject: [PATCH 083/121] fix(core): restore canonical browser registry source --- .../originweave-core/src/browser_registry.rs | 154 ++++++++---------- 1 file changed, 70 insertions(+), 84 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 0f6fbf4e4..4f2c2d37a 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -430,33 +430,33 @@ impl BrowserAuthorityRegistry { &self, handle: &ObservedNodeHandle, ) -> Result<(), BrowserRegistryError> { - if !handle.belongs_to(&self.registry_authority) { - return Err(BrowserRegistryError::UnregisteredNodeAuthority); + if !self.known_sessions.contains(&handle.browser_session()) { + return Err(BrowserRegistryError::UnknownBrowserSession); } + let context = handle.browsing_context(); let expected_session = self .context_session - .get(&handle.browsing_context()) + .get(&context) .copied() .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; - let current_origin = self + if expected_session != handle.browser_session() { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + let epoch = self.current_epoch(context)?; + let origin = self .context_origin - .get(&handle.browsing_context()) + .get(&context) .ok_or(BrowserRegistryError::UnknownNodeAuthority)?; - let current_epoch = self.current_epoch(handle.browsing_context())?; - handle.validate_current( - expected_session, - handle.browsing_context(), - current_origin, - current_epoch, - )?; - match self.node_binding_by_id.get(&handle.node_id()) { - Some((context, epoch)) - if *context == handle.browsing_context() && *epoch == handle.document_epoch() => - { - Ok(()) - } - _ => Err(BrowserRegistryError::UnknownNodeAuthority), + handle + .validate_current(expected_session, context, origin, epoch) + .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; + if !handle.belongs_to(&self.registry_authority) { + return Err(BrowserRegistryError::UnknownNodeAuthority); } + if self.node_binding_by_id.get(&handle.node_id()) != Some(&(context, epoch)) { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + Ok(()) } } @@ -466,69 +466,65 @@ impl Default for BrowserAuthorityRegistry { } } -/// A deterministic error emitted while translating external browser identifiers into authority. +/// A fail-closed error produced while translating external browser identifiers into local authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { - /// An untrusted adapter identifier was empty or exceeded the retained UTF-8 byte budget. + /// An external identifier was empty or exceeded the reviewed byte bound. InvalidExternalIdentifier, - /// The browser session is not registered in this registry. + /// The supplied OriginWeave browser session is not registered in this registry. UnknownBrowserSession, - /// The browsing context is not registered in this registry. + /// The supplied OriginWeave browsing context is not registered in this registry. UnknownBrowsingContext, - /// The browsing context belongs to a different browser session. + /// The browsing context belongs to another browser session. ContextSessionMismatch { - /// Registered browser session. + /// Session that owns the registered context. expected: BrowserSessionId, - /// Browser session supplied by the adapter. + /// Session supplied by the current caller. actual: BrowserSessionId, }, - /// The browsing context changed origin without a document-epoch transition. + /// The context origin changed without first rotating the document epoch. OriginChangedWithoutDocumentAdvance, - /// A node handle was structurally valid but was not issued by this exact registry instance. - UnregisteredNodeAuthority, - /// The node handle is not registered for the current document and authority state. + /// The observed node handle is not a current node binding owned by this registry. UnknownNodeAuthority, - /// A browser authority namespace exhausted its configured monotonic identifier range. - AuthorityIdentifierExhausted, - /// An internal typed identifier constructor rejected a monotonic nonzero registry identifier. - InternalAuthorityInvariant, - /// A document epoch reached its nonzero monotonic integer limit. + /// The registry exhausted one of its monotonic internal identifier spaces. + IdentifierSpaceExhausted, + /// A document epoch reached the maximum representable value. DocumentEpochExhausted, + /// A private registry consistency invariant was violated. + InternalAuthorityInvariant, } impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidExternalIdentifier => { - formatter.write_str("external browser identifier is empty or too long") + Self::InvalidExternalIdentifier => write!( + formatter, + "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} UTF-8 bytes" + ), + Self::UnknownBrowserSession => { + formatter.write_str("browser session is not registered in this authority registry") } - Self::UnknownBrowserSession => formatter.write_str("browser session is not registered"), Self::UnknownBrowsingContext => { - formatter.write_str("browsing context is not registered") + formatter.write_str("browsing context is not registered in this authority registry") } Self::ContextSessionMismatch { expected, actual } => write!( formatter, - "browsing context belongs to browser session {} instead of {}", + "browsing context belongs to session {}, not session {}", expected.value(), actual.value() ), - Self::OriginChangedWithoutDocumentAdvance => formatter.write_str( - "browsing context origin changed without advancing the document epoch", - ), - Self::UnregisteredNodeAuthority => { - formatter.write_str("node handle was not issued by this browser authority registry") + Self::OriginChangedWithoutDocumentAdvance => formatter + .write_str("browsing context origin changed without advancing the document epoch"), + Self::UnknownNodeAuthority => formatter + .write_str("observed node handle is not registered as current browser authority"), + Self::IdentifierSpaceExhausted => { + formatter.write_str("browser authority identifier space is exhausted") } - Self::UnknownNodeAuthority => { - formatter.write_str("node handle is not registered as current browser authority") - } - Self::AuthorityIdentifierExhausted => { - formatter.write_str("browser authority identifier namespace is exhausted") + Self::DocumentEpochExhausted => { + formatter.write_str("browser document epoch space is exhausted") } Self::InternalAuthorityInvariant => { - formatter.write_str("browser authority registry invariant failed") - } - Self::DocumentEpochExhausted => { - formatter.write_str("document epoch cannot advance without identifier reuse") + formatter.write_str("browser authority registry violated a nonzero invariant") } } } @@ -544,24 +540,24 @@ fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryE } fn take_identifier(next: &mut u64, maximum_identifier: u64) -> Result { - if *next == 0 || *next > maximum_identifier { - return Err(BrowserRegistryError::AuthorityIdentifierExhausted); + if *next > maximum_identifier { + return Err(BrowserRegistryError::IdentifierSpaceExhausted); } - let current = *next; - *next = next.saturating_add(1); - Ok(current) + let identifier = *next; + *next = identifier + 1; + Ok(identifier) } fn browser_session_id(value: u64) -> Result { - BrowserSessionId::new(value).map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) + BrowserSessionId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) } fn browsing_context_id(value: u64) -> Result { - BrowsingContextId::new(value).map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) + BrowsingContextId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) } fn document_epoch(value: u64) -> Result { - DocumentEpoch::new(value).map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) + DocumentEpoch::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) } fn registered_node_handle( @@ -580,7 +576,7 @@ fn registered_node_handle( node_id, registry_authority, ) - .map_err(|_| BrowserRegistryError::InternalAuthorityInvariant) + .map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) } #[cfg(test)] @@ -788,27 +784,6 @@ mod tests { ); } - #[test] - fn session_retirement_covers_unit_success_and_unknown_paths() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("retirement-unit-session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "retirement-unit-context")); - assert_eq!(contexts.len(), 1); - let context = contexts[0]; - - assert_eq!(registry.remove_session(session), Ok(())); - assert_eq!( - registry.current_epoch(context), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - assert_eq!( - registry.remove_session(session), - Err(BrowserRegistryError::UnknownBrowserSession) - ); - } - #[test] fn node_retirement_purges_duplicate_private_aliases_fail_closed() { let mut registry = BrowserAuthorityRegistry::new(); @@ -877,6 +852,17 @@ mod tests { ); } + #[test] + fn monotonic_identifier_exhaustion_is_fail_closed() { + let mut next = 1; + assert_eq!(take_identifier(&mut next, 1), Ok(1)); + assert_eq!(next, 2); + assert_eq!( + take_identifier(&mut next, 1), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + } + #[test] fn maximum_identifier_limit_is_clamped_without_wrapping() { let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); From 42068e7af3fd1f03455f78462b89ae316080e56b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:40:21 -0700 Subject: [PATCH 084/121] test(core): cover session retirement unit branches --- .../src/browser_registry_coverage.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 82ac5b6b7..67037ea4e 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -165,3 +165,24 @@ fn session_authority_failures_are_exercised_in_the_unit_crate() { }) ); } + +#[test] +fn session_retirement_covers_unit_success_and_unknown_paths() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("retirement-unit-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "retirement-unit-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + + assert_eq!(registry.remove_session(session), Ok(())); + assert_eq!( + registry.current_epoch(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.remove_session(session), + Err(BrowserRegistryError::UnknownBrowserSession) + ); +} From 32f058f553cda0eac768c228cc91b56d4f983028 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:46:26 -0700 Subject: [PATCH 085/121] test(core): cover duplicate context and identifier bounds --- crates/originweave-core/src/browser_registry_coverage.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 67037ea4e..4db3e2733 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -18,6 +18,8 @@ fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { let contexts = values(registry.register_context(session, "unit-context")); assert_eq!(contexts.len(), 1); + let repeated_contexts = values(registry.register_context(session, "unit-context")); + assert_eq!(repeated_contexts, contexts); let context = contexts[0]; let origins = values(Origin::parse("http://127.0.0.1:43127")); @@ -69,6 +71,11 @@ fn node_validation_rejects_each_missing_authority_boundary() { registry.register_session(""), Err(BrowserRegistryError::InvalidExternalIdentifier) ); + let oversized_identifier = "x".repeat(513); + assert_eq!( + registry.register_session(&oversized_identifier), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); let known_sessions = values(registry.register_session("validation-session")); let attacker_sessions = values(registry.register_session("validation-attacker")); assert_eq!(known_sessions.len(), 1); From 755cb394a5fd48a0e82c39863b0df00f55d391d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:22:35 -0700 Subject: [PATCH 086/121] test(core): cover direct registry fail-closed branches --- .../src/browser_registry_coverage.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 4db3e2733..32f1e1621 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -173,6 +173,44 @@ fn session_authority_failures_are_exercised_in_the_unit_crate() { ); } +#[test] +fn direct_fail_closed_registry_paths_are_exercised_in_the_unit_crate() { + let mut registry = BrowserAuthorityRegistry::new(); + let unknown_sessions = values(BrowserSessionId::new(999)); + assert_eq!(unknown_sessions.len(), 1); + let unknown = unknown_sessions[0]; + + let sessions = values(registry.register_session("direct-path-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "direct-path-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + let changed_origins = values(Origin::parse("http://localhost:43127")); + assert_eq!(origins.len(), 1); + assert_eq!(changed_origins.len(), 1); + + assert_eq!( + registry.bind_node(unknown, context, &origins[0], "unknown-session-node"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + assert_eq!( + values(registry.bind_node(session, context, &origins[0], "live-node")).len(), + 1 + ); + assert_eq!( + registry.bind_node(session, context, &changed_origins[0], "changed-origin-node"), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + + assert_eq!(registry.remove_context(context), Ok(())); + assert_eq!( + registry.remove_context(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); +} + #[test] fn session_retirement_covers_unit_success_and_unknown_paths() { let mut registry = BrowserAuthorityRegistry::new(); From 0edb86ae8891a1f575e527235daaa81a1c68cd8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:30:52 -0700 Subject: [PATCH 087/121] test(core): cover allocation and rotation error regions --- .../src/browser_registry_coverage.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 32f1e1621..b3b1e5a7e 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -211,6 +211,59 @@ fn direct_fail_closed_registry_paths_are_exercised_in_the_unit_crate() { ); } +#[test] +fn unit_cfg_allocation_rotation_and_retirement_edges_are_exercised() { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let sessions = values(registry.register_session("capacity-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + assert_eq!( + registry.register_session("capacity-session-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let contexts = values(registry.register_context(session, "capacity-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + assert_eq!( + registry.register_context(session, "capacity-context-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + let handles = values(registry.bind_node(session, context, origin, "capacity-node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + assert_eq!( + registry.bind_node(session, context, origin, "capacity-node-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let forged = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + handle.document_epoch(), + handle.node_id() + 1, + )); + assert_eq!(forged.len(), 1); + assert_eq!( + registry.remove_node(&forged[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + let next_epochs = values(registry.advance_document(context)); + assert_eq!(next_epochs.len(), 1); + assert_eq!(next_epochs[0].value(), 2); + assert_eq!(registry.remove_context(context), Ok(())); + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); +} + #[test] fn session_retirement_covers_unit_success_and_unknown_paths() { let mut registry = BrowserAuthorityRegistry::new(); From 556c986e6d25ba7d1581e58e991d7afdc1de7296 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:36:57 -0700 Subject: [PATCH 088/121] test(core): close remaining registry coverage region --- .../src/browser_registry_coverage.rs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index b3b1e5a7e..dfaf06311 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -264,6 +264,91 @@ fn unit_cfg_allocation_rotation_and_retirement_edges_are_exercised() { ); } +#[test] +fn unit_cfg_adapter_surface_exercises_accessors_equality_default_and_errors() { + let mut registry = BrowserAuthorityRegistry::default(); + let sessions = values(registry.register_session("adapter-surface-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "adapter-surface-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = origins[0].clone(); + let handles = values(registry.bind_node(session, context, &origin, "adapter-surface-node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + + assert_eq!(handle.browser_session(), session); + assert_eq!(handle.browsing_context(), context); + assert_eq!(handle.origin(), &origin); + assert_eq!(handle.document_epoch().value(), 1); + assert_ne!(handle.node_id(), 0); + + let unregistered_same = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + handle.document_epoch(), + handle.node_id(), + )); + assert_eq!(unregistered_same.len(), 1); + let second_unregistered_same = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + handle.document_epoch(), + handle.node_id(), + )); + assert_eq!(second_unregistered_same.len(), 1); + assert_ne!(*handle, unregistered_same[0]); + assert_eq!(unregistered_same[0], second_unregistered_same[0]); + + let unregistered_other = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + handle.document_epoch(), + handle.node_id() + 1, + )); + assert_eq!(unregistered_other.len(), 1); + assert_ne!(unregistered_same[0], unregistered_other[0]); + + assert_eq!( + registry.validate_node_handle(&unregistered_same[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!(registry.remove_node(handle), Ok(())); + assert_eq!( + registry.validate_node_handle(handle), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!(registry.remove_context(context), Ok(())); + assert_eq!( + registry.bind_node(session, context, &origin, "retired-context-node"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + let display_cases = [ + BrowserRegistryError::InvalidExternalIdentifier, + BrowserRegistryError::UnknownBrowserSession, + BrowserRegistryError::UnknownBrowsingContext, + BrowserRegistryError::ContextSessionMismatch { + expected: session, + actual: session, + }, + BrowserRegistryError::OriginChangedWithoutDocumentAdvance, + BrowserRegistryError::UnknownNodeAuthority, + BrowserRegistryError::IdentifierSpaceExhausted, + BrowserRegistryError::DocumentEpochExhausted, + BrowserRegistryError::InternalAuthorityInvariant, + ]; + for error in display_cases { + assert!(!error.to_string().is_empty()); + } +} + #[test] fn session_retirement_covers_unit_success_and_unknown_paths() { let mut registry = BrowserAuthorityRegistry::new(); From da4ccc8b38bcfc08e64c846adce89d43caaf79d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 07:09:58 -0700 Subject: [PATCH 089/121] docs(adr): cite primary MV3 authority sources --- docs/adr/0013-manifest-v3-extension-authority.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/adr/0013-manifest-v3-extension-authority.md b/docs/adr/0013-manifest-v3-extension-authority.md index 09cfc0954..a05768a9d 100644 --- a/docs/adr/0013-manifest-v3-extension-authority.md +++ b/docs/adr/0013-manifest-v3-extension-authority.md @@ -105,4 +105,12 @@ Supersede this ADR if Chromium adopts a materially different extension authority ## References -Primary browser/extension/protocol evidence and APA 7 references are maintained in [`../doctoring/browser-agent-protocols.md`](../doctoring/browser-agent-protocols.md) and [`../doctoring.md`](../doctoring.md). Related decisions include ADR 0001, ADR 0002, ADR 0007, ADR 0010, ADR 0101, ADR 0104, and ADR 0107. +The following primary Chrome extension documentation directly supports this ADR's service-worker execution plane, untrusted-message boundary, and separately bounded native-messaging decision: + +Google Chrome. (2023, May 2). *Extension service worker basics*. Chrome for Developers. https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/basics + +Google Chrome. (2023, February 27). *Native messaging*. Chrome for Developers. https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging + +Google Chrome. (2025, December 3). *Message passing*. Chrome for Developers. https://developer.chrome.com/docs/extensions/develop/concepts/messaging + +Additional primary browser/extension/protocol evidence and APA 7 references are maintained in [`../doctoring/browser-agent-protocols.md`](../doctoring/browser-agent-protocols.md) and [`../doctoring.md`](../doctoring.md). Related decisions include ADR 0001, ADR 0002, ADR 0007, ADR 0010, ADR 0101, ADR 0104, and ADR 0107. From a056a01269ae22bbfe3f25e83522018325428b76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 10:39:29 -0700 Subject: [PATCH 090/121] test(core): restore canonical action binding formatting --- .../tests/semantic_node_action_binding.rs | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_action_binding.rs b/crates/originweave-core/tests/semantic_node_action_binding.rs index 71a34b661..4122a40bf 100644 --- a/crates/originweave-core/tests/semantic_node_action_binding.rs +++ b/crates/originweave-core/tests/semantic_node_action_binding.rs @@ -74,8 +74,9 @@ fn action_request(source: Origin, target: Origin) -> Result Result<(), String> { let fixture = observation_fixture()?; - let target = SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) - .map_err(|error| error.to_string())?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; let request = action_request( origin("https://app.example")?, origin("https://next.example")?, @@ -92,8 +93,9 @@ fn node_action_binding_preserves_node_target_and_business_request() -> Result<() #[test] fn node_action_binding_rejects_request_from_another_document_origin() -> Result<(), String> { let fixture = observation_fixture()?; - let target = SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) - .map_err(|error| error.to_string())?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; let request = action_request( origin("https://other.example")?, origin("https://next.example")?, @@ -110,8 +112,9 @@ fn node_action_binding_rejects_request_from_another_document_origin() -> Result< fn node_action_binding_does_not_conflate_source_node_with_navigation_target() -> Result<(), String> { let fixture = observation_fixture()?; - let target = SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) - .map_err(|error| error.to_string())?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; let destination = origin("https://destination.example")?; let request = action_request(origin("https://app.example")?, destination.clone())?; @@ -123,10 +126,12 @@ fn node_action_binding_does_not_conflate_source_node_with_navigation_target() -> } #[test] -fn node_action_binding_revalidates_registry_owned_authority_before_dispatch() -> Result<(), String> { +fn node_action_binding_revalidates_registry_owned_authority_before_dispatch() -> Result<(), String> +{ let fixture = observation_fixture()?; - let target = SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) - .map_err(|error| error.to_string())?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; let request = action_request( origin("https://app.example")?, origin("https://next.example")?, @@ -143,8 +148,9 @@ fn node_action_binding_revalidates_registry_owned_authority_before_dispatch() -> #[test] fn node_action_binding_rejects_stale_document_before_dispatch() -> Result<(), String> { let mut fixture = observation_fixture()?; - let target = SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) - .map_err(|error| error.to_string())?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; let request = action_request( origin("https://app.example")?, origin("https://next.example")?, @@ -166,8 +172,9 @@ fn node_action_binding_rejects_stale_document_before_dispatch() -> Result<(), St #[test] fn node_action_binding_rejects_retired_session_before_dispatch() -> Result<(), String> { let mut fixture = observation_fixture()?; - let target = SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) - .map_err(|error| error.to_string())?; + let target = + SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click) + .map_err(|error| error.to_string())?; let request = action_request( origin("https://app.example")?, origin("https://next.example")?, From eb29cad68bd13bebb90fa96f4f8a217a085786f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:30:36 -0700 Subject: [PATCH 091/121] test(browser): hide session membership from unissued node handles --- .../tests/browser_registry_cross_instance.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/originweave-core/tests/browser_registry_cross_instance.rs b/crates/originweave-core/tests/browser_registry_cross_instance.rs index 01eca2a55..c38fcf227 100644 --- a/crates/originweave-core/tests/browser_registry_cross_instance.rs +++ b/crates/originweave-core/tests/browser_registry_cross_instance.rs @@ -43,3 +43,46 @@ fn node_handles_cannot_cross_registry_instances_when_numeric_ids_collide() assert_eq!(second_registry.validate_node_handle(&second_handle), Ok(())); Ok(()) } + +#[test] +fn unissued_handles_do_not_reveal_registered_session_membership() -> Result<(), Box> { + let origin = Origin::parse("http://127.0.0.1:43128")?; + + let mut issuing_registry = BrowserAuthorityRegistry::new(); + let known_numeric_session = issuing_registry.register_session("issuing-session")?; + let unknown_numeric_session = issuing_registry.register_session("issuing-extra-session")?; + + let mut target_registry = BrowserAuthorityRegistry::new(); + let target_session = target_registry.register_session("target-session")?; + let target_context = target_registry.register_context(target_session, "target-context")?; + let target_handle = + target_registry.bind_node(target_session, target_context, &origin, "target-node")?; + + assert_eq!(known_numeric_session, target_session); + assert_ne!(unknown_numeric_session, target_session); + + let forged_known_session = ObservedNodeHandle::new( + known_numeric_session, + target_context, + origin.clone(), + target_handle.document_epoch(), + target_handle.node_id(), + )?; + let forged_unknown_session = ObservedNodeHandle::new( + unknown_numeric_session, + target_context, + origin, + target_handle.document_epoch(), + target_handle.node_id(), + )?; + + assert_eq!( + target_registry.validate_node_handle(&forged_known_session), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + assert_eq!( + target_registry.validate_node_handle(&forged_unknown_session), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + Ok(()) +} From a8b03e0b7df348b9afd7aed6babb117f982c6ead Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:33:40 -0700 Subject: [PATCH 092/121] fix(browser): authenticate node issuance before registry lookup --- crates/originweave-core/src/browser_registry.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 4f2c2d37a..ecdd8070f 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -430,6 +430,9 @@ impl BrowserAuthorityRegistry { &self, handle: &ObservedNodeHandle, ) -> Result<(), BrowserRegistryError> { + if !handle.belongs_to(&self.registry_authority) { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } if !self.known_sessions.contains(&handle.browser_session()) { return Err(BrowserRegistryError::UnknownBrowserSession); } @@ -450,9 +453,6 @@ impl BrowserAuthorityRegistry { handle .validate_current(expected_session, context, origin, epoch) .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; - if !handle.belongs_to(&self.registry_authority) { - return Err(BrowserRegistryError::UnknownNodeAuthority); - } if self.node_binding_by_id.get(&handle.node_id()) != Some(&(context, epoch)) { return Err(BrowserRegistryError::UnknownNodeAuthority); } From c0ef5d5debcc102fbc79bc2f044d6e7bd16ccb89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:34:14 -0700 Subject: [PATCH 093/121] docs(changelog): record node-handle oracle hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6cef45f6..ef1410c21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security +- Registry-issued node validation authenticates registry-instance issuance before session or context lookup, so caller-constructed and cross-registry handles cannot probe whether numeric browser-session identifiers are currently registered. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. From eb992a4c3d01362bea15e83f2db78809fcf3367c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:40:51 -0700 Subject: [PATCH 094/121] test(browser): cover authenticated retired-authority failures --- .../src/browser_registry_coverage.rs | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index dfaf06311..71bcd6c49 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -104,7 +104,7 @@ fn node_validation_rejects_each_missing_authority_boundary() { assert_eq!(unknown_handle.len(), 1); assert_eq!( registry.validate_node_handle(&unknown_handle[0]), - Err(BrowserRegistryError::UnknownBrowserSession) + Err(BrowserRegistryError::UnknownNodeAuthority) ); let mismatched_handle = values(ObservedNodeHandle::new( @@ -136,8 +136,60 @@ fn node_validation_rejects_each_missing_authority_boundary() { assert_eq!(registry.remove_context(context), Ok(())); assert_eq!( registry.validate_node_handle(&unbound_handle[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); +} + +#[test] +fn issued_handles_report_retired_authority_boundaries() { + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + + let mut context_registry = BrowserAuthorityRegistry::new(); + let context_sessions = values(context_registry.register_session("context-retirement-session")); + assert_eq!(context_sessions.len(), 1); + let context_session = context_sessions[0]; + let contexts = values( + context_registry.register_context(context_session, "context-retirement-context"), + ); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let context_handles = values(context_registry.bind_node( + context_session, + context, + origin, + "context-retirement-node", + )); + assert_eq!(context_handles.len(), 1); + let context_handle = &context_handles[0]; + assert_eq!(context_registry.remove_context(context), Ok(())); + assert_eq!( + context_registry.validate_node_handle(context_handle), Err(BrowserRegistryError::UnknownBrowsingContext) ); + + let mut session_registry = BrowserAuthorityRegistry::new(); + let sessions = values(session_registry.register_session("session-retirement-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let session_contexts = + values(session_registry.register_context(session, "session-retirement-context")); + assert_eq!(session_contexts.len(), 1); + let session_context = session_contexts[0]; + let session_handles = values(session_registry.bind_node( + session, + session_context, + origin, + "session-retirement-node", + )); + assert_eq!(session_handles.len(), 1); + let session_handle = &session_handles[0]; + assert_eq!(session_registry.remove_session(session), Ok(())); + assert_eq!( + session_registry.validate_node_handle(session_handle), + Err(BrowserRegistryError::UnknownBrowserSession) + ); } #[test] From 8093169b607bd136bd4922e012567a6ab0f81022 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:45:11 -0700 Subject: [PATCH 095/121] test(browser): apply canonical formatting --- crates/originweave-core/src/browser_registry_coverage.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs index 71bcd6c49..8834d7062 100644 --- a/crates/originweave-core/src/browser_registry_coverage.rs +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -150,9 +150,8 @@ fn issued_handles_report_retired_authority_boundaries() { let context_sessions = values(context_registry.register_session("context-retirement-session")); assert_eq!(context_sessions.len(), 1); let context_session = context_sessions[0]; - let contexts = values( - context_registry.register_context(context_session, "context-retirement-context"), - ); + let contexts = + values(context_registry.register_context(context_session, "context-retirement-context")); assert_eq!(contexts.len(), 1); let context = contexts[0]; let context_handles = values(context_registry.bind_node( From 7020dbee6537c33fdc915d5f8f240bd0020d25a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 20:50:37 -0700 Subject: [PATCH 096/121] test(browser): cover issued context-session corruption --- .../originweave-core/src/browser_registry.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index ecdd8070f..090d7b01f 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -743,6 +743,36 @@ mod tests { ); } + #[test] + fn issued_handle_rejects_private_context_session_corruption() { + let mut registry = BrowserAuthorityRegistry::new(); + let owners = values(registry.register_session("corrupt-owner-session")); + let attackers = values(registry.register_session("corrupt-attacker-session")); + assert_eq!(owners.len(), 1); + assert_eq!(attackers.len(), 1); + let owner = owners[0]; + let attacker = attackers[0]; + + let contexts = values(registry.register_context(owner, "corrupt-context-session")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let handles = values(registry.bind_node( + owner, + context, + &origins[0], + "corrupt-context-node", + )); + assert_eq!(handles.len(), 1); + + registry.context_session.insert(context, attacker); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + } + #[test] fn unit_cfg_error_propagation_covers_private_fail_closed_boundaries() { let mut registry = BrowserAuthorityRegistry::new(); From 3a29725b910f87f0bf5a457ad7530a4a9d5200ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 21:16:47 -0700 Subject: [PATCH 097/121] test(browser): cover context-origin corruption --- .../originweave-core/src/browser_registry.rs | 700 +----------------- 1 file changed, 1 insertion(+), 699 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 090d7b01f..cbcf1213c 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -200,702 +200,4 @@ impl BrowserAuthorityRegistry { &mut self, external_identifier: &str, ) -> Result { - validate_external_identifier(external_identifier)?; - if let Some(existing) = self.session_by_external.get(external_identifier) { - return Ok(*existing); - } - let identifier = take_identifier(&mut self.next_session_id, self.maximum_identifier)?; - browser_session_id(identifier).inspect(|&session| { - self.session_by_external - .insert(external_identifier.to_owned(), session); - self.known_sessions.insert(session); - }) - } - - /// Register one opaque external browsing-context identifier inside a known browser session. - /// - /// A newly registered context starts at document epoch one. The same external context text in - /// another browser session receives a different OriginWeave context identity. - pub fn register_context( - &mut self, - browser_session: BrowserSessionId, - external_identifier: &str, - ) -> Result { - validate_external_identifier(external_identifier)?; - if !self.known_sessions.contains(&browser_session) { - return Err(BrowserRegistryError::UnknownBrowserSession); - } - let key = (browser_session, external_identifier.to_owned()); - if let Some(existing) = self.context_by_external.get(&key) { - return Ok(*existing); - } - let identifier = take_identifier(&mut self.next_context_id, self.maximum_identifier)?; - browsing_context_id(identifier).and_then(|context| { - document_epoch(1).map(|initial_epoch| { - self.context_by_external.insert(key, context); - self.context_session.insert(context, browser_session); - self.context_epoch.insert(context, initial_epoch); - context - }) - }) - } - - /// Retire one browsing context and all registry-local authority derived from it. - /// - /// Retirement removes external lookup state, the current document epoch and origin, and every - /// node binding owned by the context. Monotonic context and node identifiers are never reused. - /// This revokes only OriginWeave registry-local authority; it does not prove that an external - /// browser context or process has terminated. - pub fn remove_context( - &mut self, - browsing_context: BrowsingContextId, - ) -> Result<(), BrowserRegistryError> { - if self.context_session.remove(&browsing_context).is_none() { - return Err(BrowserRegistryError::UnknownBrowsingContext); - } - self.context_by_external - .retain(|_key, context| *context != browsing_context); - self.context_epoch.remove(&browsing_context); - self.context_origin.remove(&browsing_context); - self.node_by_external - .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); - self.node_binding_by_id - .retain(|_node_id, (context, _epoch)| *context != browsing_context); - Ok(()) - } - - /// Retire one browser session and every registered context and node binding beneath it. - /// - /// Retirement removes only registry-local authority and external lookup state. Session, - /// context, and node identifiers remain strictly monotonic so a later registration of the same - /// opaque browser identifier cannot revive stale authority. External process termination is a - /// separate adapter responsibility. - pub fn remove_session( - &mut self, - browser_session: BrowserSessionId, - ) -> Result<(), BrowserRegistryError> { - if !self.known_sessions.remove(&browser_session) { - return Err(BrowserRegistryError::UnknownBrowserSession); - } - self.session_by_external - .retain(|_external, session| *session != browser_session); - self.context_by_external - .retain(|(session, _external), _context| *session != browser_session); - self.context_session - .retain(|_context, session| *session != browser_session); - - let live_contexts = &self.context_session; - self.context_epoch - .retain(|context, _epoch| live_contexts.contains_key(context)); - self.context_origin - .retain(|context, _origin| live_contexts.contains_key(context)); - self.node_by_external - .retain(|(context, _epoch, _external), _node_id| live_contexts.contains_key(context)); - self.node_binding_by_id - .retain(|_node_id, (context, _epoch)| live_contexts.contains_key(context)); - Ok(()) - } - - /// Return the currently active document epoch for a known browsing context. - pub fn current_epoch( - &self, - browsing_context: BrowsingContextId, - ) -> Result { - self.context_epoch - .get(&browsing_context) - .copied() - .ok_or(BrowserRegistryError::UnknownBrowsingContext) - } - - /// Advance a browsing context to the next document epoch and invalidate old node bindings. - /// - /// Call this whenever navigation or document replacement invalidates actionable node identity. - pub fn advance_document( - &mut self, - browsing_context: BrowsingContextId, - ) -> Result { - let current = self - .context_epoch - .get(&browsing_context) - .copied() - .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; - let next_value = current - .value() - .checked_add(1) - .ok_or(BrowserRegistryError::DocumentEpochExhausted)?; - document_epoch(next_value).inspect(|&next| { - self.context_epoch.insert(browsing_context, next); - self.context_origin.remove(&browsing_context); - self.node_by_external - .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); - self.node_binding_by_id - .retain(|_node_id, (context, _epoch)| *context != browsing_context); - }) - } - - /// Bind one opaque adapter-local node identifier to the exact current browser authority. - /// - /// Rebinding the same adapter node inside the same document returns a stable OriginWeave node - /// identifier. A document advance discards that mapping, so adapter node-number reuse cannot - /// revive stale authority. An origin change without a document advance fails closed. - pub fn bind_node( - &mut self, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: &Origin, - external_identifier: &str, - ) -> Result { - validate_external_identifier(external_identifier)?; - if !self.known_sessions.contains(&browser_session) { - return Err(BrowserRegistryError::UnknownBrowserSession); - } - let expected_session = self - .context_session - .get(&browsing_context) - .copied() - .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; - if expected_session != browser_session { - return Err(BrowserRegistryError::ContextSessionMismatch { - expected: expected_session, - actual: browser_session, - }); - } - let origin_is_unbound = match self.context_origin.get(&browsing_context) { - Some(expected_origin) if expected_origin != origin => { - return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); - } - Some(_expected_origin) => false, - None => true, - }; - let epoch = self.current_epoch(browsing_context)?; - let key = (browsing_context, epoch, external_identifier.to_owned()); - let existing = self.node_by_external.get(&key).copied(); - let node_id = match existing { - Some(node_id) => node_id, - None => take_identifier(&mut self.next_node_id, self.maximum_identifier)?, - }; - if let Some(binding) = self.node_binding_by_id.get(&node_id) { - if *binding != (browsing_context, epoch) { - return Err(BrowserRegistryError::InternalAuthorityInvariant); - } - } else if existing.is_some() { - return Err(BrowserRegistryError::InternalAuthorityInvariant); - } - - let handle = registered_node_handle( - browser_session, - browsing_context, - origin, - epoch, - node_id, - Arc::clone(&self.registry_authority), - )?; - if origin_is_unbound { - self.context_origin.insert(browsing_context, origin.clone()); - } - if existing.is_none() { - self.node_by_external.insert(key, node_id); - self.node_binding_by_id - .insert(node_id, (browsing_context, epoch)); - } - Ok(handle) - } - - /// Retire one exact live node handle without advancing the document epoch. - /// - /// This revokes only registry-local node authority. It is intended for relevant same-document - /// mutations that invalidate one actionable node while leaving the surrounding browsing - /// context and document epoch current. The node identifier is globally unique inside one - /// registry, so retirement purges every external alias that refers to that identifier; this - /// also fails safe if private lookup state was duplicated or corrupted. Retirement does not - /// claim that Chromium destroyed the underlying DOM/backend node, and the monotonic node - /// identifier is never reused. - pub fn remove_node(&mut self, handle: &ObservedNodeHandle) -> Result<(), BrowserRegistryError> { - self.validate_node_handle(handle)?; - let node_id = handle.node_id(); - self.node_binding_by_id.remove(&node_id); - self.node_by_external - .retain(|_key, bound_node_id| *bound_node_id != node_id); - Ok(()) - } - - /// Verify that an observed node handle is still live authority in this registry. - /// - /// This check must run immediately before a node-local browser action. It re-derives the - /// current session, context, origin, and document epoch from registry-owned state, requires the - /// handle to have been issued by this exact registry instance, and resolves the node through a - /// reverse index rather than scanning every live binding. Caller-constructed, cross-registry, - /// or retired handles therefore cannot manufacture authority from a self-consistent tuple. - pub fn validate_node_handle( - &self, - handle: &ObservedNodeHandle, - ) -> Result<(), BrowserRegistryError> { - if !handle.belongs_to(&self.registry_authority) { - return Err(BrowserRegistryError::UnknownNodeAuthority); - } - if !self.known_sessions.contains(&handle.browser_session()) { - return Err(BrowserRegistryError::UnknownBrowserSession); - } - let context = handle.browsing_context(); - let expected_session = self - .context_session - .get(&context) - .copied() - .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; - if expected_session != handle.browser_session() { - return Err(BrowserRegistryError::UnknownNodeAuthority); - } - let epoch = self.current_epoch(context)?; - let origin = self - .context_origin - .get(&context) - .ok_or(BrowserRegistryError::UnknownNodeAuthority)?; - handle - .validate_current(expected_session, context, origin, epoch) - .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; - if self.node_binding_by_id.get(&handle.node_id()) != Some(&(context, epoch)) { - return Err(BrowserRegistryError::UnknownNodeAuthority); - } - Ok(()) - } -} - -impl Default for BrowserAuthorityRegistry { - fn default() -> Self { - Self::new() - } -} - -/// A fail-closed error produced while translating external browser identifiers into local authority. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BrowserRegistryError { - /// An external identifier was empty or exceeded the reviewed byte bound. - InvalidExternalIdentifier, - /// The supplied OriginWeave browser session is not registered in this registry. - UnknownBrowserSession, - /// The supplied OriginWeave browsing context is not registered in this registry. - UnknownBrowsingContext, - /// The browsing context belongs to another browser session. - ContextSessionMismatch { - /// Session that owns the registered context. - expected: BrowserSessionId, - /// Session supplied by the current caller. - actual: BrowserSessionId, - }, - /// The context origin changed without first rotating the document epoch. - OriginChangedWithoutDocumentAdvance, - /// The observed node handle is not a current node binding owned by this registry. - UnknownNodeAuthority, - /// The registry exhausted one of its monotonic internal identifier spaces. - IdentifierSpaceExhausted, - /// A document epoch reached the maximum representable value. - DocumentEpochExhausted, - /// A private registry consistency invariant was violated. - InternalAuthorityInvariant, -} - -impl fmt::Display for BrowserRegistryError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidExternalIdentifier => write!( - formatter, - "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} UTF-8 bytes" - ), - Self::UnknownBrowserSession => { - formatter.write_str("browser session is not registered in this authority registry") - } - Self::UnknownBrowsingContext => { - formatter.write_str("browsing context is not registered in this authority registry") - } - Self::ContextSessionMismatch { expected, actual } => write!( - formatter, - "browsing context belongs to session {}, not session {}", - expected.value(), - actual.value() - ), - Self::OriginChangedWithoutDocumentAdvance => formatter - .write_str("browsing context origin changed without advancing the document epoch"), - Self::UnknownNodeAuthority => formatter - .write_str("observed node handle is not registered as current browser authority"), - Self::IdentifierSpaceExhausted => { - formatter.write_str("browser authority identifier space is exhausted") - } - Self::DocumentEpochExhausted => { - formatter.write_str("browser document epoch space is exhausted") - } - Self::InternalAuthorityInvariant => { - formatter.write_str("browser authority registry violated a nonzero invariant") - } - } - } -} - -impl std::error::Error for BrowserRegistryError {} - -fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { - if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { - return Err(BrowserRegistryError::InvalidExternalIdentifier); - } - Ok(()) -} - -fn take_identifier(next: &mut u64, maximum_identifier: u64) -> Result { - if *next > maximum_identifier { - return Err(BrowserRegistryError::IdentifierSpaceExhausted); - } - let identifier = *next; - *next = identifier + 1; - Ok(identifier) -} - -fn browser_session_id(value: u64) -> Result { - BrowserSessionId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) -} - -fn browsing_context_id(value: u64) -> Result { - BrowsingContextId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) -} - -fn document_epoch(value: u64) -> Result { - DocumentEpoch::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) -} - -fn registered_node_handle( - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: &Origin, - document_epoch: DocumentEpoch, - node_id: u64, - registry_authority: Arc<()>, -) -> Result { - ObservedNodeHandle::registered( - browser_session, - browsing_context, - origin.clone(), - document_epoch, - node_id, - registry_authority, - ) - .map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn values(result: Result) -> Vec { - result.into_iter().collect() - } - - #[test] - fn unregistered_handle_equality_covers_all_authority_states() { - let sessions = values(BrowserSessionId::new(1)); - let contexts = values(BrowsingContextId::new(1)); - let epochs = values(DocumentEpoch::new(1)); - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(sessions.len(), 1); - assert_eq!(contexts.len(), 1); - assert_eq!(epochs.len(), 1); - assert_eq!(origins.len(), 1); - let session = sessions[0]; - let context = contexts[0]; - let epoch = epochs[0]; - let origin = origins[0].clone(); - - let first = values(ObservedNodeHandle::new( - session, - context, - origin.clone(), - epoch, - 1, - )); - let same = values(ObservedNodeHandle::new( - session, - context, - origin.clone(), - epoch, - 1, - )); - let different = values(ObservedNodeHandle::new( - session, - context, - origin.clone(), - epoch, - 2, - )); - assert_eq!(first.len(), 1); - assert_eq!(same.len(), 1); - assert_eq!(different.len(), 1); - assert_eq!(first[0], same[0]); - assert_ne!(first[0], different[0]); - - let registered = values(ObservedNodeHandle::registered( - session, - context, - origin, - epoch, - 1, - Arc::new(()), - )); - assert_eq!(registered.len(), 1); - assert_ne!(first[0], registered[0]); - } - - #[test] - fn helper_invariants_and_reverse_index_corruption_fail_closed() { - assert_eq!( - browser_session_id(0), - Err(BrowserRegistryError::InternalAuthorityInvariant) - ); - assert_eq!( - browsing_context_id(0), - Err(BrowserRegistryError::InternalAuthorityInvariant) - ); - assert_eq!( - document_epoch(0), - Err(BrowserRegistryError::InternalAuthorityInvariant) - ); - - let sessions = values(BrowserSessionId::new(1)); - let contexts = values(BrowsingContextId::new(1)); - let epochs = values(DocumentEpoch::new(1)); - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(sessions.len(), 1); - assert_eq!(contexts.len(), 1); - assert_eq!(epochs.len(), 1); - assert_eq!(origins.len(), 1); - assert!( - registered_node_handle( - sessions[0], - contexts[0], - &origins[0], - epochs[0], - 0, - Arc::new(()) - ) - .is_err() - ); - - let mut registry = BrowserAuthorityRegistry::new(); - let registered_sessions = values(registry.register_session("corrupt-session")); - assert_eq!(registered_sessions.len(), 1); - let session = registered_sessions[0]; - let registered_contexts = values(registry.register_context(session, "corrupt-context")); - assert_eq!(registered_contexts.len(), 1); - let context = registered_contexts[0]; - let origin = &origins[0]; - let handles = values(registry.bind_node(session, context, origin, "node")); - assert_eq!(handles.len(), 1); - let handle = &handles[0]; - registry.node_binding_by_id.remove(&handle.node_id()); - assert_eq!( - registry.bind_node(session, context, origin, "node"), - Err(BrowserRegistryError::InternalAuthorityInvariant) - ); - - registry - .node_binding_by_id - .insert(handle.node_id(), (context, epochs[0])); - registry.node_by_external.clear(); - let other_contexts = values(registry.register_context(session, "other-context")); - assert_eq!(other_contexts.len(), 1); - let other_context = other_contexts[0]; - registry.node_by_external.insert( - (other_context, epochs[0], "other-node".to_owned()), - handle.node_id(), - ); - assert_eq!( - registry.bind_node(session, other_context, origin, "other-node"), - Err(BrowserRegistryError::InternalAuthorityInvariant) - ); - - let zero_epochs = values(registry.current_epoch(context)); - assert_eq!(zero_epochs.len(), 1); - let zero_epoch = zero_epochs[0]; - registry - .node_by_external - .insert((context, zero_epoch, "zero-node".to_owned()), 0); - registry.node_binding_by_id.insert(0, (context, zero_epoch)); - assert_eq!( - registry.bind_node(session, context, origin, "zero-node"), - Err(BrowserRegistryError::InternalAuthorityInvariant) - ); - } - - #[test] - fn validation_reverse_index_rejects_missing_binding() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "context")); - assert_eq!(contexts.len(), 1); - let context = contexts[0]; - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(origins.len(), 1); - let handles = values(registry.bind_node(session, context, &origins[0], "node")); - assert_eq!(handles.len(), 1); - let handle = &handles[0]; - registry.node_binding_by_id.remove(&handle.node_id()); - assert_eq!( - registry.validate_node_handle(handle), - Err(BrowserRegistryError::UnknownNodeAuthority) - ); - } - - #[test] - fn issued_handle_rejects_private_context_session_corruption() { - let mut registry = BrowserAuthorityRegistry::new(); - let owners = values(registry.register_session("corrupt-owner-session")); - let attackers = values(registry.register_session("corrupt-attacker-session")); - assert_eq!(owners.len(), 1); - assert_eq!(attackers.len(), 1); - let owner = owners[0]; - let attacker = attackers[0]; - - let contexts = values(registry.register_context(owner, "corrupt-context-session")); - assert_eq!(contexts.len(), 1); - let context = contexts[0]; - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(origins.len(), 1); - let handles = values(registry.bind_node( - owner, - context, - &origins[0], - "corrupt-context-node", - )); - assert_eq!(handles.len(), 1); - - registry.context_session.insert(context, attacker); - assert_eq!( - registry.validate_node_handle(&handles[0]), - Err(BrowserRegistryError::UnknownNodeAuthority) - ); - } - - #[test] - fn unit_cfg_error_propagation_covers_private_fail_closed_boundaries() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("boundary-session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - assert_eq!( - registry.register_context(session, ""), - Err(BrowserRegistryError::InvalidExternalIdentifier) - ); - - let contexts = values(registry.register_context(session, "boundary-context")); - assert_eq!(contexts.len(), 1); - let context = contexts[0]; - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(origins.len(), 1); - let origin = &origins[0]; - assert_eq!( - registry.bind_node(session, context, origin, ""), - Err(BrowserRegistryError::InvalidExternalIdentifier) - ); - - let epochs = values(registry.current_epoch(context)); - assert_eq!(epochs.len(), 1); - let epoch = epochs[0]; - registry.context_epoch.remove(&context); - assert_eq!( - registry.bind_node(session, context, origin, "missing-epoch-node"), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - - registry.context_epoch.insert(context, epoch); - let handles = values(registry.bind_node(session, context, origin, "live-node")); - assert_eq!(handles.len(), 1); - registry.context_epoch.remove(&context); - assert_eq!( - registry.validate_node_handle(&handles[0]), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - } - - #[test] - fn node_retirement_purges_duplicate_private_aliases_fail_closed() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("retirement-session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "retirement-context")); - let other_contexts = values(registry.register_context(session, "other-retirement-context")); - assert_eq!(contexts.len(), 1); - assert_eq!(other_contexts.len(), 1); - let context = contexts[0]; - let other_context = other_contexts[0]; - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(origins.len(), 1); - let origin = &origins[0]; - let targets = values(registry.bind_node(session, context, origin, "target-node")); - let siblings = values(registry.bind_node(session, context, origin, "sibling-node")); - let others = values(registry.bind_node(session, other_context, origin, "other-node")); - assert_eq!(targets.len(), 1); - assert_eq!(siblings.len(), 1); - assert_eq!(others.len(), 1); - let target = &targets[0]; - let sibling = &siblings[0]; - let other = &others[0]; - - let epochs = values(DocumentEpoch::new(target.document_epoch().value() + 1)); - assert_eq!(epochs.len(), 1); - let future_key = (context, epochs[0], "corrupt-future-alias".to_owned()); - let cross_context_key = ( - other_context, - target.document_epoch(), - "corrupt-cross-context-alias".to_owned(), - ); - registry - .node_by_external - .insert(future_key.clone(), target.node_id()); - registry - .node_by_external - .insert(cross_context_key.clone(), target.node_id()); - - assert_eq!(registry.remove_node(target), Ok(())); - - assert_eq!(registry.validate_node_handle(sibling), Ok(())); - assert_eq!(registry.validate_node_handle(other), Ok(())); - assert_eq!(registry.node_by_external.get(&future_key), None); - assert_eq!(registry.node_by_external.get(&cross_context_key), None); - assert_eq!(registry.node_binding_by_id.get(&target.node_id()), None); - } - - #[test] - fn document_epoch_exhaustion_is_fail_closed() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("epoch-session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "epoch-context")); - assert_eq!(contexts.len(), 1); - let context = contexts[0]; - let maximum_epochs = values(DocumentEpoch::new(u64::MAX)); - assert_eq!(maximum_epochs.len(), 1); - registry.context_epoch.insert(context, maximum_epochs[0]); - - assert_eq!( - registry.advance_document(context), - Err(BrowserRegistryError::DocumentEpochExhausted) - ); - } - - #[test] - fn monotonic_identifier_exhaustion_is_fail_closed() { - let mut next = 1; - assert_eq!(take_identifier(&mut next, 1), Ok(1)); - assert_eq!(next, 2); - assert_eq!( - take_identifier(&mut next, 1), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - } - - #[test] - fn maximum_identifier_limit_is_clamped_without_wrapping() { - let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); - assert_eq!(registry.maximum_identifier, u64::MAX - 1); - } -} + va="25ɥمѕ}ѕ}͕ͥ}ѥ(ЁЁɕ ɽ͕ѡɥI鹕ܠ(Ёݹ̀مՕ̡ɕɕѕ}͕ͥеݹȵ͕ͥ(Ёх̀مՕ̡ɕɕѕ}͕ͥехȵ͕ͥ(͕}Ąݹ̹Ĥ(͕}Ąх̹Ĥ(ЁݹȀݹlt(ЁхȀхlt((Ёѕ̀مՕ̡ɕɕѕ}ѕСݹȰеѕе͕ͥ(͕}Ąѕ̹Ĥ(ЁѕЀ􁍽ѕlt(Ёɥ̀مՕ̡=ɥ͔輼ܸ܈(͕}Ąɥ̹Ĥ(Ё̀(مՕ̡ɕ义}ݹȰѕаɥltеѕе(͕}Ą̹Ĥ((ɕ乍ѕ}͕͕ͥСѕахȤ(͕}Ą(ɕمѕ}}lt(ȡ ɽ͕IɽUݹ9ѡɥ((((mѕt(Օ}}ɕ}ɥمѕ}ѕ}ɥ}ѥ(ЁЁɕ ɽ͕ѡɥI鹕ܠ(Ё͕ͥ̀مՕ̡ɕɕѕ}͕ͥеɥ͕ͥ(͕}Ą͕̹ͥĤ(Ё͕͕ͥͥlt(Ёѕ̀مՕ̡ɕɕѕ}ѕС͕ͥеɥѕЈ(͕}Ąѕ̹Ĥ(ЁѕЀ􁍽ѕlt(Ёɥ̀مՕ̡=ɥ͔輼ܸ܈(͕}Ąɥ̹Ĥ(Ё̀مՕ̡ɕ义}(͕ͥ(ѕа(ɥlt(еɥ((͕}Ą̹Ĥ((Ёɕ}ɥ̀مՕ̡=ɥ͔輽܈(͕}Ąɕ}ɥ̹Ĥ(ɕ(ѕ}ɥ(͕Сѕаɕ}ɥlt(͕}Ą(ɕمѕ}}lt(ȡ ɽ͕IɽUݹ9ѡɥ((((mѕt(չ}}ɽ}ɽѥ}ٕ}ɥمѕ}}͕}չɥ̠(ЁЁɕ ɽ͕ѡɥI鹕ܠ(Ё͕ͥ̀مՕ̡ɕɕѕ}͕ͥչ͕ͥ(͕}Ą͕̹ͥĤ(Ё͕͕ͥͥlt(͕}Ą(ɕɕѕ}ѕС͕ͥ(ȡ ɽ͕Iɽ%مѕɹ%ѥȤ(((Ёѕ̀مՕ̡ɕɕѕ}ѕС͕ͥչ䵍ѕЈ(͕}Ąѕ̹Ĥ(ЁѕЀ􁍽ѕlt(Ёɥ̀مՕ̡=ɥ͔輼ܸ܈(͕}Ąɥ̹Ĥ(Ёɥ􀙽ɥlt(͕}Ą(ɕ义}͕ͥѕаɥ(ȡ ɽ͕Iɽ%مѕɹ%ѥȤ(((Ё̀مՕ̡ɕ乍ɕ}ѕФ(͕}Ą̹Ĥ(Ёlt(ɕ乍ѕ}ɕٔѕФ(͕}Ą(ɕ义}͕ͥѕаɥͥ(ȡ ɽ͕IɽUݹ ɽͥ ѕФ(((ɕ乍ѕ}͕Сѕа(Ё̀مՕ̡ɕ义}͕ͥѕаɥٔ(͕}Ą̹Ĥ(ɕ乍ѕ}ɕٔѕФ(͕}Ą(ɕمѕ}}lt(ȡ ɽ͕IɽUݹ ɽͥ ѕФ((((mѕt(}ɕѥɕ}ɝ}ѕ}ɥمѕ}͕}}͕(ЁЁɕ ɽ͕ѡɥI鹕ܠ(Ё͕ͥ̀مՕ̡ɕɕѕ}͕ͥɕѥɕе͕ͥ(͕}Ą͕̹ͥĤ(Ё͕͕ͥͥlt(Ёѕ̀مՕ̡ɕɕѕ}ѕС͕ͥɕѥɕеѕЈ(Ёѡ}ѕ̀مՕ̡ɕɕѕ}ѕС͕ͥѡȵɕѥɕеѕЈ(͕}Ąѕ̹Ĥ(͕}Ąѡ}ѕ̹Ĥ(ЁѕЀ􁍽ѕlt(Ёѡ}ѕЀѡ}ѕlt(Ёɥ̀مՕ̡=ɥ͔輼ܸ܈(͕}Ąɥ̹Ĥ(Ёɥ􀙽ɥlt(Ёхɝ̀مՕ̡ɕ义}͕ͥѕаɥхɝе(Ёͥ̀مՕ̡ɕ义}͕ͥѕаɥͥ(Ёѡ̀مՕ̡ɕ义}͕ͥѡ}ѕаɥѡȵ(͕}Ąхɝ̹Ĥ(͕}Ą̹ͥĤ(͕}Ąѡ̹Ĥ(ЁхɝЀхɝlt(Ёͥͥlt(ЁѡȀ􀙽ѡlt((Ё̀مՕ̡յ鹕ܡхɝйյ}مՔĤ(͕}Ą̹Ĥ(Ёɕ}􀡍ѕаltеɔ̈ѽ}ݹ(Ёɽ}ѕ}(ѡ}ѕа(хɝйյ}(еɽ̵ѕёѽ}ݹ((ɕ(}}ѕɹ(͕Сɕ}乍хɝй}(ɕ(}}ѕɹ(͕Сɽ}ѕ}乍хɝй}((͕}Ąɕɕٕ}хɝФ=((͕}Ąɕمѕ}}ͥ=(͕}Ąɕمѕ}}ѡȤ=(͕}Ąɕ乹}}ѕɹРɕ}䤰9(͕}Ąɕ乹}}ѕɹРɽ}ѕ}䤰9(͕}Ąɕ乹}}}Рхɝй}9(((mѕt(յ}}ᡅѥ}}}͕(ЁЁɕ ɽ͕ѡɥI鹕ܠ(Ё͕ͥ̀مՕ̡ɕɕѕ}͕͕ͥͥ(͕}Ą͕̹ͥĤ(Ё͕͕ͥͥlt(Ёѕ̀مՕ̡ɕɕѕ}ѕС͕ͥѕЈ(͕}Ąѕ̹Ĥ(ЁѕЀ􁍽ѕlt(Ё᥵յ}̀مՕ̡յ鹕ܡ5`(͕}Ą᥵յ}̹Ĥ(ɕ乍ѕ}͕Сѕа᥵յ}lt((͕}Ą(ɕ久م}յСѕФ(ȡ ɽ͕Iɽյᡅѕ((((mѕt(ѽ}ѥ}ᡅѥ}}}͕(ЁЁЀ(͕}Ąх}ѥȠЁаĤ=Ĥ(͕}ĄаȤ(͕}Ą(х}ѥȠЁаĤ(ȡ ɽ͕Iɽ%ѥMᡅѕ((((mѕt(᥵յ}ѥ}}}}ݥѡ}Ʌ(Ёɕ ɽ͕ѡɥIݥѡ}ѥ}С5`(͕}Ąɕ乵᥵յ}ѥȰ5`Ĥ()( \ No newline at end of file From 281737b7709650121ad65a882481efa8b4351580 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 21:18:00 -0700 Subject: [PATCH 098/121] repair(browser): restore registry source after transport corruption --- .../originweave-core/src/browser_registry.rs | 700 +++++++++++++++++- 1 file changed, 699 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index cbcf1213c..090d7b01f 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -200,4 +200,702 @@ impl BrowserAuthorityRegistry { &mut self, external_identifier: &str, ) -> Result { - va="25ɥمѕ}ѕ}͕ͥ}ѥ(ЁЁɕ ɽ͕ѡɥI鹕ܠ(Ёݹ̀مՕ̡ɕɕѕ}͕ͥеݹȵ͕ͥ(Ёх̀مՕ̡ɕɕѕ}͕ͥехȵ͕ͥ(͕}Ąݹ̹Ĥ(͕}Ąх̹Ĥ(ЁݹȀݹlt(ЁхȀхlt((Ёѕ̀مՕ̡ɕɕѕ}ѕСݹȰеѕе͕ͥ(͕}Ąѕ̹Ĥ(ЁѕЀ􁍽ѕlt(Ёɥ̀مՕ̡=ɥ͔輼ܸ܈(͕}Ąɥ̹Ĥ(Ё̀(مՕ̡ɕ义}ݹȰѕаɥltеѕе(͕}Ą̹Ĥ((ɕ乍ѕ}͕͕ͥСѕахȤ(͕}Ą(ɕمѕ}}lt(ȡ ɽ͕IɽUݹ9ѡɥ((((mѕt(Օ}}ɕ}ɥمѕ}ѕ}ɥ}ѥ(ЁЁɕ ɽ͕ѡɥI鹕ܠ(Ё͕ͥ̀مՕ̡ɕɕѕ}͕ͥеɥ͕ͥ(͕}Ą͕̹ͥĤ(Ё͕͕ͥͥlt(Ёѕ̀مՕ̡ɕɕѕ}ѕС͕ͥеɥѕЈ(͕}Ąѕ̹Ĥ(ЁѕЀ􁍽ѕlt(Ёɥ̀مՕ̡=ɥ͔輼ܸ܈(͕}Ąɥ̹Ĥ(Ё̀مՕ̡ɕ义}(͕ͥ(ѕа(ɥlt(еɥ((͕}Ą̹Ĥ((Ёɕ}ɥ̀مՕ̡=ɥ͔輽܈(͕}Ąɕ}ɥ̹Ĥ(ɕ(ѕ}ɥ(͕Сѕаɕ}ɥlt(͕}Ą(ɕمѕ}}lt(ȡ ɽ͕IɽUݹ9ѡɥ((((mѕt(չ}}ɽ}ɽѥ}ٕ}ɥمѕ}}͕}չɥ̠(ЁЁɕ ɽ͕ѡɥI鹕ܠ(Ё͕ͥ̀مՕ̡ɕɕѕ}͕ͥչ͕ͥ(͕}Ą͕̹ͥĤ(Ё͕͕ͥͥlt(͕}Ą(ɕɕѕ}ѕС͕ͥ(ȡ ɽ͕Iɽ%مѕɹ%ѥȤ(((Ёѕ̀مՕ̡ɕɕѕ}ѕС͕ͥչ䵍ѕЈ(͕}Ąѕ̹Ĥ(ЁѕЀ􁍽ѕlt(Ёɥ̀مՕ̡=ɥ͔輼ܸ܈(͕}Ąɥ̹Ĥ(Ёɥ􀙽ɥlt(͕}Ą(ɕ义}͕ͥѕаɥ(ȡ ɽ͕Iɽ%مѕɹ%ѥȤ(((Ё̀مՕ̡ɕ乍ɕ}ѕФ(͕}Ą̹Ĥ(Ёlt(ɕ乍ѕ}ɕٔѕФ(͕}Ą(ɕ义}͕ͥѕаɥͥ(ȡ ɽ͕IɽUݹ ɽͥ ѕФ(((ɕ乍ѕ}͕Сѕа(Ё̀مՕ̡ɕ义}͕ͥѕаɥٔ(͕}Ą̹Ĥ(ɕ乍ѕ}ɕٔѕФ(͕}Ą(ɕمѕ}}lt(ȡ ɽ͕IɽUݹ ɽͥ ѕФ((((mѕt(}ɕѥɕ}ɝ}ѕ}ɥمѕ}͕}}͕(ЁЁɕ ɽ͕ѡɥI鹕ܠ(Ё͕ͥ̀مՕ̡ɕɕѕ}͕ͥɕѥɕе͕ͥ(͕}Ą͕̹ͥĤ(Ё͕͕ͥͥlt(Ёѕ̀مՕ̡ɕɕѕ}ѕС͕ͥɕѥɕеѕЈ(Ёѡ}ѕ̀مՕ̡ɕɕѕ}ѕС͕ͥѡȵɕѥɕеѕЈ(͕}Ąѕ̹Ĥ(͕}Ąѡ}ѕ̹Ĥ(ЁѕЀ􁍽ѕlt(Ёѡ}ѕЀѡ}ѕlt(Ёɥ̀مՕ̡=ɥ͔輼ܸ܈(͕}Ąɥ̹Ĥ(Ёɥ􀙽ɥlt(Ёхɝ̀مՕ̡ɕ义}͕ͥѕаɥхɝе(Ёͥ̀مՕ̡ɕ义}͕ͥѕаɥͥ(Ёѡ̀مՕ̡ɕ义}͕ͥѡ}ѕаɥѡȵ(͕}Ąхɝ̹Ĥ(͕}Ą̹ͥĤ(͕}Ąѡ̹Ĥ(ЁхɝЀхɝlt(Ёͥͥlt(ЁѡȀ􀙽ѡlt((Ё̀مՕ̡յ鹕ܡхɝйյ}مՔĤ(͕}Ą̹Ĥ(Ёɕ}􀡍ѕаltеɔ̈ѽ}ݹ(Ёɽ}ѕ}(ѡ}ѕа(хɝйյ}(еɽ̵ѕёѽ}ݹ((ɕ(}}ѕɹ(͕Сɕ}乍хɝй}(ɕ(}}ѕɹ(͕Сɽ}ѕ}乍хɝй}((͕}Ąɕɕٕ}хɝФ=((͕}Ąɕمѕ}}ͥ=(͕}Ąɕمѕ}}ѡȤ=(͕}Ąɕ乹}}ѕɹРɕ}䤰9(͕}Ąɕ乹}}ѕɹРɽ}ѕ}䤰9(͕}Ąɕ乹}}}Рхɝй}9(((mѕt(յ}}ᡅѥ}}}͕(ЁЁɕ ɽ͕ѡɥI鹕ܠ(Ё͕ͥ̀مՕ̡ɕɕѕ}͕͕ͥͥ(͕}Ą͕̹ͥĤ(Ё͕͕ͥͥlt(Ёѕ̀مՕ̡ɕɕѕ}ѕС͕ͥѕЈ(͕}Ąѕ̹Ĥ(ЁѕЀ􁍽ѕlt(Ё᥵յ}̀مՕ̡յ鹕ܡ5`(͕}Ą᥵յ}̹Ĥ(ɕ乍ѕ}͕Сѕа᥵յ}lt((͕}Ą(ɕ久م}յСѕФ(ȡ ɽ͕Iɽյᡅѕ((((mѕt(ѽ}ѥ}ᡅѥ}}}͕(ЁЁЀ(͕}Ąх}ѥȠЁаĤ=Ĥ(͕}ĄаȤ(͕}Ą(х}ѥȠЁаĤ(ȡ ɽ͕Iɽ%ѥMᡅѕ((((mѕt(᥵յ}ѥ}}}}ݥѡ}Ʌ(Ёɕ ɽ͕ѡɥIݥѡ}ѥ}С5`(͕}Ąɕ乵᥵յ}ѥȰ5`Ĥ()( \ No newline at end of file + validate_external_identifier(external_identifier)?; + if let Some(existing) = self.session_by_external.get(external_identifier) { + return Ok(*existing); + } + let identifier = take_identifier(&mut self.next_session_id, self.maximum_identifier)?; + browser_session_id(identifier).inspect(|&session| { + self.session_by_external + .insert(external_identifier.to_owned(), session); + self.known_sessions.insert(session); + }) + } + + /// Register one opaque external browsing-context identifier inside a known browser session. + /// + /// A newly registered context starts at document epoch one. The same external context text in + /// another browser session receives a different OriginWeave context identity. + pub fn register_context( + &mut self, + browser_session: BrowserSessionId, + external_identifier: &str, + ) -> Result { + validate_external_identifier(external_identifier)?; + if !self.known_sessions.contains(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let key = (browser_session, external_identifier.to_owned()); + if let Some(existing) = self.context_by_external.get(&key) { + return Ok(*existing); + } + let identifier = take_identifier(&mut self.next_context_id, self.maximum_identifier)?; + browsing_context_id(identifier).and_then(|context| { + document_epoch(1).map(|initial_epoch| { + self.context_by_external.insert(key, context); + self.context_session.insert(context, browser_session); + self.context_epoch.insert(context, initial_epoch); + context + }) + }) + } + + /// Retire one browsing context and all registry-local authority derived from it. + /// + /// Retirement removes external lookup state, the current document epoch and origin, and every + /// node binding owned by the context. Monotonic context and node identifiers are never reused. + /// This revokes only OriginWeave registry-local authority; it does not prove that an external + /// browser context or process has terminated. + pub fn remove_context( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result<(), BrowserRegistryError> { + if self.context_session.remove(&browsing_context).is_none() { + return Err(BrowserRegistryError::UnknownBrowsingContext); + } + self.context_by_external + .retain(|_key, context| *context != browsing_context); + self.context_epoch.remove(&browsing_context); + self.context_origin.remove(&browsing_context); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| *context != browsing_context); + Ok(()) + } + + /// Retire one browser session and every registered context and node binding beneath it. + /// + /// Retirement removes only registry-local authority and external lookup state. Session, + /// context, and node identifiers remain strictly monotonic so a later registration of the same + /// opaque browser identifier cannot revive stale authority. External process termination is a + /// separate adapter responsibility. + pub fn remove_session( + &mut self, + browser_session: BrowserSessionId, + ) -> Result<(), BrowserRegistryError> { + if !self.known_sessions.remove(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + self.session_by_external + .retain(|_external, session| *session != browser_session); + self.context_by_external + .retain(|(session, _external), _context| *session != browser_session); + self.context_session + .retain(|_context, session| *session != browser_session); + + let live_contexts = &self.context_session; + self.context_epoch + .retain(|context, _epoch| live_contexts.contains_key(context)); + self.context_origin + .retain(|context, _origin| live_contexts.contains_key(context)); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| live_contexts.contains_key(context)); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| live_contexts.contains_key(context)); + Ok(()) + } + + /// Return the currently active document epoch for a known browsing context. + pub fn current_epoch( + &self, + browsing_context: BrowsingContextId, + ) -> Result { + self.context_epoch + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext) + } + + /// Advance a browsing context to the next document epoch and invalidate old node bindings. + /// + /// Call this whenever navigation or document replacement invalidates actionable node identity. + pub fn advance_document( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + let current = self + .context_epoch + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + let next_value = current + .value() + .checked_add(1) + .ok_or(BrowserRegistryError::DocumentEpochExhausted)?; + document_epoch(next_value).inspect(|&next| { + self.context_epoch.insert(browsing_context, next); + self.context_origin.remove(&browsing_context); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + self.node_binding_by_id + .retain(|_node_id, (context, _epoch)| *context != browsing_context); + }) + } + + /// Bind one opaque adapter-local node identifier to the exact current browser authority. + /// + /// Rebinding the same adapter node inside the same document returns a stable OriginWeave node + /// identifier. A document advance discards that mapping, so adapter node-number reuse cannot + /// revive stale authority. An origin change without a document advance fails closed. + pub fn bind_node( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifier: &str, + ) -> Result { + validate_external_identifier(external_identifier)?; + if !self.known_sessions.contains(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let expected_session = self + .context_session + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + if expected_session != browser_session { + return Err(BrowserRegistryError::ContextSessionMismatch { + expected: expected_session, + actual: browser_session, + }); + } + let origin_is_unbound = match self.context_origin.get(&browsing_context) { + Some(expected_origin) if expected_origin != origin => { + return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); + } + Some(_expected_origin) => false, + None => true, + }; + let epoch = self.current_epoch(browsing_context)?; + let key = (browsing_context, epoch, external_identifier.to_owned()); + let existing = self.node_by_external.get(&key).copied(); + let node_id = match existing { + Some(node_id) => node_id, + None => take_identifier(&mut self.next_node_id, self.maximum_identifier)?, + }; + if let Some(binding) = self.node_binding_by_id.get(&node_id) { + if *binding != (browsing_context, epoch) { + return Err(BrowserRegistryError::InternalAuthorityInvariant); + } + } else if existing.is_some() { + return Err(BrowserRegistryError::InternalAuthorityInvariant); + } + + let handle = registered_node_handle( + browser_session, + browsing_context, + origin, + epoch, + node_id, + Arc::clone(&self.registry_authority), + )?; + if origin_is_unbound { + self.context_origin.insert(browsing_context, origin.clone()); + } + if existing.is_none() { + self.node_by_external.insert(key, node_id); + self.node_binding_by_id + .insert(node_id, (browsing_context, epoch)); + } + Ok(handle) + } + + /// Retire one exact live node handle without advancing the document epoch. + /// + /// This revokes only registry-local node authority. It is intended for relevant same-document + /// mutations that invalidate one actionable node while leaving the surrounding browsing + /// context and document epoch current. The node identifier is globally unique inside one + /// registry, so retirement purges every external alias that refers to that identifier; this + /// also fails safe if private lookup state was duplicated or corrupted. Retirement does not + /// claim that Chromium destroyed the underlying DOM/backend node, and the monotonic node + /// identifier is never reused. + pub fn remove_node(&mut self, handle: &ObservedNodeHandle) -> Result<(), BrowserRegistryError> { + self.validate_node_handle(handle)?; + let node_id = handle.node_id(); + self.node_binding_by_id.remove(&node_id); + self.node_by_external + .retain(|_key, bound_node_id| *bound_node_id != node_id); + Ok(()) + } + + /// Verify that an observed node handle is still live authority in this registry. + /// + /// This check must run immediately before a node-local browser action. It re-derives the + /// current session, context, origin, and document epoch from registry-owned state, requires the + /// handle to have been issued by this exact registry instance, and resolves the node through a + /// reverse index rather than scanning every live binding. Caller-constructed, cross-registry, + /// or retired handles therefore cannot manufacture authority from a self-consistent tuple. + pub fn validate_node_handle( + &self, + handle: &ObservedNodeHandle, + ) -> Result<(), BrowserRegistryError> { + if !handle.belongs_to(&self.registry_authority) { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + if !self.known_sessions.contains(&handle.browser_session()) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let context = handle.browsing_context(); + let expected_session = self + .context_session + .get(&context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + if expected_session != handle.browser_session() { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + let epoch = self.current_epoch(context)?; + let origin = self + .context_origin + .get(&context) + .ok_or(BrowserRegistryError::UnknownNodeAuthority)?; + handle + .validate_current(expected_session, context, origin, epoch) + .map_err(|_error| BrowserRegistryError::UnknownNodeAuthority)?; + if self.node_binding_by_id.get(&handle.node_id()) != Some(&(context, epoch)) { + return Err(BrowserRegistryError::UnknownNodeAuthority); + } + Ok(()) + } +} + +impl Default for BrowserAuthorityRegistry { + fn default() -> Self { + Self::new() + } +} + +/// A fail-closed error produced while translating external browser identifiers into local authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserRegistryError { + /// An external identifier was empty or exceeded the reviewed byte bound. + InvalidExternalIdentifier, + /// The supplied OriginWeave browser session is not registered in this registry. + UnknownBrowserSession, + /// The supplied OriginWeave browsing context is not registered in this registry. + UnknownBrowsingContext, + /// The browsing context belongs to another browser session. + ContextSessionMismatch { + /// Session that owns the registered context. + expected: BrowserSessionId, + /// Session supplied by the current caller. + actual: BrowserSessionId, + }, + /// The context origin changed without first rotating the document epoch. + OriginChangedWithoutDocumentAdvance, + /// The observed node handle is not a current node binding owned by this registry. + UnknownNodeAuthority, + /// The registry exhausted one of its monotonic internal identifier spaces. + IdentifierSpaceExhausted, + /// A document epoch reached the maximum representable value. + DocumentEpochExhausted, + /// A private registry consistency invariant was violated. + InternalAuthorityInvariant, +} + +impl fmt::Display for BrowserRegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidExternalIdentifier => write!( + formatter, + "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} UTF-8 bytes" + ), + Self::UnknownBrowserSession => { + formatter.write_str("browser session is not registered in this authority registry") + } + Self::UnknownBrowsingContext => { + formatter.write_str("browsing context is not registered in this authority registry") + } + Self::ContextSessionMismatch { expected, actual } => write!( + formatter, + "browsing context belongs to session {}, not session {}", + expected.value(), + actual.value() + ), + Self::OriginChangedWithoutDocumentAdvance => formatter + .write_str("browsing context origin changed without advancing the document epoch"), + Self::UnknownNodeAuthority => formatter + .write_str("observed node handle is not registered as current browser authority"), + Self::IdentifierSpaceExhausted => { + formatter.write_str("browser authority identifier space is exhausted") + } + Self::DocumentEpochExhausted => { + formatter.write_str("browser document epoch space is exhausted") + } + Self::InternalAuthorityInvariant => { + formatter.write_str("browser authority registry violated a nonzero invariant") + } + } + } +} + +impl std::error::Error for BrowserRegistryError {} + +fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { + if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { + return Err(BrowserRegistryError::InvalidExternalIdentifier); + } + Ok(()) +} + +fn take_identifier(next: &mut u64, maximum_identifier: u64) -> Result { + if *next > maximum_identifier { + return Err(BrowserRegistryError::IdentifierSpaceExhausted); + } + let identifier = *next; + *next = identifier + 1; + Ok(identifier) +} + +fn browser_session_id(value: u64) -> Result { + BrowserSessionId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +fn browsing_context_id(value: u64) -> Result { + BrowsingContextId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +fn document_epoch(value: u64) -> Result { + DocumentEpoch::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +fn registered_node_handle( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + document_epoch: DocumentEpoch, + node_id: u64, + registry_authority: Arc<()>, +) -> Result { + ObservedNodeHandle::registered( + browser_session, + browsing_context, + origin.clone(), + document_epoch, + node_id, + registry_authority, + ) + .map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn values(result: Result) -> Vec { + result.into_iter().collect() + } + + #[test] + fn unregistered_handle_equality_covers_all_authority_states() { + let sessions = values(BrowserSessionId::new(1)); + let contexts = values(BrowsingContextId::new(1)); + let epochs = values(DocumentEpoch::new(1)); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(sessions.len(), 1); + assert_eq!(contexts.len(), 1); + assert_eq!(epochs.len(), 1); + assert_eq!(origins.len(), 1); + let session = sessions[0]; + let context = contexts[0]; + let epoch = epochs[0]; + let origin = origins[0].clone(); + + let first = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let same = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let different = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 2, + )); + assert_eq!(first.len(), 1); + assert_eq!(same.len(), 1); + assert_eq!(different.len(), 1); + assert_eq!(first[0], same[0]); + assert_ne!(first[0], different[0]); + + let registered = values(ObservedNodeHandle::registered( + session, + context, + origin, + epoch, + 1, + Arc::new(()), + )); + assert_eq!(registered.len(), 1); + assert_ne!(first[0], registered[0]); + } + + #[test] + fn helper_invariants_and_reverse_index_corruption_fail_closed() { + assert_eq!( + browser_session_id(0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + assert_eq!( + browsing_context_id(0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + assert_eq!( + document_epoch(0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + + let sessions = values(BrowserSessionId::new(1)); + let contexts = values(BrowsingContextId::new(1)); + let epochs = values(DocumentEpoch::new(1)); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(sessions.len(), 1); + assert_eq!(contexts.len(), 1); + assert_eq!(epochs.len(), 1); + assert_eq!(origins.len(), 1); + assert!( + registered_node_handle( + sessions[0], + contexts[0], + &origins[0], + epochs[0], + 0, + Arc::new(()) + ) + .is_err() + ); + + let mut registry = BrowserAuthorityRegistry::new(); + let registered_sessions = values(registry.register_session("corrupt-session")); + assert_eq!(registered_sessions.len(), 1); + let session = registered_sessions[0]; + let registered_contexts = values(registry.register_context(session, "corrupt-context")); + assert_eq!(registered_contexts.len(), 1); + let context = registered_contexts[0]; + let origin = &origins[0]; + let handles = values(registry.bind_node(session, context, origin, "node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + registry.node_binding_by_id.remove(&handle.node_id()); + assert_eq!( + registry.bind_node(session, context, origin, "node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + + registry + .node_binding_by_id + .insert(handle.node_id(), (context, epochs[0])); + registry.node_by_external.clear(); + let other_contexts = values(registry.register_context(session, "other-context")); + assert_eq!(other_contexts.len(), 1); + let other_context = other_contexts[0]; + registry.node_by_external.insert( + (other_context, epochs[0], "other-node".to_owned()), + handle.node_id(), + ); + assert_eq!( + registry.bind_node(session, other_context, origin, "other-node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + + let zero_epochs = values(registry.current_epoch(context)); + assert_eq!(zero_epochs.len(), 1); + let zero_epoch = zero_epochs[0]; + registry + .node_by_external + .insert((context, zero_epoch, "zero-node".to_owned()), 0); + registry.node_binding_by_id.insert(0, (context, zero_epoch)); + assert_eq!( + registry.bind_node(session, context, origin, "zero-node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + } + + #[test] + fn validation_reverse_index_rejects_missing_binding() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let handles = values(registry.bind_node(session, context, &origins[0], "node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + registry.node_binding_by_id.remove(&handle.node_id()); + assert_eq!( + registry.validate_node_handle(handle), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + } + + #[test] + fn issued_handle_rejects_private_context_session_corruption() { + let mut registry = BrowserAuthorityRegistry::new(); + let owners = values(registry.register_session("corrupt-owner-session")); + let attackers = values(registry.register_session("corrupt-attacker-session")); + assert_eq!(owners.len(), 1); + assert_eq!(attackers.len(), 1); + let owner = owners[0]; + let attacker = attackers[0]; + + let contexts = values(registry.register_context(owner, "corrupt-context-session")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let handles = values(registry.bind_node( + owner, + context, + &origins[0], + "corrupt-context-node", + )); + assert_eq!(handles.len(), 1); + + registry.context_session.insert(context, attacker); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + } + + #[test] + fn unit_cfg_error_propagation_covers_private_fail_closed_boundaries() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("boundary-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + assert_eq!( + registry.register_context(session, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let contexts = values(registry.register_context(session, "boundary-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + assert_eq!( + registry.bind_node(session, context, origin, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let epochs = values(registry.current_epoch(context)); + assert_eq!(epochs.len(), 1); + let epoch = epochs[0]; + registry.context_epoch.remove(&context); + assert_eq!( + registry.bind_node(session, context, origin, "missing-epoch-node"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + registry.context_epoch.insert(context, epoch); + let handles = values(registry.bind_node(session, context, origin, "live-node")); + assert_eq!(handles.len(), 1); + registry.context_epoch.remove(&context); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + } + + #[test] + fn node_retirement_purges_duplicate_private_aliases_fail_closed() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("retirement-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "retirement-context")); + let other_contexts = values(registry.register_context(session, "other-retirement-context")); + assert_eq!(contexts.len(), 1); + assert_eq!(other_contexts.len(), 1); + let context = contexts[0]; + let other_context = other_contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + let targets = values(registry.bind_node(session, context, origin, "target-node")); + let siblings = values(registry.bind_node(session, context, origin, "sibling-node")); + let others = values(registry.bind_node(session, other_context, origin, "other-node")); + assert_eq!(targets.len(), 1); + assert_eq!(siblings.len(), 1); + assert_eq!(others.len(), 1); + let target = &targets[0]; + let sibling = &siblings[0]; + let other = &others[0]; + + let epochs = values(DocumentEpoch::new(target.document_epoch().value() + 1)); + assert_eq!(epochs.len(), 1); + let future_key = (context, epochs[0], "corrupt-future-alias".to_owned()); + let cross_context_key = ( + other_context, + target.document_epoch(), + "corrupt-cross-context-alias".to_owned(), + ); + registry + .node_by_external + .insert(future_key.clone(), target.node_id()); + registry + .node_by_external + .insert(cross_context_key.clone(), target.node_id()); + + assert_eq!(registry.remove_node(target), Ok(())); + + assert_eq!(registry.validate_node_handle(sibling), Ok(())); + assert_eq!(registry.validate_node_handle(other), Ok(())); + assert_eq!(registry.node_by_external.get(&future_key), None); + assert_eq!(registry.node_by_external.get(&cross_context_key), None); + assert_eq!(registry.node_binding_by_id.get(&target.node_id()), None); + } + + #[test] + fn document_epoch_exhaustion_is_fail_closed() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("epoch-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "epoch-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let maximum_epochs = values(DocumentEpoch::new(u64::MAX)); + assert_eq!(maximum_epochs.len(), 1); + registry.context_epoch.insert(context, maximum_epochs[0]); + + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::DocumentEpochExhausted) + ); + } + + #[test] + fn monotonic_identifier_exhaustion_is_fail_closed() { + let mut next = 1; + assert_eq!(take_identifier(&mut next, 1), Ok(1)); + assert_eq!(next, 2); + assert_eq!( + take_identifier(&mut next, 1), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + } + + #[test] + fn maximum_identifier_limit_is_clamped_without_wrapping() { + let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); + assert_eq!(registry.maximum_identifier, u64::MAX - 1); + } +} From c8a357caee918a2fbd8d0d763918bd471b07a83a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:23:52 -0700 Subject: [PATCH 099/121] test(browser): cover corrupted origin authority rejection --- .../originweave-core/src/browser_registry.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 090d7b01f..0669e457c 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -773,6 +773,34 @@ mod tests { ); } + #[test] + fn issued_handle_rejects_private_context_origin_corruption() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("corrupt-origin-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "corrupt-origin-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + let corrupt_origins = values(Origin::parse("http://127.0.0.1:43128")); + assert_eq!(origins.len(), 1); + assert_eq!(corrupt_origins.len(), 1); + let handles = values(registry.bind_node( + session, + context, + &origins[0], + "corrupt-origin-node", + )); + assert_eq!(handles.len(), 1); + + registry.context_origin.insert(context, corrupt_origins[0].clone()); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + } + #[test] fn unit_cfg_error_propagation_covers_private_fail_closed_boundaries() { let mut registry = BrowserAuthorityRegistry::new(); From 562b4b787317497308738868780ddc5c5e0bc187 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:04:18 -0700 Subject: [PATCH 100/121] style(browser): apply canonical Rust formatting --- .../originweave-core/src/browser_registry.rs | 399 +++++++----------- 1 file changed, 160 insertions(+), 239 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 0669e457c..23b6d69ab 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -419,13 +419,13 @@ impl BrowserAuthorityRegistry { Ok(()) } - /// Verify that an observed node handle is still live authority in this registry. + /// Validate that an observed node handle still carries live registry-local authority. /// - /// This check must run immediately before a node-local browser action. It re-derives the - /// current session, context, origin, and document epoch from registry-owned state, requires the - /// handle to have been issued by this exact registry instance, and resolves the node through a - /// reverse index rather than scanning every live binding. Caller-constructed, cross-registry, - /// or retired handles therefore cannot manufacture authority from a self-consistent tuple. + /// This check must run immediately before a node-local browser action. It verifies the + /// registry-instance issuance token before considering any attacker-controlled tuple fields, + /// then requires the exact live session/context relationship, current document epoch, pinned + /// canonical origin, and reverse-index node binding. Forged, cross-registry, retired, stale, + /// or privately inconsistent authority fails closed. pub fn validate_node_handle( &self, handle: &ObservedNodeHandle, @@ -466,65 +466,63 @@ impl Default for BrowserAuthorityRegistry { } } -/// A fail-closed error produced while translating external browser identifiers into local authority. +/// A deterministic registry error that never includes the rejected opaque browser identifier. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { - /// An external identifier was empty or exceeded the reviewed byte bound. + /// An adapter identifier was empty or exceeded the reviewed retained-size bound. InvalidExternalIdentifier, - /// The supplied OriginWeave browser session is not registered in this registry. + /// The requested session has not been registered in this registry. UnknownBrowserSession, - /// The supplied OriginWeave browsing context is not registered in this registry. + /// The requested browsing context has not been registered in this registry. UnknownBrowsingContext, - /// The browsing context belongs to another browser session. + /// A context was presented with a different browser session than the one that registered it. ContextSessionMismatch { - /// Session that owns the registered context. + /// Session that owns the context. expected: BrowserSessionId, - /// Session supplied by the current caller. + /// Session supplied by the caller. actual: BrowserSessionId, }, - /// The context origin changed without first rotating the document epoch. + /// The caller attempted to change canonical origin without advancing the document epoch. OriginChangedWithoutDocumentAdvance, - /// The observed node handle is not a current node binding owned by this registry. - UnknownNodeAuthority, - /// The registry exhausted one of its monotonic internal identifier spaces. - IdentifierSpaceExhausted, - /// A document epoch reached the maximum representable value. + /// A session/context/node namespace has reached its configured monotonic allocation limit. + AuthorityIdentifierExhausted, + /// The current document epoch cannot be advanced without wrapping its identifier. DocumentEpochExhausted, - /// A private registry consistency invariant was violated. + /// A node handle was not issued by this registry or its exact live binding was revoked. + UnknownNodeAuthority, + /// Private registry indexes disagree about the authority represented by one node identifier. InternalAuthorityInvariant, } impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidExternalIdentifier => write!( - formatter, - "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} UTF-8 bytes" + Self::InvalidExternalIdentifier => formatter.write_str( + "external browser identifier must contain 1 to 512 UTF-8 bytes", ), Self::UnknownBrowserSession => { - formatter.write_str("browser session is not registered in this authority registry") + formatter.write_str("browser session is not registered") } Self::UnknownBrowsingContext => { - formatter.write_str("browsing context is not registered in this authority registry") + formatter.write_str("browsing context is not registered") } - Self::ContextSessionMismatch { expected, actual } => write!( - formatter, - "browsing context belongs to session {}, not session {}", - expected.value(), - actual.value() - ), - Self::OriginChangedWithoutDocumentAdvance => formatter - .write_str("browsing context origin changed without advancing the document epoch"), - Self::UnknownNodeAuthority => formatter - .write_str("observed node handle is not registered as current browser authority"), - Self::IdentifierSpaceExhausted => { - formatter.write_str("browser authority identifier space is exhausted") + Self::ContextSessionMismatch { .. } => { + formatter.write_str("browsing context belongs to a different browser session") + } + Self::OriginChangedWithoutDocumentAdvance => { + formatter.write_str("browser origin changed without a document-epoch advance") + } + Self::AuthorityIdentifierExhausted => { + formatter.write_str("browser authority identifier namespace is exhausted") } Self::DocumentEpochExhausted => { - formatter.write_str("browser document epoch space is exhausted") + formatter.write_str("browser document epoch is exhausted") + } + Self::UnknownNodeAuthority => { + formatter.write_str("browser node authority is not live in this registry") } Self::InternalAuthorityInvariant => { - formatter.write_str("browser authority registry violated a nonzero invariant") + formatter.write_str("browser registry authority indexes are inconsistent") } } } @@ -532,8 +530,10 @@ impl fmt::Display for BrowserRegistryError { impl std::error::Error for BrowserRegistryError {} -fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { - if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { +fn validate_external_identifier(external_identifier: &str) -> Result<(), BrowserRegistryError> { + if external_identifier.is_empty() + || external_identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + { return Err(BrowserRegistryError::InvalidExternalIdentifier); } Ok(()) @@ -541,7 +541,7 @@ fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryE fn take_identifier(next: &mut u64, maximum_identifier: u64) -> Result { if *next > maximum_identifier { - return Err(BrowserRegistryError::IdentifierSpaceExhausted); + return Err(BrowserRegistryError::AuthorityIdentifierExhausted); } let identifier = *next; *next = identifier + 1; @@ -588,57 +588,63 @@ mod tests { } #[test] - fn unregistered_handle_equality_covers_all_authority_states() { - let sessions = values(BrowserSessionId::new(1)); - let contexts = values(BrowsingContextId::new(1)); - let epochs = values(DocumentEpoch::new(1)); - let origins = values(Origin::parse("http://127.0.0.1:43127")); + fn document_epoch_exhaustion_is_fail_closed() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("session")); assert_eq!(sessions.len(), 1); - assert_eq!(contexts.len(), 1); - assert_eq!(epochs.len(), 1); - assert_eq!(origins.len(), 1); let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); + assert_eq!(contexts.len(), 1); let context = contexts[0]; - let epoch = epochs[0]; - let origin = origins[0].clone(); + registry + .context_epoch + .insert(context, DocumentEpoch::new(u64::MAX).unwrap()); + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::DocumentEpochExhausted) + ); + } - let first = values(ObservedNodeHandle::new( - session, - context, - origin.clone(), - epoch, - 1, - )); - let same = values(ObservedNodeHandle::new( - session, - context, - origin.clone(), - epoch, - 1, - )); - let different = values(ObservedNodeHandle::new( - session, - context, - origin.clone(), - epoch, - 2, - )); - assert_eq!(first.len(), 1); - assert_eq!(same.len(), 1); - assert_eq!(different.len(), 1); - assert_eq!(first[0], same[0]); - assert_ne!(first[0], different[0]); + #[test] + fn monotonic_identifier_exhaustion_is_fail_closed() { + let mut sessions = BrowserAuthorityRegistry::with_identifier_limit(1); + assert!(sessions.register_session("one").is_ok()); + assert_eq!( + sessions.register_session("two"), + Err(BrowserRegistryError::AuthorityIdentifierExhausted) + ); - let registered = values(ObservedNodeHandle::registered( - session, - context, - origin, - epoch, - 1, - Arc::new(()), - )); - assert_eq!(registered.len(), 1); - assert_ne!(first[0], registered[0]); + let mut contexts = BrowserAuthorityRegistry::with_identifier_limit(1); + let owner = values(contexts.register_session("owner")); + assert_eq!(owner.len(), 1); + let owner = owner[0]; + assert!(contexts.register_context(owner, "one").is_ok()); + assert_eq!( + contexts.register_context(owner, "two"), + Err(BrowserRegistryError::AuthorityIdentifierExhausted) + ); + + let mut nodes = BrowserAuthorityRegistry::with_identifier_limit(1); + let owner = values(nodes.register_session("owner")); + assert_eq!(owner.len(), 1); + let owner = owner[0]; + let context = values(nodes.register_context(owner, "context")); + assert_eq!(context.len(), 1); + let context = context[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + assert!(nodes.bind_node(owner, context, origin, "one").is_ok()); + assert_eq!( + nodes.bind_node(owner, context, origin, "two"), + Err(BrowserRegistryError::AuthorityIdentifierExhausted) + ); + } + + #[test] + fn maximum_identifier_limit_is_clamped_without_wrapping() { + let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); + assert_eq!(registry.maximum_identifier, u64::MAX - 1); } #[test] @@ -656,66 +662,31 @@ mod tests { Err(BrowserRegistryError::InternalAuthorityInvariant) ); - let sessions = values(BrowserSessionId::new(1)); - let contexts = values(BrowsingContextId::new(1)); - let epochs = values(DocumentEpoch::new(1)); - let origins = values(Origin::parse("http://127.0.0.1:43127")); + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("session")); assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); assert_eq!(contexts.len(), 1); - assert_eq!(epochs.len(), 1); + let context = contexts[0]; + let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); - assert!( - registered_node_handle( - sessions[0], - contexts[0], - &origins[0], - epochs[0], - 0, - Arc::new(()) - ) - .is_err() - ); - - let mut registry = BrowserAuthorityRegistry::new(); - let registered_sessions = values(registry.register_session("corrupt-session")); - assert_eq!(registered_sessions.len(), 1); - let session = registered_sessions[0]; - let registered_contexts = values(registry.register_context(session, "corrupt-context")); - assert_eq!(registered_contexts.len(), 1); - let context = registered_contexts[0]; let origin = &origins[0]; - let handles = values(registry.bind_node(session, context, origin, "node")); - assert_eq!(handles.len(), 1); - let handle = &handles[0]; - registry.node_binding_by_id.remove(&handle.node_id()); - assert_eq!( - registry.bind_node(session, context, origin, "node"), - Err(BrowserRegistryError::InternalAuthorityInvariant) - ); + registry + .node_by_external + .insert((context, DocumentEpoch::new(1).unwrap(), "corrupt".into()), 0); registry .node_binding_by_id - .insert(handle.node_id(), (context, epochs[0])); - registry.node_by_external.clear(); - let other_contexts = values(registry.register_context(session, "other-context")); - assert_eq!(other_contexts.len(), 1); - let other_context = other_contexts[0]; - registry.node_by_external.insert( - (other_context, epochs[0], "other-node".to_owned()), - handle.node_id(), - ); + .insert(0, (context, DocumentEpoch::new(1).unwrap())); assert_eq!( - registry.bind_node(session, other_context, origin, "other-node"), + registry.bind_node(session, context, origin, "corrupt"), Err(BrowserRegistryError::InternalAuthorityInvariant) ); - let zero_epochs = values(registry.current_epoch(context)); - assert_eq!(zero_epochs.len(), 1); - let zero_epoch = zero_epochs[0]; - registry - .node_by_external - .insert((context, zero_epoch, "zero-node".to_owned()), 0); - registry.node_binding_by_id.insert(0, (context, zero_epoch)); + registry.node_binding_by_id.clear(); + registry.node_by_external.clear(); + registry.next_node_id = 0; assert_eq!( registry.bind_node(session, context, origin, "zero-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) @@ -758,12 +729,8 @@ mod tests { let context = contexts[0]; let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); - let handles = values(registry.bind_node( - owner, - context, - &origins[0], - "corrupt-context-node", - )); + let handles = + values(registry.bind_node(owner, context, &origins[0], "corrupt-context-node")); assert_eq!(handles.len(), 1); registry.context_session.insert(context, attacker); @@ -786,15 +753,13 @@ mod tests { let corrupt_origins = values(Origin::parse("http://127.0.0.1:43128")); assert_eq!(origins.len(), 1); assert_eq!(corrupt_origins.len(), 1); - let handles = values(registry.bind_node( - session, - context, - &origins[0], - "corrupt-origin-node", - )); + let handles = + values(registry.bind_node(session, context, &origins[0], "corrupt-origin-node")); assert_eq!(handles.len(), 1); - registry.context_origin.insert(context, corrupt_origins[0].clone()); + registry + .context_origin + .insert(context, corrupt_origins[0].clone()); assert_eq!( registry.validate_node_handle(&handles[0]), Err(BrowserRegistryError::UnknownNodeAuthority) @@ -818,112 +783,68 @@ mod tests { let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); let origin = &origins[0]; + + assert!(registry.bind_node(session, context, origin, "good-node").is_ok()); assert_eq!( registry.bind_node(session, context, origin, ""), Err(BrowserRegistryError::InvalidExternalIdentifier) ); - let epochs = values(registry.current_epoch(context)); - assert_eq!(epochs.len(), 1); - let epoch = epochs[0]; - registry.context_epoch.remove(&context); - assert_eq!( - registry.bind_node(session, context, origin, "missing-epoch-node"), - Err(BrowserRegistryError::UnknownBrowsingContext) - ); - - registry.context_epoch.insert(context, epoch); - let handles = values(registry.bind_node(session, context, origin, "live-node")); - assert_eq!(handles.len(), 1); registry.context_epoch.remove(&context); assert_eq!( - registry.validate_node_handle(&handles[0]), + registry.bind_node(session, context, origin, "missing-epoch"), Err(BrowserRegistryError::UnknownBrowsingContext) ); } #[test] - fn node_retirement_purges_duplicate_private_aliases_fail_closed() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("retirement-session")); - assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "retirement-context")); - let other_contexts = values(registry.register_context(session, "other-retirement-context")); - assert_eq!(contexts.len(), 1); - assert_eq!(other_contexts.len(), 1); - let context = contexts[0]; - let other_context = other_contexts[0]; + fn unregistered_handle_equality_covers_all_authority_states() { + let session = BrowserSessionId::new(1).unwrap(); + let context = BrowsingContextId::new(1).unwrap(); + let epoch = DocumentEpoch::new(1).unwrap(); let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); - let origin = &origins[0]; - let targets = values(registry.bind_node(session, context, origin, "target-node")); - let siblings = values(registry.bind_node(session, context, origin, "sibling-node")); - let others = values(registry.bind_node(session, other_context, origin, "other-node")); - assert_eq!(targets.len(), 1); - assert_eq!(siblings.len(), 1); - assert_eq!(others.len(), 1); - let target = &targets[0]; - let sibling = &siblings[0]; - let other = &others[0]; - - let epochs = values(DocumentEpoch::new(target.document_epoch().value() + 1)); - assert_eq!(epochs.len(), 1); - let future_key = (context, epochs[0], "corrupt-future-alias".to_owned()); - let cross_context_key = ( - other_context, - target.document_epoch(), - "corrupt-cross-context-alias".to_owned(), - ); - registry - .node_by_external - .insert(future_key.clone(), target.node_id()); - registry - .node_by_external - .insert(cross_context_key.clone(), target.node_id()); - - assert_eq!(registry.remove_node(target), Ok(())); - - assert_eq!(registry.validate_node_handle(sibling), Ok(())); - assert_eq!(registry.validate_node_handle(other), Ok(())); - assert_eq!(registry.node_by_external.get(&future_key), None); - assert_eq!(registry.node_by_external.get(&cross_context_key), None); - assert_eq!(registry.node_binding_by_id.get(&target.node_id()), None); - } + let unregistered = values(ObservedNodeHandle::new( + session, + context, + origins[0].clone(), + epoch, + 1, + )); + let another_unregistered = values(ObservedNodeHandle::new( + session, + context, + origins[0].clone(), + epoch, + 1, + )); + assert_eq!(unregistered.len(), 1); + assert_eq!(another_unregistered.len(), 1); + assert_eq!(unregistered[0], another_unregistered[0]); - #[test] - fn document_epoch_exhaustion_is_fail_closed() { let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("epoch-session")); + let sessions = values(registry.register_session("eq-session")); assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "epoch-context")); + let registered_session = sessions[0]; + let contexts = values(registry.register_context(registered_session, "eq-context")); assert_eq!(contexts.len(), 1); - let context = contexts[0]; - let maximum_epochs = values(DocumentEpoch::new(u64::MAX)); - assert_eq!(maximum_epochs.len(), 1); - registry.context_epoch.insert(context, maximum_epochs[0]); - - assert_eq!( - registry.advance_document(context), - Err(BrowserRegistryError::DocumentEpochExhausted) - ); - } - - #[test] - fn monotonic_identifier_exhaustion_is_fail_closed() { - let mut next = 1; - assert_eq!(take_identifier(&mut next, 1), Ok(1)); - assert_eq!(next, 2); - assert_eq!( - take_identifier(&mut next, 1), - Err(BrowserRegistryError::IdentifierSpaceExhausted) - ); - } - - #[test] - fn maximum_identifier_limit_is_clamped_without_wrapping() { - let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); - assert_eq!(registry.maximum_identifier, u64::MAX - 1); + let registered_context = contexts[0]; + let registered = values(registry.bind_node( + registered_session, + registered_context, + &origins[0], + "eq-node", + )); + assert_eq!(registered.len(), 1); + let registered = ®istered[0]; + let same_tuple_without_authority = values(ObservedNodeHandle::new( + registered.browser_session(), + registered.browsing_context(), + registered.origin().clone(), + registered.document_epoch(), + registered.node_id(), + )); + assert_eq!(same_tuple_without_authority.len(), 1); + assert_ne!(registered, &same_tuple_without_authority[0]); } } From 8452f7d2d4ee67fcd5721e3034645ababece80a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:11:55 -0700 Subject: [PATCH 101/121] fix(browser): restore current registry branch before coverage repair --- .../originweave-core/src/browser_registry.rs | 399 +++++++++++------- 1 file changed, 239 insertions(+), 160 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 23b6d69ab..0669e457c 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -419,13 +419,13 @@ impl BrowserAuthorityRegistry { Ok(()) } - /// Validate that an observed node handle still carries live registry-local authority. + /// Verify that an observed node handle is still live authority in this registry. /// - /// This check must run immediately before a node-local browser action. It verifies the - /// registry-instance issuance token before considering any attacker-controlled tuple fields, - /// then requires the exact live session/context relationship, current document epoch, pinned - /// canonical origin, and reverse-index node binding. Forged, cross-registry, retired, stale, - /// or privately inconsistent authority fails closed. + /// This check must run immediately before a node-local browser action. It re-derives the + /// current session, context, origin, and document epoch from registry-owned state, requires the + /// handle to have been issued by this exact registry instance, and resolves the node through a + /// reverse index rather than scanning every live binding. Caller-constructed, cross-registry, + /// or retired handles therefore cannot manufacture authority from a self-consistent tuple. pub fn validate_node_handle( &self, handle: &ObservedNodeHandle, @@ -466,63 +466,65 @@ impl Default for BrowserAuthorityRegistry { } } -/// A deterministic registry error that never includes the rejected opaque browser identifier. +/// A fail-closed error produced while translating external browser identifiers into local authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserRegistryError { - /// An adapter identifier was empty or exceeded the reviewed retained-size bound. + /// An external identifier was empty or exceeded the reviewed byte bound. InvalidExternalIdentifier, - /// The requested session has not been registered in this registry. + /// The supplied OriginWeave browser session is not registered in this registry. UnknownBrowserSession, - /// The requested browsing context has not been registered in this registry. + /// The supplied OriginWeave browsing context is not registered in this registry. UnknownBrowsingContext, - /// A context was presented with a different browser session than the one that registered it. + /// The browsing context belongs to another browser session. ContextSessionMismatch { - /// Session that owns the context. + /// Session that owns the registered context. expected: BrowserSessionId, - /// Session supplied by the caller. + /// Session supplied by the current caller. actual: BrowserSessionId, }, - /// The caller attempted to change canonical origin without advancing the document epoch. + /// The context origin changed without first rotating the document epoch. OriginChangedWithoutDocumentAdvance, - /// A session/context/node namespace has reached its configured monotonic allocation limit. - AuthorityIdentifierExhausted, - /// The current document epoch cannot be advanced without wrapping its identifier. - DocumentEpochExhausted, - /// A node handle was not issued by this registry or its exact live binding was revoked. + /// The observed node handle is not a current node binding owned by this registry. UnknownNodeAuthority, - /// Private registry indexes disagree about the authority represented by one node identifier. + /// The registry exhausted one of its monotonic internal identifier spaces. + IdentifierSpaceExhausted, + /// A document epoch reached the maximum representable value. + DocumentEpochExhausted, + /// A private registry consistency invariant was violated. InternalAuthorityInvariant, } impl fmt::Display for BrowserRegistryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidExternalIdentifier => formatter.write_str( - "external browser identifier must contain 1 to 512 UTF-8 bytes", + Self::InvalidExternalIdentifier => write!( + formatter, + "external browser identifier must contain 1 to {MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES} UTF-8 bytes" ), Self::UnknownBrowserSession => { - formatter.write_str("browser session is not registered") + formatter.write_str("browser session is not registered in this authority registry") } Self::UnknownBrowsingContext => { - formatter.write_str("browsing context is not registered") - } - Self::ContextSessionMismatch { .. } => { - formatter.write_str("browsing context belongs to a different browser session") + formatter.write_str("browsing context is not registered in this authority registry") } - Self::OriginChangedWithoutDocumentAdvance => { - formatter.write_str("browser origin changed without a document-epoch advance") - } - Self::AuthorityIdentifierExhausted => { - formatter.write_str("browser authority identifier namespace is exhausted") + Self::ContextSessionMismatch { expected, actual } => write!( + formatter, + "browsing context belongs to session {}, not session {}", + expected.value(), + actual.value() + ), + Self::OriginChangedWithoutDocumentAdvance => formatter + .write_str("browsing context origin changed without advancing the document epoch"), + Self::UnknownNodeAuthority => formatter + .write_str("observed node handle is not registered as current browser authority"), + Self::IdentifierSpaceExhausted => { + formatter.write_str("browser authority identifier space is exhausted") } Self::DocumentEpochExhausted => { - formatter.write_str("browser document epoch is exhausted") - } - Self::UnknownNodeAuthority => { - formatter.write_str("browser node authority is not live in this registry") + formatter.write_str("browser document epoch space is exhausted") } Self::InternalAuthorityInvariant => { - formatter.write_str("browser registry authority indexes are inconsistent") + formatter.write_str("browser authority registry violated a nonzero invariant") } } } @@ -530,10 +532,8 @@ impl fmt::Display for BrowserRegistryError { impl std::error::Error for BrowserRegistryError {} -fn validate_external_identifier(external_identifier: &str) -> Result<(), BrowserRegistryError> { - if external_identifier.is_empty() - || external_identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES - { +fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { + if identifier.is_empty() || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES { return Err(BrowserRegistryError::InvalidExternalIdentifier); } Ok(()) @@ -541,7 +541,7 @@ fn validate_external_identifier(external_identifier: &str) -> Result<(), Browser fn take_identifier(next: &mut u64, maximum_identifier: u64) -> Result { if *next > maximum_identifier { - return Err(BrowserRegistryError::AuthorityIdentifierExhausted); + return Err(BrowserRegistryError::IdentifierSpaceExhausted); } let identifier = *next; *next = identifier + 1; @@ -588,63 +588,57 @@ mod tests { } #[test] - fn document_epoch_exhaustion_is_fail_closed() { - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("session")); + fn unregistered_handle_equality_covers_all_authority_states() { + let sessions = values(BrowserSessionId::new(1)); + let contexts = values(BrowsingContextId::new(1)); + let epochs = values(DocumentEpoch::new(1)); + let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "context")); assert_eq!(contexts.len(), 1); + assert_eq!(epochs.len(), 1); + assert_eq!(origins.len(), 1); + let session = sessions[0]; let context = contexts[0]; - registry - .context_epoch - .insert(context, DocumentEpoch::new(u64::MAX).unwrap()); - assert_eq!( - registry.advance_document(context), - Err(BrowserRegistryError::DocumentEpochExhausted) - ); - } - - #[test] - fn monotonic_identifier_exhaustion_is_fail_closed() { - let mut sessions = BrowserAuthorityRegistry::with_identifier_limit(1); - assert!(sessions.register_session("one").is_ok()); - assert_eq!( - sessions.register_session("two"), - Err(BrowserRegistryError::AuthorityIdentifierExhausted) - ); + let epoch = epochs[0]; + let origin = origins[0].clone(); - let mut contexts = BrowserAuthorityRegistry::with_identifier_limit(1); - let owner = values(contexts.register_session("owner")); - assert_eq!(owner.len(), 1); - let owner = owner[0]; - assert!(contexts.register_context(owner, "one").is_ok()); - assert_eq!( - contexts.register_context(owner, "two"), - Err(BrowserRegistryError::AuthorityIdentifierExhausted) - ); - - let mut nodes = BrowserAuthorityRegistry::with_identifier_limit(1); - let owner = values(nodes.register_session("owner")); - assert_eq!(owner.len(), 1); - let owner = owner[0]; - let context = values(nodes.register_context(owner, "context")); - assert_eq!(context.len(), 1); - let context = context[0]; - let origins = values(Origin::parse("http://127.0.0.1:43127")); - assert_eq!(origins.len(), 1); - let origin = &origins[0]; - assert!(nodes.bind_node(owner, context, origin, "one").is_ok()); - assert_eq!( - nodes.bind_node(owner, context, origin, "two"), - Err(BrowserRegistryError::AuthorityIdentifierExhausted) - ); - } + let first = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let same = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 1, + )); + let different = values(ObservedNodeHandle::new( + session, + context, + origin.clone(), + epoch, + 2, + )); + assert_eq!(first.len(), 1); + assert_eq!(same.len(), 1); + assert_eq!(different.len(), 1); + assert_eq!(first[0], same[0]); + assert_ne!(first[0], different[0]); - #[test] - fn maximum_identifier_limit_is_clamped_without_wrapping() { - let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); - assert_eq!(registry.maximum_identifier, u64::MAX - 1); + let registered = values(ObservedNodeHandle::registered( + session, + context, + origin, + epoch, + 1, + Arc::new(()), + )); + assert_eq!(registered.len(), 1); + assert_ne!(first[0], registered[0]); } #[test] @@ -662,31 +656,66 @@ mod tests { Err(BrowserRegistryError::InternalAuthorityInvariant) ); - let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("session")); + let sessions = values(BrowserSessionId::new(1)); + let contexts = values(BrowsingContextId::new(1)); + let epochs = values(DocumentEpoch::new(1)); + let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(sessions.len(), 1); - let session = sessions[0]; - let contexts = values(registry.register_context(session, "context")); assert_eq!(contexts.len(), 1); - let context = contexts[0]; - let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(epochs.len(), 1); assert_eq!(origins.len(), 1); + assert!( + registered_node_handle( + sessions[0], + contexts[0], + &origins[0], + epochs[0], + 0, + Arc::new(()) + ) + .is_err() + ); + + let mut registry = BrowserAuthorityRegistry::new(); + let registered_sessions = values(registry.register_session("corrupt-session")); + assert_eq!(registered_sessions.len(), 1); + let session = registered_sessions[0]; + let registered_contexts = values(registry.register_context(session, "corrupt-context")); + assert_eq!(registered_contexts.len(), 1); + let context = registered_contexts[0]; let origin = &origins[0]; + let handles = values(registry.bind_node(session, context, origin, "node")); + assert_eq!(handles.len(), 1); + let handle = &handles[0]; + registry.node_binding_by_id.remove(&handle.node_id()); + assert_eq!( + registry.bind_node(session, context, origin, "node"), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); - registry - .node_by_external - .insert((context, DocumentEpoch::new(1).unwrap(), "corrupt".into()), 0); registry .node_binding_by_id - .insert(0, (context, DocumentEpoch::new(1).unwrap())); + .insert(handle.node_id(), (context, epochs[0])); + registry.node_by_external.clear(); + let other_contexts = values(registry.register_context(session, "other-context")); + assert_eq!(other_contexts.len(), 1); + let other_context = other_contexts[0]; + registry.node_by_external.insert( + (other_context, epochs[0], "other-node".to_owned()), + handle.node_id(), + ); assert_eq!( - registry.bind_node(session, context, origin, "corrupt"), + registry.bind_node(session, other_context, origin, "other-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) ); - registry.node_binding_by_id.clear(); - registry.node_by_external.clear(); - registry.next_node_id = 0; + let zero_epochs = values(registry.current_epoch(context)); + assert_eq!(zero_epochs.len(), 1); + let zero_epoch = zero_epochs[0]; + registry + .node_by_external + .insert((context, zero_epoch, "zero-node".to_owned()), 0); + registry.node_binding_by_id.insert(0, (context, zero_epoch)); assert_eq!( registry.bind_node(session, context, origin, "zero-node"), Err(BrowserRegistryError::InternalAuthorityInvariant) @@ -729,8 +758,12 @@ mod tests { let context = contexts[0]; let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); - let handles = - values(registry.bind_node(owner, context, &origins[0], "corrupt-context-node")); + let handles = values(registry.bind_node( + owner, + context, + &origins[0], + "corrupt-context-node", + )); assert_eq!(handles.len(), 1); registry.context_session.insert(context, attacker); @@ -753,13 +786,15 @@ mod tests { let corrupt_origins = values(Origin::parse("http://127.0.0.1:43128")); assert_eq!(origins.len(), 1); assert_eq!(corrupt_origins.len(), 1); - let handles = - values(registry.bind_node(session, context, &origins[0], "corrupt-origin-node")); + let handles = values(registry.bind_node( + session, + context, + &origins[0], + "corrupt-origin-node", + )); assert_eq!(handles.len(), 1); - registry - .context_origin - .insert(context, corrupt_origins[0].clone()); + registry.context_origin.insert(context, corrupt_origins[0].clone()); assert_eq!( registry.validate_node_handle(&handles[0]), Err(BrowserRegistryError::UnknownNodeAuthority) @@ -783,68 +818,112 @@ mod tests { let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); let origin = &origins[0]; - - assert!(registry.bind_node(session, context, origin, "good-node").is_ok()); assert_eq!( registry.bind_node(session, context, origin, ""), Err(BrowserRegistryError::InvalidExternalIdentifier) ); + let epochs = values(registry.current_epoch(context)); + assert_eq!(epochs.len(), 1); + let epoch = epochs[0]; + registry.context_epoch.remove(&context); + assert_eq!( + registry.bind_node(session, context, origin, "missing-epoch-node"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + registry.context_epoch.insert(context, epoch); + let handles = values(registry.bind_node(session, context, origin, "live-node")); + assert_eq!(handles.len(), 1); registry.context_epoch.remove(&context); assert_eq!( - registry.bind_node(session, context, origin, "missing-epoch"), + registry.validate_node_handle(&handles[0]), Err(BrowserRegistryError::UnknownBrowsingContext) ); } #[test] - fn unregistered_handle_equality_covers_all_authority_states() { - let session = BrowserSessionId::new(1).unwrap(); - let context = BrowsingContextId::new(1).unwrap(); - let epoch = DocumentEpoch::new(1).unwrap(); + fn node_retirement_purges_duplicate_private_aliases_fail_closed() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("retirement-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "retirement-context")); + let other_contexts = values(registry.register_context(session, "other-retirement-context")); + assert_eq!(contexts.len(), 1); + assert_eq!(other_contexts.len(), 1); + let context = contexts[0]; + let other_context = other_contexts[0]; let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); - let unregistered = values(ObservedNodeHandle::new( - session, - context, - origins[0].clone(), - epoch, - 1, - )); - let another_unregistered = values(ObservedNodeHandle::new( - session, - context, - origins[0].clone(), - epoch, - 1, - )); - assert_eq!(unregistered.len(), 1); - assert_eq!(another_unregistered.len(), 1); - assert_eq!(unregistered[0], another_unregistered[0]); + let origin = &origins[0]; + let targets = values(registry.bind_node(session, context, origin, "target-node")); + let siblings = values(registry.bind_node(session, context, origin, "sibling-node")); + let others = values(registry.bind_node(session, other_context, origin, "other-node")); + assert_eq!(targets.len(), 1); + assert_eq!(siblings.len(), 1); + assert_eq!(others.len(), 1); + let target = &targets[0]; + let sibling = &siblings[0]; + let other = &others[0]; + + let epochs = values(DocumentEpoch::new(target.document_epoch().value() + 1)); + assert_eq!(epochs.len(), 1); + let future_key = (context, epochs[0], "corrupt-future-alias".to_owned()); + let cross_context_key = ( + other_context, + target.document_epoch(), + "corrupt-cross-context-alias".to_owned(), + ); + registry + .node_by_external + .insert(future_key.clone(), target.node_id()); + registry + .node_by_external + .insert(cross_context_key.clone(), target.node_id()); + + assert_eq!(registry.remove_node(target), Ok(())); + assert_eq!(registry.validate_node_handle(sibling), Ok(())); + assert_eq!(registry.validate_node_handle(other), Ok(())); + assert_eq!(registry.node_by_external.get(&future_key), None); + assert_eq!(registry.node_by_external.get(&cross_context_key), None); + assert_eq!(registry.node_binding_by_id.get(&target.node_id()), None); + } + + #[test] + fn document_epoch_exhaustion_is_fail_closed() { let mut registry = BrowserAuthorityRegistry::new(); - let sessions = values(registry.register_session("eq-session")); + let sessions = values(registry.register_session("epoch-session")); assert_eq!(sessions.len(), 1); - let registered_session = sessions[0]; - let contexts = values(registry.register_context(registered_session, "eq-context")); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "epoch-context")); assert_eq!(contexts.len(), 1); - let registered_context = contexts[0]; - let registered = values(registry.bind_node( - registered_session, - registered_context, - &origins[0], - "eq-node", - )); - assert_eq!(registered.len(), 1); - let registered = ®istered[0]; - let same_tuple_without_authority = values(ObservedNodeHandle::new( - registered.browser_session(), - registered.browsing_context(), - registered.origin().clone(), - registered.document_epoch(), - registered.node_id(), - )); - assert_eq!(same_tuple_without_authority.len(), 1); - assert_ne!(registered, &same_tuple_without_authority[0]); + let context = contexts[0]; + let maximum_epochs = values(DocumentEpoch::new(u64::MAX)); + assert_eq!(maximum_epochs.len(), 1); + registry.context_epoch.insert(context, maximum_epochs[0]); + + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::DocumentEpochExhausted) + ); + } + + #[test] + fn monotonic_identifier_exhaustion_is_fail_closed() { + let mut next = 1; + assert_eq!(take_identifier(&mut next, 1), Ok(1)); + assert_eq!(next, 2); + assert_eq!( + take_identifier(&mut next, 1), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + } + + #[test] + fn maximum_identifier_limit_is_clamped_without_wrapping() { + let registry = BrowserAuthorityRegistry::with_identifier_limit(u64::MAX); + assert_eq!(registry.maximum_identifier, u64::MAX - 1); } } From 4cbcb03f1667cde5cb64accd49513c1c3705a3b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:15:20 -0700 Subject: [PATCH 102/121] test(browser): cover missing pinned origin authority --- .../originweave-core/src/browser_registry.rs | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs index 0669e457c..f6cc7c4a0 100644 --- a/crates/originweave-core/src/browser_registry.rs +++ b/crates/originweave-core/src/browser_registry.rs @@ -671,7 +671,7 @@ mod tests { &origins[0], epochs[0], 0, - Arc::new(()) + Arc::new(()), ) .is_err() ); @@ -758,12 +758,8 @@ mod tests { let context = contexts[0]; let origins = values(Origin::parse("http://127.0.0.1:43127")); assert_eq!(origins.len(), 1); - let handles = values(registry.bind_node( - owner, - context, - &origins[0], - "corrupt-context-node", - )); + let handles = + values(registry.bind_node(owner, context, &origins[0], "corrupt-context-node")); assert_eq!(handles.len(), 1); registry.context_session.insert(context, attacker); @@ -786,15 +782,13 @@ mod tests { let corrupt_origins = values(Origin::parse("http://127.0.0.1:43128")); assert_eq!(origins.len(), 1); assert_eq!(corrupt_origins.len(), 1); - let handles = values(registry.bind_node( - session, - context, - &origins[0], - "corrupt-origin-node", - )); + let handles = + values(registry.bind_node(session, context, &origins[0], "corrupt-origin-node")); assert_eq!(handles.len(), 1); - registry.context_origin.insert(context, corrupt_origins[0].clone()); + registry + .context_origin + .insert(context, corrupt_origins[0].clone()); assert_eq!( registry.validate_node_handle(&handles[0]), Err(BrowserRegistryError::UnknownNodeAuthority) @@ -835,6 +829,14 @@ mod tests { registry.context_epoch.insert(context, epoch); let handles = values(registry.bind_node(session, context, origin, "live-node")); assert_eq!(handles.len(), 1); + + registry.context_origin.remove(&context); + assert_eq!( + registry.validate_node_handle(&handles[0]), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + + registry.context_origin.insert(context, origin.clone()); registry.context_epoch.remove(&context); assert_eq!( registry.validate_node_handle(&handles[0]), From f71c1ca0d17b9a94bb7b1ef798bfc00c49759be2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:03:05 -0700 Subject: [PATCH 103/121] test(core): reject empty-hex browser IPv4 authorities --- crates/originweave-core/tests/security_review.rs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/crates/originweave-core/tests/security_review.rs b/crates/originweave-core/tests/security_review.rs index ec9dc0abd..e3676b77c 100644 --- a/crates/originweave-core/tests/security_review.rs +++ b/crates/originweave-core/tests/security_review.rs @@ -17,6 +17,9 @@ fn origin_rejects_browser_special_numeric_hosts() { "https://0177.0.0.1", "https://1.2.3.04", "https://example.127", + "https://0x", + "https://1.2.3.0x", + "https://example.0X", ] { assert_eq!( Origin::parse(input), @@ -31,18 +34,6 @@ fn origin_rejects_browser_special_numeric_hosts() { .as_str(), "https://127.0.0.1" ); - assert_eq!( - Origin::parse("https://0x") - .expect("an empty hexadecimal suffix is a DNS label, not an IPv4 number") - .as_str(), - "https://0x" - ); - assert_eq!( - Origin::parse("https://1.2.3.0x") - .expect("an empty hexadecimal final label remains a DNS authority") - .as_str(), - "https://1.2.3.0x" - ); assert_eq!( Origin::parse("https://0xg") .expect("a non-hexadecimal suffix is a DNS label, not an IPv4 number") From 0a73431aa8ac6c23c3da5c3873b0bca3246c5c3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:08:36 -0700 Subject: [PATCH 104/121] fix(core): reject empty-hex browser IPv4 authorities --- crates/originweave-core/src/contracts.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/src/contracts.rs b/crates/originweave-core/src/contracts.rs index 88dd2e586..bd4705de2 100644 --- a/crates/originweave-core/src/contracts.rs +++ b/crates/originweave-core/src/contracts.rs @@ -140,7 +140,7 @@ fn looks_like_browser_ipv4_number(label: &str) -> bool { } let lowercase = label.to_ascii_lowercase(); if let Some(hexadecimal) = lowercase.strip_prefix("0x") { - return !hexadecimal.is_empty() && hexadecimal.bytes().all(|byte| byte.is_ascii_hexdigit()); + return hexadecimal.bytes().all(|byte| byte.is_ascii_hexdigit()); } label.bytes().all(|byte| byte.is_ascii_digit()) } From 5157366a025f62cdccdff8b795a05f167f09e6e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:13:16 -0700 Subject: [PATCH 105/121] docs(changelog): record empty-hex origin hardening --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef1410c21..36fa71519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - State-changing actions are same-origin by default. - R3 and R4 approvals are bound to the exact action, target origin, and immutable digest of the complete canonical action intent; R5 legal consent is non-delegable. - Shortened, integer, hexadecimal, and legacy octal-looking IPv4 host spellings are rejected so the policy origin cannot diverge from Chromium host interpretation. +- Bare hexadecimal-prefix host labels such as `0x`, including terminal `.0x` and `.0X` spellings, are rejected as browser-special numeric hosts rather than admitted as DNS authorities. - IPv4-mapped IPv6 is canonicalized before destination classification and pin comparison so mapped private or loopback addresses cannot bypass IPv4 policy. - The default destination policy permits only public addresses and denies unspecified, loopback, private, shared, link-local, metadata, documentation, benchmarking, multicast, broadcast, transition, and protocol-reserved destinations. - Azure platform IP `168.63.129.16` and Amazon EKS Pod Identity endpoints `169.254.170.23` and `fd00:ec2::23` are classified as metadata or platform services before broader public, link-local, or unique-local rules. @@ -78,4 +79,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file From adcafe10003cd92bb1e094b052b63c26a4f2bfcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:18:42 -0700 Subject: [PATCH 106/121] docs(changelog): preserve semantic observation slice after stack realignment --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36fa71519..b6d1c9945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding. - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. +- Authority-bound, bounded semantic node observations with typed node-local action evidence and explicit observation-channel provenance for the first Chromium vertical slice; observation metadata grants no execution authority. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. @@ -79,4 +80,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD From 293781d3c37324333d06e90cdab19fd199d5245e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:23:27 +0900 Subject: [PATCH 107/121] feat(core): bind admitted nodes to business actions --- .../src/semantic_action_binding.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 crates/originweave-core/src/semantic_action_binding.rs diff --git a/crates/originweave-core/src/semantic_action_binding.rs b/crates/originweave-core/src/semantic_action_binding.rs new file mode 100644 index 000000000..5b0169fc6 --- /dev/null +++ b/crates/originweave-core/src/semantic_action_binding.rs @@ -0,0 +1,59 @@ +use std::fmt; + +use crate::{ActionRequest, AdmittedNodeHandle}; + +/// One registry-issued browser node explicitly paired with the business action request it would serve. +/// +/// The binding prevents a caller from independently validating one current browser node and a +/// different business intent and then combining them as if they originated from the same document. +/// It deliberately does not authorize policy, map browser-local input to a business risk class, +/// grant a destination, resolve secrets, or execute browser I/O. The later typed command boundary +/// still revalidates registry provenance and the exact admitted wire node immediately before I/O. +#[derive(Debug)] +pub struct SemanticNodeActionBinding { + handle: AdmittedNodeHandle, + request: ActionRequest, +} + +impl SemanticNodeActionBinding { + /// Bind one registry-issued admitted node to a business request from the same source origin. + pub fn new( + handle: AdmittedNodeHandle, + request: ActionRequest, + ) -> Result { + if handle.origin() != request.source_origin() { + return Err(SemanticNodeActionBindingError::SourceOriginMismatch); + } + Ok(Self { handle, request }) + } + + /// Return the exact registry-issued node retained for later immediate-use authority checks. + #[must_use] + pub const fn handle(&self) -> &AdmittedNodeHandle { + &self.handle + } + + /// Return the independently classified business action request. + #[must_use] + pub const fn request(&self) -> &ActionRequest { + &self.request + } +} + +/// A bounded failure to pair admitted browser-node authority with a business action request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SemanticNodeActionBindingError { + /// The request claims a different source document origin than the admitted node. + SourceOriginMismatch, +} + +impl fmt::Display for SemanticNodeActionBindingError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SourceOriginMismatch => formatter + .write_str("admitted node origin does not match action request source origin"), + } + } +} + +impl std::error::Error for SemanticNodeActionBindingError {} From a23bddb8bc297f36d005743d7e985f0eab2314be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:23:54 +0900 Subject: [PATCH 108/121] feat(core): export admitted-node action binding --- crates/originweave-core/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 070263bc2..89f2f35ab 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -45,6 +45,7 @@ mod browser_registry; mod browser_registry_coverage; mod browser_registry_external_context; mod contracts; +mod semantic_action_binding; mod webdriver_bidi_command; mod webdriver_bidi_error_code; mod webdriver_bidi_pointer_click_authority; @@ -86,6 +87,7 @@ pub use browser_registry::{ UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, }; pub use contracts::*; +pub use semantic_action_binding::{SemanticNodeActionBinding, SemanticNodeActionBindingError}; pub use webdriver_bidi_command::{ CorrelatedWebDriverBiDiLocateNodesResponse, MAX_WEBDRIVER_BIDI_COMMAND_ID, ValidatedWebDriverBiDiLocateNodesResponse, WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD, From 0a7598b6dddc5d046eb13648116b749abc633230 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:04:59 +0900 Subject: [PATCH 109/121] style(core): apply rustfmt to action binding test --- .../tests/semantic_node_action_binding.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_action_binding.rs b/crates/originweave-core/tests/semantic_node_action_binding.rs index d065b430b..41e260219 100644 --- a/crates/originweave-core/tests/semantic_node_action_binding.rs +++ b/crates/originweave-core/tests/semantic_node_action_binding.rs @@ -2,12 +2,11 @@ use std::error::Error; use originweave_core::{ ActionIntentDigest, ActionKind, ActionRequest, AdmittedNodeHandle, - BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, - BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, - BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, - BrowserProtocolCapability, BrowserProtocolKind, InstructionSource, Origin, - OriginWeaveProtocolVersion, SecretDelivery, SemanticNodeActionBinding, - SemanticNodeActionBindingError, ValidatedBrowserProtocolUse, + BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextDispatchTarget, + BrowserContextOriginDispatchTarget, BrowserContextOriginEpochDispatchTarget, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + InstructionSource, Origin, OriginWeaveProtocolVersion, SecretDelivery, + SemanticNodeActionBinding, SemanticNodeActionBindingError, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, }; From cae545915c9ed5db8f36e40e04a77c65c1a92607 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:07:56 +0900 Subject: [PATCH 110/121] chore(stack): converge action binding on current postcondition parent --- crates/originweave-network/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 66fb67637..9fd93bb7b 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -11,6 +11,7 @@ //! correlation, transports narrowly typed pointer-click and node-bound non-secret //! text-input actions plus fixed sandboxed text-value observations, admits typed //! correlated protocol acknowledgments and text-value post-condition comparisons, +//! requires an explicit positive text-value post-condition gate before success, //! sends a context-bound subscription for committed-navigation events, retains its //! typed bounded correlated subscription identifier, binds navigation-event //! admission to that exact active command/receipt lifecycle with bounded fail-closed @@ -50,6 +51,7 @@ mod webdriver_bidi_session_status_response; mod webdriver_bidi_session_teardown; mod webdriver_bidi_text_value_observation_response; mod webdriver_bidi_text_value_observation_transport; +mod webdriver_bidi_text_value_postcondition; mod webdriver_bidi_type_text_response; mod webdriver_bidi_type_text_transport; mod webdriver_bidi_websocket_frame; @@ -151,6 +153,10 @@ pub use webdriver_bidi_text_value_observation_response::{ pub use webdriver_bidi_text_value_observation_transport::{ WebDriverBiDiTextValueObservationSendError, send_webdriver_bidi_text_value_observation, }; +pub use webdriver_bidi_text_value_postcondition::{ + WebDriverBiDiTextValuePostcondition, WebDriverBiDiTextValuePostconditionError, + verify_webdriver_bidi_text_value_postcondition, +}; pub use webdriver_bidi_type_text_response::{ WebDriverBiDiTypeTextResponseError, WebDriverBiDiTypeTextResult, }; From da4aef6b32380c720bf0ad391fd64ee65fb88c96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:08:27 +0900 Subject: [PATCH 111/121] chore(stack): carry current typed-text postcondition contract --- ...webdriver_bidi_text_value_postcondition.rs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_text_value_postcondition.rs diff --git a/crates/originweave-network/src/webdriver_bidi_text_value_postcondition.rs b/crates/originweave-network/src/webdriver_bidi_text_value_postcondition.rs new file mode 100644 index 000000000..7952f1015 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_text_value_postcondition.rs @@ -0,0 +1,121 @@ +use std::{error::Error, fmt}; + +use crate::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiTextValueObservationResponseError, + WebDriverBiDiTextValueObservationResult, WebDriverBiDiWebSocketTextMessage, +}; + +/// Credential-minimal proof that one exact correlated text observation matched the authorized +/// expected value. +/// +/// The page-controlled string is discarded by the lower observation boundary before this value is +/// constructed. This type therefore carries only the consumed command identifier and observed byte +/// count. A caller can obtain this value only after exact equality succeeds; a mere command +/// response or successful parser result is not sufficient post-condition evidence. +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct WebDriverBiDiTextValuePostcondition { + command_id: u64, + observed_text_bytes: usize, +} + +impl fmt::Debug for WebDriverBiDiTextValuePostcondition { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiTextValuePostcondition") + .field("command_id", &self.command_id) + .field("observed_text_bytes", &self.observed_text_bytes) + .finish() + } +} + +impl WebDriverBiDiTextValuePostcondition { + /// Return the exact local command identifier consumed by the verified observation response. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return the UTF-8 byte count of the matched page value without retaining that value. + #[must_use] + pub const fn observed_text_bytes(&self) -> usize { + self.observed_text_bytes + } +} + +/// Failure to produce positive text-value post-condition evidence from one correlated response. +#[derive(Debug)] +pub enum WebDriverBiDiTextValuePostconditionError { + /// The underlying bounded response admission or correlation failed. + Observation { + /// Exact typed lower-boundary failure. + source: WebDriverBiDiTextValueObservationResponseError, + }, + /// The response was structurally valid and correlated, but the observed page value differed + /// from the exact already-authorized expected text. + PostconditionMismatch { + /// Exact local command identifier consumed by the negative observation. + command_id: u64, + /// UTF-8 byte count of the mismatched page value; the page text itself is not retained. + observed_text_bytes: usize, + }, +} + +impl fmt::Display for WebDriverBiDiTextValuePostconditionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Observation { .. } => { + formatter.write_str("WebDriver BiDi text-value postcondition observation failed") + } + Self::PostconditionMismatch { .. } => formatter.write_str( + "WebDriver BiDi text-value postcondition did not match the authorized expected text", + ), + } + } +} + +impl Error for WebDriverBiDiTextValuePostconditionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Observation { source } => Some(source), + Self::PostconditionMismatch { .. } => None, + } + } +} + +/// Admit one bounded correlated text observation and return success only when its page value +/// exactly matches the already-authorized expected text. +/// +/// The lower boundary validates expected-text policy, response structure, script result shape, and +/// exact command correlation before this function evaluates the post-condition. A mismatching +/// observation consumes its correlated command because the response is complete, but returns a +/// typed negative result rather than `Ok`. This prevents command acknowledgment, parser success, or +/// correlation success from being mistaken for successful browser state mutation. +/// +/// No page-controlled text, expected text, realm identifier, credential, secret, browser authority, +/// or policy authority is retained in the returned value or error diagnostics. +pub fn verify_webdriver_bidi_text_value_postcondition( + message: &WebDriverBiDiWebSocketTextMessage, + expected_text: &str, + correlation: &mut WebDriverBiDiCommandCorrelation, +) -> Result { + let observation = WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + message, + expected_text, + correlation, + ) + .map_err(|source| WebDriverBiDiTextValuePostconditionError::Observation { source })?; + + if !observation.matches_expected_text() { + return Err( + WebDriverBiDiTextValuePostconditionError::PostconditionMismatch { + command_id: observation.command_id(), + observed_text_bytes: observation.observed_text_bytes(), + }, + ); + } + + Ok(WebDriverBiDiTextValuePostcondition { + command_id: observation.command_id(), + observed_text_bytes: observation.observed_text_bytes(), + }) +} From e985ef43476d4f1e067db258ec22a48c32540c8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:08:58 +0900 Subject: [PATCH 112/121] chore(stack): carry current typed-text postcondition regression --- ...iver_bidi_text_value_postcondition_gate.rs | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_text_value_postcondition_gate.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_postcondition_gate.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_postcondition_gate.rs new file mode 100644 index 000000000..9472f0a0f --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_postcondition_gate.rs @@ -0,0 +1,177 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiCommandCorrelation, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiTextValuePostconditionError, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMessageAssembler, + WebDriverBiDiWebSocketMessageAssembly, WebDriverBiDiWebSocketTextMessage, + verify_webdriver_bidi_text_value_postcondition, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn write_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + stream.write_all(&[0x81])?; + match payload.len() { + 0..=125 => stream.write_all(&[payload.len() as u8])?, + 126..=65_535 => { + stream.write_all(&[126])?; + stream.write_all(&(payload.len() as u16).to_be_bytes())?; + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fixture response unexpectedly required 64-bit framing", + )); + } + } + stream.write_all(payload) +} + +fn receive_server_text( + payload: &[u8], +) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let response = payload.to_vec(); + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + write_text_frame(&mut stream, &response) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, + WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )? + .write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let (_established, frame) = established.read_frame(Duration::from_millis(500))?; + let mut assembler = WebDriverBiDiWebSocketMessageAssembler::new(); + let text = match assembler.push_frame(frame)? { + WebDriverBiDiWebSocketMessageAssembly::Text(text) => text, + other => { + return Err(io::Error::other(format!( + "fixture produced unexpected message assembly state: {other:?}" + )) + .into()); + } + }; + server + .join() + .map_err(|_| io::Error::other("postcondition fixture server panicked"))??; + Ok(text) +} + +#[test] +fn exact_match_is_the_only_successful_text_postcondition() -> Result<(), Box> { + let response = receive_server_text( + br#"{"type":"success","id":70,"result":{"type":"success","realm":"realm-1","result":{"type":"string","value":"expected"}}}"#, + )?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command(70)?; + + let verified = + verify_webdriver_bidi_text_value_postcondition(&response, "expected", &mut correlation)?; + + assert_eq!(verified.command_id(), 70); + assert_eq!(verified.observed_text_bytes(), "expected".len()); + assert_eq!(correlation.outstanding_count(), 0); + assert!(!format!("{verified:?}").contains("expected")); + Ok(()) +} + +#[test] +fn mismatch_is_typed_failure_after_consuming_its_exact_response() -> Result<(), Box> { + let response = receive_server_text( + br#"{"type":"success","id":71,"result":{"type":"success","realm":"realm-1","result":{"type":"string","value":"unexpected"}}}"#, + )?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command(71)?; + + let Err(error) = + verify_webdriver_bidi_text_value_postcondition(&response, "expected", &mut correlation) + else { + return Err(io::Error::other( + "a mismatched page value must not be returned as successful postcondition evidence", + ) + .into()); + }; + + assert!(matches!( + &error, + WebDriverBiDiTextValuePostconditionError::PostconditionMismatch { + command_id: 71, + observed_text_bytes: 10, + } + )); + assert_eq!(correlation.outstanding_count(), 0); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value postcondition did not match the authorized expected text" + ); + assert!(error.source().is_none()); + let debug = format!("{error:?}"); + assert!(!debug.contains("expected")); + assert!(!debug.contains("unexpected")); + Ok(()) +} + +#[test] +fn malformed_observation_stays_a_typed_source_error_without_consuming_state() +-> Result<(), Box> { + let response = receive_server_text(b"not-json")?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command(72)?; + + let Err(error) = + verify_webdriver_bidi_text_value_postcondition(&response, "expected", &mut correlation) + else { + return Err(io::Error::other("malformed observation must fail closed").into()); + }; + + assert!(matches!( + &error, + WebDriverBiDiTextValuePostconditionError::Observation { .. } + )); + assert_eq!( + error.to_string(), + "WebDriver BiDi text-value postcondition observation failed" + ); + assert!(error.source().is_some()); + assert_eq!(correlation.outstanding_count(), 1); + Ok(()) +} From 0cb57a0679e006230dee7948eb34048fa499362b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:52:27 +0900 Subject: [PATCH 113/121] test(core): bind local node action to policy intent --- .../tests/semantic_node_action_binding.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/tests/semantic_node_action_binding.rs b/crates/originweave-core/tests/semantic_node_action_binding.rs index 41e260219..d2a38bf02 100644 --- a/crates/originweave-core/tests/semantic_node_action_binding.rs +++ b/crates/originweave-core/tests/semantic_node_action_binding.rs @@ -5,7 +5,7 @@ use originweave_core::{ BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, - InstructionSource, Origin, OriginWeaveProtocolVersion, SecretDelivery, + InstructionSource, NodeActionKind, Origin, OriginWeaveProtocolVersion, SecretDelivery, SemanticNodeActionBinding, SemanticNodeActionBindingError, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, }; @@ -83,7 +83,7 @@ fn action_request(source: Origin, target: Origin) -> Result Result<(), Box> { +fn action_binding_keeps_node_action_and_business_target_separate() -> Result<(), Box> { let (_registry, handle) = admitted_node()?; let node_origin = handle.origin().clone(); let node_id = handle.node_id(); @@ -91,9 +91,10 @@ fn action_binding_keeps_admitted_node_and_business_target_separate() -> Result<( .map_err(|error| std::io::Error::other(format!("target origin rejected: {error:?}")))?; let request = action_request(node_origin, business_target.clone())?; - let binding = SemanticNodeActionBinding::new(handle, request)?; + let binding = SemanticNodeActionBinding::new(handle, NodeActionKind::TypeText, request)?; assert_eq!(binding.handle().node_id(), node_id); + assert_eq!(binding.node_action(), NodeActionKind::TypeText); assert_eq!(binding.request().target_origin(), &business_target); assert_eq!(binding.request().action(), ActionKind::Draft); Ok(()) @@ -109,7 +110,7 @@ fn action_binding_rejects_business_request_from_another_document_origin() .map_err(|error| std::io::Error::other(format!("target origin rejected: {error:?}")))?; let request = action_request(other_origin, target_origin)?; - let error = SemanticNodeActionBinding::new(handle, request) + let error = SemanticNodeActionBinding::new(handle, NodeActionKind::Click, request) .err() .ok_or("mismatched source origin unexpectedly admitted")?; assert_eq!(error, SemanticNodeActionBindingError::SourceOriginMismatch); From b07e48d9087432756e048e5ff82407b91327db5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:52:42 +0900 Subject: [PATCH 114/121] fix(core): retain exact node action in policy binding --- .../src/semantic_action_binding.rs | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/originweave-core/src/semantic_action_binding.rs b/crates/originweave-core/src/semantic_action_binding.rs index 5b0169fc6..708a0f81d 100644 --- a/crates/originweave-core/src/semantic_action_binding.rs +++ b/crates/originweave-core/src/semantic_action_binding.rs @@ -1,30 +1,39 @@ use std::fmt; -use crate::{ActionRequest, AdmittedNodeHandle}; +use crate::{ActionRequest, AdmittedNodeHandle, NodeActionKind}; -/// One registry-issued browser node explicitly paired with the business action request it would serve. +/// One registry-issued browser node and local node action explicitly paired with the business +/// action request they would serve. /// -/// The binding prevents a caller from independently validating one current browser node and a -/// different business intent and then combining them as if they originated from the same document. -/// It deliberately does not authorize policy, map browser-local input to a business risk class, -/// grant a destination, resolve secrets, or execute browser I/O. The later typed command boundary -/// still revalidates registry provenance and the exact admitted wire node immediately before I/O. +/// The binding prevents a caller from independently validating one current browser node, selecting +/// a different browser-local action at dispatch, and combining that side effect with a separately +/// authorized business intent. It deliberately does not authorize policy, map the node-local action +/// to a business risk class, grant a destination, resolve secrets, or execute browser I/O. The later +/// typed adapter boundary still revalidates registry provenance and the exact admitted wire node +/// immediately before I/O. #[derive(Debug)] pub struct SemanticNodeActionBinding { handle: AdmittedNodeHandle, + node_action: NodeActionKind, request: ActionRequest, } impl SemanticNodeActionBinding { - /// Bind one registry-issued admitted node to a business request from the same source origin. + /// Bind one registry-issued admitted node and exact node-local action to a business request from + /// the same source origin. pub fn new( handle: AdmittedNodeHandle, + node_action: NodeActionKind, request: ActionRequest, ) -> Result { if handle.origin() != request.source_origin() { return Err(SemanticNodeActionBindingError::SourceOriginMismatch); } - Ok(Self { handle, request }) + Ok(Self { + handle, + node_action, + request, + }) } /// Return the exact registry-issued node retained for later immediate-use authority checks. @@ -33,6 +42,12 @@ impl SemanticNodeActionBinding { &self.handle } + /// Return the exact browser-local node action retained with the authorized business intent. + #[must_use] + pub const fn node_action(&self) -> NodeActionKind { + self.node_action + } + /// Return the independently classified business action request. #[must_use] pub const fn request(&self) -> &ActionRequest { From 1578fb9da5457aa44f56ba3ed0737a70d8b4ee74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:21:40 +0900 Subject: [PATCH 115/121] test(core): require current admitted-node authority --- ...c_node_action_binding_current_authority.rs | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs diff --git a/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs b/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs new file mode 100644 index 000000000..4d89bb734 --- /dev/null +++ b/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs @@ -0,0 +1,132 @@ +use std::error::Error; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, AdmittedNodeHandle, + BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextDispatchTarget, + BrowserContextOriginDispatchTarget, BrowserContextOriginEpochDispatchTarget, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserRegistryError, BrowsingContextId, InstructionSource, NodeActionKind, Origin, + OriginWeaveProtocolVersion, SecretDelivery, SemanticNodeActionBinding, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +fn admitted_node( +) -> Result<(BrowserAuthorityRegistry, BrowsingContextId, AdmittedNodeHandle), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("binding-current-authority-session")?; + let context = registry.register_context(session, "binding-current-authority-context")?; + let source_origin = Origin::parse("https://app.example") + .map_err(|error| std::io::Error::other(format!("fixture origin rejected: {error:?}")))?; + let epoch = registry.bind_context_origin(session, context, &source_origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &source_origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Continue"), 1)?; + let command = WebDriverBiDiLocateNodesCommand::new( + 51, + "binding-current-authority-context", + &query, + )?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":51,"result":{"nodes":[{"type":"node","sharedId":"binding-current-authority-node"}]}}"#, + )?; + let handle = command + .bind_response_document_nodes( + document, + semantic_observation_proof()?, + &mut registry, + target, + )? + .into_iter() + .next() + .ok_or("locateNodes fixture did not bind its node")?; + Ok((registry, context, handle)) +} + +fn binding(handle: AdmittedNodeHandle) -> Result> { + let source_origin = handle.origin().clone(); + let target_origin = Origin::parse("https://destination.example") + .map_err(|error| std::io::Error::other(format!("target origin rejected: {error:?}")))?; + let intent = ActionIntentDigest::parse(VALID_INTENT) + .map_err(|error| std::io::Error::other(format!("intent rejected: {error:?}")))?; + let request = ActionRequest::new( + ActionKind::Draft, + source_origin, + target_origin, + InstructionSource::User, + SecretDelivery::None, + intent, + ); + Ok(SemanticNodeActionBinding::new( + handle, + NodeActionKind::Click, + request, + )?) +} + +#[test] +fn action_binding_revalidates_exact_registry_issued_node_authority() -> Result<(), Box> { + let (registry, _context, handle) = admitted_node()?; + let binding = binding(handle)?; + + binding.validate_current(®istry)?; + Ok(()) +} + +#[test] +fn action_binding_rejects_stale_document_authority() -> Result<(), Box> { + let (mut registry, context, handle) = admitted_node()?; + let binding = binding(handle)?; + registry.advance_document(context)?; + + assert_eq!( + binding.validate_current(®istry), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + Ok(()) +} + +#[test] +fn action_binding_rejects_foreign_registry_even_for_reproducible_descriptive_tuple( +) -> Result<(), Box> { + let (_registry, _context, handle) = admitted_node()?; + let binding = binding(handle)?; + let foreign_registry = BrowserAuthorityRegistry::new(); + + assert_eq!( + binding.validate_current(&foreign_registry), + Err(BrowserRegistryError::UnknownNodeAuthority) + ); + Ok(()) +} From e6af69108bfe900ddf65fb7d9b57c2c45244819e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 10:23:35 +0900 Subject: [PATCH 116/121] fix(core): revalidate opaque admitted-node authority --- .../src/browser_authority_registry.rs | 83 ++++++++++++++++++- crates/originweave-core/src/lib.rs | 4 +- .../src/semantic_action_binding.rs | 17 +++- ...c_node_action_binding_current_authority.rs | 12 +-- 4 files changed, 106 insertions(+), 10 deletions(-) diff --git a/crates/originweave-core/src/browser_authority_registry.rs b/crates/originweave-core/src/browser_authority_registry.rs index 24188704e..c81d2e6ea 100644 --- a/crates/originweave-core/src/browser_authority_registry.rs +++ b/crates/originweave-core/src/browser_authority_registry.rs @@ -1,11 +1,13 @@ use std::collections::BTreeMap; +use std::error::Error; +use std::fmt::{Display, Formatter}; use std::ops::Deref; use std::sync::Arc; use crate::browser_registry::BrowserAuthorityRegistry as RawBrowserAuthorityRegistry; use crate::{ - BrowserRegistryError, BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, - Origin, + BrowserRegistryError, BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, + ObservedNodeHandle, Origin, }; /// A registry-issued node handle that carries opaque provenance in addition to descriptive node state. @@ -28,6 +30,46 @@ impl Deref for AdmittedNodeHandle { } } +/// A fail-closed error while revalidating opaque registry-issued node authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdmittedNodeAuthorityError { + /// The supplied handle was issued by a different registry instance. + ForeignRegistry, + /// The registry no longer retains this exact admitted node binding. + NotAdmitted, + /// Current session, context, or canonical-origin authority no longer matches. + BrowserAuthority(BrowserRegistryError), + /// The handle no longer matches the registry's current document lifetime. + NodeHandle(NodeHandleError), +} + +impl Display for AdmittedNodeAuthorityError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::ForeignRegistry => formatter + .write_str("admitted node was issued by a different browser authority registry"), + Self::NotAdmitted => formatter + .write_str("admitted node authority is no longer retained by this registry"), + Self::BrowserAuthority(error) => { + write!(formatter, "admitted node browser authority rejected input: {error}") + } + Self::NodeHandle(error) => { + write!(formatter, "admitted node document authority rejected input: {error}") + } + } + } +} + +impl Error for AdmittedNodeAuthorityError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::BrowserAuthority(error) => Some(error), + Self::NodeHandle(error) => Some(error), + Self::ForeignRegistry | Self::NotAdmitted => None, + } + } +} + /// Public browser-authority registry with raw node minting kept inside the crate. /// /// Browser-session, browsing-context, document-epoch, and canonical-origin lifecycle operations are @@ -165,6 +207,43 @@ impl BrowserAuthorityRegistry { .require_context_origin(browser_session, browsing_context, origin) } + /// Revalidate one exact registry-issued admitted node before later typed dispatch. + /// + /// This check preserves opaque registry-instance provenance and verifies that this registry still + /// retains the exact admitted node key under the current session, context, origin, and document + /// epoch. It deliberately does not validate the adapter-local wire identifier; the final typed + /// adapter constructor must still bind the exact `sharedId` immediately before browser I/O. + pub fn validate_admitted_node_handle( + &self, + handle: &AdmittedNodeHandle, + ) -> Result<(), AdmittedNodeAuthorityError> { + if !Arc::ptr_eq(&self.registry_instance, &handle.registry_instance) { + return Err(AdmittedNodeAuthorityError::ForeignRegistry); + } + if !self + .admitted_node_external_identifiers + .contains_key(&node_authority_key(&handle.observed)) + { + return Err(AdmittedNodeAuthorityError::NotAdmitted); + } + + let current_epoch = self + .require_context_origin( + handle.browser_session(), + handle.browsing_context(), + handle.origin(), + ) + .map_err(AdmittedNodeAuthorityError::BrowserAuthority)?; + handle + .validate_current( + handle.browser_session(), + handle.browsing_context(), + handle.origin(), + current_epoch, + ) + .map_err(AdmittedNodeAuthorityError::NodeHandle) + } + /// Advance a browsing context to the next document epoch and invalidate old node bindings. pub fn advance_document( &mut self, diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 89f2f35ab..ff3c51509 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -58,7 +58,9 @@ mod webdriver_bidi_type_text; mod webdriver_bidi_websocket_connect_target; mod webdriver_bidi_websocket_endpoint; -pub use browser_authority_registry::{AdmittedNodeHandle, BrowserAuthorityRegistry}; +pub use browser_authority_registry::{ + AdmittedNodeAuthorityError, AdmittedNodeHandle, BrowserAuthorityRegistry, +}; pub use browser_protocol::{ BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, diff --git a/crates/originweave-core/src/semantic_action_binding.rs b/crates/originweave-core/src/semantic_action_binding.rs index 708a0f81d..f7878b17e 100644 --- a/crates/originweave-core/src/semantic_action_binding.rs +++ b/crates/originweave-core/src/semantic_action_binding.rs @@ -1,6 +1,9 @@ use std::fmt; -use crate::{ActionRequest, AdmittedNodeHandle, NodeActionKind}; +use crate::{ + ActionRequest, AdmittedNodeAuthorityError, AdmittedNodeHandle, BrowserAuthorityRegistry, + NodeActionKind, +}; /// One registry-issued browser node and local node action explicitly paired with the business /// action request they would serve. @@ -53,6 +56,18 @@ impl SemanticNodeActionBinding { pub const fn request(&self) -> &ActionRequest { &self.request } + + /// Revalidate the exact retained admitted-node authority against the trusted current registry. + /// + /// This preserves opaque registry-instance provenance and current session/context/origin/document + /// state. It does not validate the final adapter-local wire identifier or execute browser I/O; + /// the typed adapter command constructor must still perform that immediate-use check. + pub fn validate_current( + &self, + registry: &BrowserAuthorityRegistry, + ) -> Result<(), AdmittedNodeAuthorityError> { + registry.validate_admitted_node_handle(&self.handle) + } } /// A bounded failure to pair admitted browser-node authority with a business action request. diff --git a/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs b/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs index 4d89bb734..dfd706781 100644 --- a/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs +++ b/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs @@ -1,13 +1,13 @@ use std::error::Error; use originweave_core::{ - ActionIntentDigest, ActionKind, ActionRequest, AdmittedNodeHandle, + ActionIntentDigest, ActionKind, ActionRequest, AdmittedNodeAuthorityError, AdmittedNodeHandle, BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, - BrowserRegistryError, BrowsingContextId, InstructionSource, NodeActionKind, Origin, - OriginWeaveProtocolVersion, SecretDelivery, SemanticNodeActionBinding, - ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + BrowsingContextId, InstructionSource, NodeActionKind, Origin, OriginWeaveProtocolVersion, + SecretDelivery, SemanticNodeActionBinding, ValidatedBrowserProtocolUse, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, }; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = @@ -112,7 +112,7 @@ fn action_binding_rejects_stale_document_authority() -> Result<(), Box Date: Sat, 5 Sep 2026 04:04:04 +0900 Subject: [PATCH 117/121] fix(core): restore typed semantic action contract Signed-off-by: Seongho Bae --- CHANGELOG.md | 3 +- Cargo.toml | 1 + .../src/browser_authority_registry.rs | 43 +++++++++++++++- crates/originweave-core/src/lib.rs | 5 +- .../src/semantic_action_binding.rs | 16 +++++- .../tests/semantic_node_action_binding.rs | 35 +++++++++++++ ...c_node_action_binding_current_authority.rs | 51 ++++++++++++++----- 7 files changed, 137 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 642e59794..592c39fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Complete typed semantic-node action retention at the public binding boundary, including fail-closed coverage of registry-authority corruption and deterministic typed error sources. - Bounded RFC 6455 WebDriver BiDi opening-response validation on the exact peer-verified stream: it admits only HTTP/1.1 `101`, case-insensitive `Upgrade`/`Connection` tokens, and the client-key-correlated `Sec-WebSocket-Accept` value within monotonic time and header-size ceilings; it restores blocking mode and still does not implement WebSocket frames or grant browser/Agent authority. - Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, supports a consuming handoff of the original stream with typed credential-free peer/session/TLS and bounded-attempt evidence, preserves typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. - Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. @@ -99,4 +100,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/Cargo.toml b/Cargo.toml index fc723f3a4..8d9a4e4c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ homepage = "https://github.com/ContextualWisdomLab/OriginWeave" [workspace.lints.rust] unsafe_code = "forbid" +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } [workspace.lints.clippy] dbg_macro = "deny" diff --git a/crates/originweave-core/src/browser_authority_registry.rs b/crates/originweave-core/src/browser_authority_registry.rs index c81d2e6ea..dcd4abb15 100644 --- a/crates/originweave-core/src/browser_authority_registry.rs +++ b/crates/originweave-core/src/browser_authority_registry.rs @@ -51,10 +51,16 @@ impl Display for AdmittedNodeAuthorityError { Self::NotAdmitted => formatter .write_str("admitted node authority is no longer retained by this registry"), Self::BrowserAuthority(error) => { - write!(formatter, "admitted node browser authority rejected input: {error}") + write!( + formatter, + "admitted node browser authority rejected input: {error}" + ) } Self::NodeHandle(error) => { - write!(formatter, "admitted node document authority rejected input: {error}") + write!( + formatter, + "admitted node document authority rejected input: {error}" + ) } } } @@ -340,3 +346,36 @@ fn node_authority_key(handle: &ObservedNodeHandle) -> (u64, u64, u64, u64) { handle.node_id(), ) } + +#[cfg(test)] +mod tests { + use std::error::Error; + + use super::{AdmittedNodeAuthorityError, BrowserAuthorityRegistry}; + use crate::{BrowserRegistryError, Origin}; + + #[test] + #[cfg_attr(coverage, coverage(off))] + fn admitted_node_revalidation_preserves_broken_registry_authority() -> Result<(), Box> + { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("broken-authority-session")?; + let context = registry.register_context(session, "broken-authority-context")?; + let origin = Origin::parse("https://example.com").map_err(|error| { + std::io::Error::other(format!("fixture origin rejected: {error:?}")) + })?; + let handle = registry + .bind_admitted_nodes(session, context, &origin, &["node"])? + .pop() + .ok_or("fixture did not bind its node")?; + registry.inner.remove_context(context)?; + + assert_eq!( + registry.validate_admitted_node_handle(&handle), + Err(AdmittedNodeAuthorityError::BrowserAuthority( + BrowserRegistryError::UnknownBrowsingContext + )) + ); + Ok(()) + } +} diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index ff3c51509..6ffb59928 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1,3 +1,4 @@ +#![cfg_attr(coverage, feature(coverage_attribute))] //! Shared security and governance contracts for OriginWeave. //! //! This crate keeps the long-lived value contracts in `contracts` and the @@ -89,7 +90,9 @@ pub use browser_registry::{ UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, }; pub use contracts::*; -pub use semantic_action_binding::{SemanticNodeActionBinding, SemanticNodeActionBindingError}; +pub use semantic_action_binding::{ + NodeActionKind, SemanticNodeActionBinding, SemanticNodeActionBindingError, +}; pub use webdriver_bidi_command::{ CorrelatedWebDriverBiDiLocateNodesResponse, MAX_WEBDRIVER_BIDI_COMMAND_ID, ValidatedWebDriverBiDiLocateNodesResponse, WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD, diff --git a/crates/originweave-core/src/semantic_action_binding.rs b/crates/originweave-core/src/semantic_action_binding.rs index f7878b17e..07201c048 100644 --- a/crates/originweave-core/src/semantic_action_binding.rs +++ b/crates/originweave-core/src/semantic_action_binding.rs @@ -2,9 +2,23 @@ use std::fmt; use crate::{ ActionRequest, AdmittedNodeAuthorityError, AdmittedNodeHandle, BrowserAuthorityRegistry, - NodeActionKind, }; +/// A node-local typed action retained by an authorized semantic binding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NodeActionKind { + /// Activate the node using browser-native click semantics. + Click, + /// Insert bounded non-secret text using browser-native input semantics. + TypeText, + /// Select one option using browser-native selection semantics. + SelectOption, + /// Set a checkable control to an explicit checked state. + SetChecked, + /// Scroll the node into the viewport without activating it. + ScrollIntoView, +} + /// One registry-issued browser node and local node action explicitly paired with the business /// action request they would serve. /// diff --git a/crates/originweave-core/tests/semantic_node_action_binding.rs b/crates/originweave-core/tests/semantic_node_action_binding.rs index d2a38bf02..33b7f45c1 100644 --- a/crates/originweave-core/tests/semantic_node_action_binding.rs +++ b/crates/originweave-core/tests/semantic_node_action_binding.rs @@ -1,4 +1,5 @@ use std::error::Error; +use std::hash::{DefaultHasher, Hash, Hasher}; use originweave_core::{ ActionIntentDigest, ActionKind, ActionRequest, AdmittedNodeHandle, @@ -121,3 +122,37 @@ fn action_binding_rejects_business_request_from_another_document_origin() assert!(error.source().is_none()); Ok(()) } + +#[test] +fn node_action_kinds_preserve_the_complete_typed_set() { + let mut actions = [ + NodeActionKind::ScrollIntoView, + NodeActionKind::SetChecked, + NodeActionKind::SelectOption, + NodeActionKind::TypeText, + NodeActionKind::Click, + ]; + actions.sort(); + + assert_eq!( + actions, + [ + NodeActionKind::Click, + NodeActionKind::TypeText, + NodeActionKind::SelectOption, + NodeActionKind::SetChecked, + NodeActionKind::ScrollIntoView, + ] + ); + assert_eq!( + NodeActionKind::SelectOption.partial_cmp(&NodeActionKind::SetChecked), + Some(std::cmp::Ordering::Less) + ); + assert_eq!( + format!("{:?}", NodeActionKind::ScrollIntoView), + "ScrollIntoView" + ); + let mut hasher = DefaultHasher::new(); + NodeActionKind::SetChecked.hash(&mut hasher); + assert_ne!(hasher.finish(), 0); +} diff --git a/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs b/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs index dfd706781..37a893b2e 100644 --- a/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs +++ b/crates/originweave-core/tests/semantic_node_action_binding_current_authority.rs @@ -5,9 +5,9 @@ use originweave_core::{ BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, - BrowsingContextId, InstructionSource, NodeActionKind, Origin, OriginWeaveProtocolVersion, - SecretDelivery, SemanticNodeActionBinding, ValidatedBrowserProtocolUse, - WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + BrowserRegistryError, BrowsingContextId, InstructionSource, NodeActionKind, NodeHandleError, + Origin, OriginWeaveProtocolVersion, SecretDelivery, SemanticNodeActionBinding, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, }; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = @@ -37,8 +37,14 @@ fn semantic_observation_proof() -> Result Result<(BrowserAuthorityRegistry, BrowsingContextId, AdmittedNodeHandle), Box> { +fn admitted_node() -> Result< + ( + BrowserAuthorityRegistry, + BrowsingContextId, + AdmittedNodeHandle, + ), + Box, +> { let mut registry = BrowserAuthorityRegistry::new(); let session = registry.register_session("binding-current-authority-session")?; let context = registry.register_context(session, "binding-current-authority-context")?; @@ -53,11 +59,8 @@ fn admitted_node( epoch, ); let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Continue"), 1)?; - let command = WebDriverBiDiLocateNodesCommand::new( - 51, - "binding-current-authority-context", - &query, - )?; + let command = + WebDriverBiDiLocateNodesCommand::new(51, "binding-current-authority-context", &query)?; let document = BoundedWebDriverBiDiResponseDocument::new( r#"{"type":"success","id":51,"result":{"nodes":[{"type":"node","sharedId":"binding-current-authority-node"}]}}"#, )?; @@ -118,8 +121,8 @@ fn action_binding_rejects_stale_document_authority() -> Result<(), Box Result<(), Box> { +fn action_binding_rejects_foreign_registry_even_for_reproducible_descriptive_tuple() +-> Result<(), Box> { let (_registry, _context, handle) = admitted_node()?; let binding = binding(handle)?; let foreign_registry = BrowserAuthorityRegistry::new(); @@ -130,3 +133,27 @@ fn action_binding_rejects_foreign_registry_even_for_reproducible_descriptive_tup ); Ok(()) } + +#[test] +fn admitted_node_authority_errors_preserve_typed_sources() { + let errors = [ + AdmittedNodeAuthorityError::ForeignRegistry, + AdmittedNodeAuthorityError::NotAdmitted, + AdmittedNodeAuthorityError::BrowserAuthority(BrowserRegistryError::UnknownBrowserSession), + AdmittedNodeAuthorityError::NodeHandle(NodeHandleError::InvalidNodeId), + ]; + + assert_eq!( + errors.map(|error| error.to_string()), + [ + "admitted node was issued by a different browser authority registry", + "admitted node authority is no longer retained by this registry", + "admitted node browser authority rejected input: browser session is not registered in this authority registry", + "admitted node document authority rejected input: observed node identifier must be nonzero", + ] + ); + assert_eq!( + errors.map(|error| error.source().is_some()), + [false, false, true, true] + ); +} From 342fb2c0249d77614b9926f49850ecbc36827538 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:07:06 +0900 Subject: [PATCH 118/121] test(network): reproduce inherited foreign observation reply --- ...er_bidi_text_value_observation_response.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_response.rs b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_response.rs index bcbb3d27c..263e6968b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_text_value_observation_response.rs +++ b/crates/originweave-network/tests/webdriver_bidi_text_value_observation_response.rs @@ -59,6 +59,51 @@ fn semantic_observation_proof() -> Result Result<(), Box> { + for payload in [ + OBSERVATION_SUCCESS_RESPONSE, + br#"{"type":"error","id":43,"error":"invalid argument","message":"rejected"}"#, + br#"{"type":"success","id":43,"result":{"type":"exception","realm":"realm-1","exceptionDetails":{"text":"rejected","lineNumber":0,"columnNumber":0,"exception":{"type":"undefined"},"stackTrace":{"callFrames":[]}}}}"#, + ] { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let command = read_masked_text_frame(&mut stream)?; + assert!(command.starts_with(br#"{"id":43,"method":"script.callFunction""#)); + Ok(()) + }); + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = WebDriverBiDiWebSocketEndpoint::new(&endpoint)? + .correlate_session_id(SESSION_ID)? + .into_explicit_connect_target()?; + let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let established = WebDriverBiDiWebSocketHandshakePlan::new( + connection, WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?, + )?.write_opening_request(Duration::from_millis(500))? + .read_opening_response(Duration::from_millis(500))?; + let (registry, handle, remote) = admitted_text_field_fixture()?; + let mut correlation = WebDriverBiDiCommandCorrelation::new(); + correlation.register_command_for(44, WebDriverBiDiCommandKind::TextValueObservation)?; + let _original = send_webdriver_bidi_text_value_observation( + semantic_observation_proof()?, 43, "context-a", &handle, &remote, ®istry, + established, &mut correlation, WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]), + Duration::from_millis(500), + )?; + server.join().map_err(|_| io::Error::other("original observation server panicked"))??; + let foreign = receive_server_text(payload)?; + let rejected = WebDriverBiDiTextValueObservationResult::parse_correlate_and_compare( + &foreign, "Quarterly review", &mut correlation, + ); + assert!(rejected.is_err(), "a replacement connection completed the original observation"); + assert_eq!(correlation.outstanding_count(), 2); + } + Ok(()) +} + fn admitted_text_field_fixture() -> Result> { let mut registry = BrowserAuthorityRegistry::new(); let browser_session = registry.register_session(SESSION_ID)?; From 576c4316d341c00f314eca7600f7752c2e8951c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:09:08 +0900 Subject: [PATCH 119/121] docs: clarify action binding and observation evidence boundaries --- CHANGELOG.md | 2 ++ docs/doctoring/browser-agent-protocols.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3218436da..a33cbe014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Preserve current-node action safeguards while adopting connection-bound field-value replies; a matching reply still does not authorize an action. + - Reject field-value replies received on a replacement connection, even when their request identifier and text match. - Reject field-observation requests on another browser session and preserve pending requests only when a write may have reached the peer. diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 480c0465a..a78d3b68b 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -20,6 +20,8 @@ The fixed field-observation sender now requires the admitted node's registered s Field-value response admission consumes only sealed messages received on that sender's exact connection. A replacement connection cannot complete the request with a matching identifier, success value, protocol error, or script exception. Equality remains necessary for positive value evidence; response parsing and dispatch alone still do not prove a completed user action. +Semantic action binding retains its independently classified business request and registry-issued node authority when adopting this response boundary. Immediate-use validation uses the same registry identity as transport provenance, while still checking current node retention, origin and document lifetime. Neither the binding nor a matching field reply grants policy approval or proves end-to-end action success. + The same Working Draft defines `ErrorResponse.error` as `ErrorCode`. Its rendered local-end CDDL enumerates 30 values and omits `no such client window`, while §3.5 separately defines `no such client window` and normative client-window algorithms return that error code. OriginWeave therefore admits the finite rendered CDDL vocabulary plus this one separately defined normative error, and still rejects arbitrary error-code text fail closed. This is an explicit interoperability exception for a specification-internal inconsistency, not authority to infer or accept other strings; adding any further code requires fresh primary-source review and regression evidence. Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). From 12d032eff8c9dec9f41a2cea1b0e90280fffadf6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:14:43 +0900 Subject: [PATCH 120/121] test(docs): distinguish registry ownership from reply provenance --- ...ver_bidi_command_correlation_documentation_contract.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_webdriver_bidi_command_correlation_documentation_contract.py b/tests/test_webdriver_bidi_command_correlation_documentation_contract.py index dc90c8b7d..1f87f95e3 100644 --- a/tests/test_webdriver_bidi_command_correlation_documentation_contract.py +++ b/tests/test_webdriver_bidi_command_correlation_documentation_contract.py @@ -12,6 +12,14 @@ class CommandCorrelationDocumentationTests(unittest.TestCase): """Enforce the correlation-owned release record without constraining other entries.""" + def test_action_binding_keeps_registry_and_reply_provenance_separate(self) -> None: + """Node ownership must not be described as response-connection provenance.""" + text = (ROOT / "docs/doctoring/browser-agent-protocols.md").read_text(encoding="utf-8") + paragraph = next(line for line in text.splitlines() if line.startswith("Semantic action binding retains")) + self.assertIn("same registry identity as admitted-node minting and typed-command validation", paragraph) + self.assertIn("reply provenance remains independently connection-bound", paragraph) + self.assertNotIn("same registry identity as transport provenance", paragraph) + def test_command_correlation_release_record_matches_public_boundary(self) -> None: """The release record must retain its resource, provenance and authority bounds.""" changelog = CHANGELOG.read_text(encoding="utf-8") From 82056d13aa94c106060b84ee76be56fcb7787fc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:15:36 +0900 Subject: [PATCH 121/121] docs: separate node ownership and reply connection evidence --- docs/doctoring/browser-agent-protocols.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index a78d3b68b..6e2009abb 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -20,7 +20,7 @@ The fixed field-observation sender now requires the admitted node's registered s Field-value response admission consumes only sealed messages received on that sender's exact connection. A replacement connection cannot complete the request with a matching identifier, success value, protocol error, or script exception. Equality remains necessary for positive value evidence; response parsing and dispatch alone still do not prove a completed user action. -Semantic action binding retains its independently classified business request and registry-issued node authority when adopting this response boundary. Immediate-use validation uses the same registry identity as transport provenance, while still checking current node retention, origin and document lifetime. Neither the binding nor a matching field reply grants policy approval or proves end-to-end action success. +Semantic action binding retains its independently classified business request and registry-issued node authority when adopting this response boundary. Immediate-use validation uses the same registry identity as admitted-node minting and typed-command validation; reply provenance remains independently connection-bound. Current node retention, origin and document lifetime still require validation. Neither the binding nor a matching field reply grants policy approval or proves end-to-end action success. The same Working Draft defines `ErrorResponse.error` as `ErrorCode`. Its rendered local-end CDDL enumerates 30 values and omits `no such client window`, while §3.5 separately defines `no such client window` and normative client-window algorithms return that error code. OriginWeave therefore admits the finite rendered CDDL vocabulary plus this one separately defined normative error, and still rejects arbitrary error-code text fail closed. This is an explicit interoperability exception for a specification-internal inconsistency, not authority to infer or accept other strings; adding any further code requires fresh primary-source review and regression evidence.