From 603ae1c629408e670ff07f11464adea674bb8969 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 01:37:34 +0900 Subject: [PATCH 01/30] 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 02/30] 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 03/30] 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 04/30] 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 05/30] 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 06/30] 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 07/30] 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 08/30] 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 09/30] 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 10/30] 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 11/30] 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 12/30] 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 13/30] 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 14/30] 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 15/30] 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 16/30] 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 17/30] 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 18/30] 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 19/30] 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 c84a9c426792bb45981ff189f3682009f0b8a25e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:39:51 -0700 Subject: [PATCH 20/30] 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 21/30] 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 22/30] 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 23/30] 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 24/30] 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 25/30] 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 26/30] 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 27/30] 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 adcafe10003cd92bb1e094b052b63c26a4f2bfcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 04:18:42 -0700 Subject: [PATCH 28/30] 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 ed3297bf2e0dea6bd89e4a54d6edc6c0e3e3937d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 16:52:37 -0700 Subject: [PATCH 29/30] docs(browser): restore semantic observation changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b9d4f34b..0fc85c470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,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 8e7a7f2563718610cf07e9b2d34e2a6b70be3d21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 12:19:40 -0700 Subject: [PATCH 30/30] fix(core): apply canonical formatting after stack reconciliation --- crates/originweave-core/src/root.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs index 2f71e3bf3..bd516460a 100644 --- a/crates/originweave-core/src/root.rs +++ b/crates/originweave-core/src/root.rs @@ -21,9 +21,9 @@ pub use core_contracts::{ DocumentEpoch, ExecutionPurpose, ExtensionAgentCapability, ExtensionId, ExtensionIdError, InstructionSource, MAX_ACCESSIBLE_NAME_BYTES, MAX_BROWSER_PROTOCOL_METADATA_BYTES, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES, - MAX_VISIBLE_TEXT_BYTES, NodeActionKind, NodeHandleError, ObservationChannel, Origin, OriginError, - PolicyContext, RegistryObservedNodeHandle as ObservedNodeHandle, RiskClass, RobotsDecision, - SecretDelivery, SemanticNodeObservation, SemanticNodeObservationError, + MAX_VISIBLE_TEXT_BYTES, NodeActionKind, NodeHandleError, ObservationChannel, Origin, + OriginError, PolicyContext, RegistryObservedNodeHandle as ObservedNodeHandle, RiskClass, + RobotsDecision, SecretDelivery, SemanticNodeObservation, SemanticNodeObservationError, SemanticNodeObservationInput, SessionMode, evaluate_extension_authority_access as evaluate_extension_access, };