Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e8cf134
test(core): require typed semantic node query
seonghobae Aug 10, 2026
d0cd133
style(core): apply canonical semantic query formatting
seonghobae Aug 10, 2026
096bb97
feat(core): implement typed semantic node query
seonghobae Aug 10, 2026
5e6b81b
feat(core): export typed semantic node query
seonghobae Aug 10, 2026
135d325
docs(changelog): record typed semantic node query boundary
seonghobae Aug 10, 2026
bcd69bf
style(core): apply canonical semantic query formatting
seonghobae Aug 10, 2026
b4fa499
fix(core): satisfy strict semantic query linting
seonghobae Aug 10, 2026
4863768
merge: align semantic query with current observation authority
seonghobae Aug 15, 2026
255c53a
merge: align semantic query with current observation authority
seonghobae Aug 15, 2026
756668c
chore(core): align semantic query stack with current observation auth…
seonghobae Aug 17, 2026
96c5df1
chore(core): align semantic query stack with current observation auth…
seonghobae Aug 17, 2026
ffd3810
chore(core): align semantic query with latest observation authority
seonghobae Aug 20, 2026
57032ef
chore(core): realign semantic query stack to live observation head
seonghobae Aug 21, 2026
5e8c254
docs(changelog): retain semantic query contract after stack realignment
seonghobae Aug 21, 2026
2241c14
chore(core): align semantic query with observation root
seonghobae Aug 22, 2026
630503c
chore(browser): realign semantic query stack
seonghobae Aug 23, 2026
0300473
merge: refresh semantic query onto live observation prerequisite
seonghobae Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ All notable changes to OriginWeave are documented in this file. The format follo
- Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits.
- Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state.
- Authority-bound, bounded semantic node observations with typed node-local action evidence and explicit observation-channel provenance for the first Chromium vertical slice; observation metadata grants no execution authority.
- Bounded typed semantic node queries over reviewed role, accessible-name, and node-action evidence, without exposing raw DOM/protocol selector languages or granting execution authority.
- Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement.
- Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge.
- Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation.
Expand Down Expand Up @@ -80,4 +81,4 @@ All notable changes to OriginWeave are documented in this file. The format follo
- The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it.
- The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels.

[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD
[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD
3 changes: 2 additions & 1 deletion crates/originweave-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ pub use extension_authority::{
pub use semantic_observation::{
MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES,
MAX_VISIBLE_TEXT_BYTES, NodeActionKind, ObservationChannel, SemanticNodeObservation,
SemanticNodeObservationError, SemanticNodeObservationInput,
SemanticNodeObservationError, SemanticNodeObservationInput, SemanticNodeQuery,
SemanticNodeQueryError,
};
113 changes: 113 additions & 0 deletions crates/originweave-core/src/semantic_observation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,119 @@ impl SemanticNodeObservation {
}
}

/// A bounded typed selector over already validated semantic node observations.
///
/// Queries match only reviewed semantic fields and descriptive action evidence. They never expose
/// raw DOM/protocol selectors and never grant browser action authority.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SemanticNodeQuery {
role: Option<String>,
accessible_name: Option<String>,
required_action: Option<NodeActionKind>,
}

impl SemanticNodeQuery {
/// Validate and construct a query with at least one exact typed selector.
pub fn new(
role: Option<String>,
accessible_name: Option<String>,
required_action: Option<NodeActionKind>,
) -> Result<Self, SemanticNodeQueryError> {
if role.is_none() && accessible_name.is_none() && required_action.is_none() {
return Err(SemanticNodeQueryError::EmptySelector);
}
if role
.as_ref()
.is_some_and(|role| role.len() > MAX_SEMANTIC_ROLE_BYTES)
{
return Err(SemanticNodeQueryError::RoleTooLong);
}
if accessible_name
.as_ref()
.is_some_and(|accessible_name| accessible_name.len() > MAX_ACCESSIBLE_NAME_BYTES)
{
return Err(SemanticNodeQueryError::AccessibleNameTooLong);
}
Ok(Self {
role,
accessible_name,
required_action,
})
}

/// Return the optional exact semantic-role selector.
#[must_use]
pub fn role(&self) -> Option<&str> {
self.role.as_deref()
}

/// Return the optional exact accessible-name selector.
#[must_use]
pub fn accessible_name(&self) -> Option<&str> {
self.accessible_name.as_deref()
}

/// Return the optional required descriptive node action.
#[must_use]
pub const fn required_action(&self) -> Option<NodeActionKind> {
self.required_action
}

/// Match the query against one already bounded semantic observation.
#[must_use]
pub fn matches(&self, observation: &SemanticNodeObservation) -> bool {
if self
.role
.as_deref()
.is_some_and(|role| observation.role() != role)
{
return false;
}
if self
.accessible_name
.as_deref()
.is_some_and(|accessible_name| observation.accessible_name() != accessible_name)
{
return false;
}
if self.required_action.is_some_and(|required_action| {
!observation.supported_actions().contains(&required_action)
}) {
return false;
}
true
}
}

/// A bounded validation failure for one typed semantic node query.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SemanticNodeQueryError {
/// No typed selector was supplied.
EmptySelector,
/// The role selector exceeded [`MAX_SEMANTIC_ROLE_BYTES`].
RoleTooLong,
/// The accessible-name selector exceeded [`MAX_ACCESSIBLE_NAME_BYTES`].
AccessibleNameTooLong,
}

impl fmt::Display for SemanticNodeQueryError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptySelector => {
formatter.write_str("semantic node query requires at least one selector")
}
Self::RoleTooLong => {
formatter.write_str("semantic node query role exceeds 64 UTF-8 bytes")
}
Self::AccessibleNameTooLong => {
formatter.write_str("semantic node query accessible name exceeds 512 UTF-8 bytes")
}
}
}
}

impl std::error::Error for SemanticNodeQueryError {}

fn validate_live_node(
registry: &BrowserAuthorityRegistry,
handle: &ObservedNodeHandle,
Expand Down
108 changes: 108 additions & 0 deletions crates/originweave-core/tests/semantic_node_query.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
use std::collections::BTreeSet;

use originweave_core::{
BrowserAuthorityRegistry, MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_ROLE_BYTES, NodeActionKind,
ObservationChannel, Origin, SemanticNodeObservation, SemanticNodeObservationInput,
SemanticNodeQuery, SemanticNodeQueryError,
};

fn observation() -> Result<SemanticNodeObservation, String> {
let mut registry = BrowserAuthorityRegistry::new();
let session = registry
.register_session("semantic-query-session")
.map_err(|error| error.to_string())?;
let context = registry
.register_context(session, "semantic-query-context")
.map_err(|error| error.to_string())?;
let origin = Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?;
let handle = registry
.bind_node(session, context, &origin, "semantic-query-node")
.map_err(|error| error.to_string())?;

SemanticNodeObservation::new(
SemanticNodeObservationInput {
handle,
parent: None,
children: Vec::new(),
role: "textbox".to_owned(),
accessible_name: "Email address".to_owned(),
visible_text: Some("name@example.test".to_owned()),
enabled: true,
visible: true,
selected: None,
supported_actions: BTreeSet::from([NodeActionKind::Click, NodeActionKind::TypeText]),
evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]),
},
&registry,
)
.map_err(|error| error.to_string())
}

#[test]
fn semantic_node_query_matches_exact_reviewed_fields_and_action() -> Result<(), String> {
let observed = observation()?;
let query = SemanticNodeQuery::new(
Some("textbox".to_owned()),
Some("Email address".to_owned()),
Some(NodeActionKind::TypeText),
)
.map_err(|error| error.to_string())?;

assert!(query.matches(&observed));
assert_eq!(query.role(), Some("textbox"));
assert_eq!(query.accessible_name(), Some("Email address"));
assert_eq!(query.required_action(), Some(NodeActionKind::TypeText));
Ok(())
}

#[test]
fn semantic_node_query_fails_closed_on_each_exact_selector_mismatch() -> Result<(), String> {
let observed = observation()?;
let cases = [
SemanticNodeQuery::new(Some("button".to_owned()), None, None),
SemanticNodeQuery::new(None, Some("Different label".to_owned()), None),
SemanticNodeQuery::new(None, None, Some(NodeActionKind::SelectOption)),
];

for query in cases {
let query = query.map_err(|error| error.to_string())?;
assert!(!query.matches(&observed));
}
Ok(())
}

#[test]
fn semantic_node_query_requires_at_least_one_selector() {
assert_eq!(
SemanticNodeQuery::new(None, None, None).err(),
Some(SemanticNodeQueryError::EmptySelector)
);
}

#[test]
fn semantic_node_query_bounds_attacker_controlled_text() {
assert_eq!(
SemanticNodeQuery::new(Some("r".repeat(MAX_SEMANTIC_ROLE_BYTES + 1)), None, None).err(),
Some(SemanticNodeQueryError::RoleTooLong)
);
assert_eq!(
SemanticNodeQuery::new(None, Some("n".repeat(MAX_ACCESSIBLE_NAME_BYTES + 1)), None,).err(),
Some(SemanticNodeQueryError::AccessibleNameTooLong)
);
}

#[test]
fn semantic_node_query_errors_are_stable_and_credential_free() {
assert_eq!(
SemanticNodeQueryError::EmptySelector.to_string(),
"semantic node query requires at least one selector"
);
assert_eq!(
SemanticNodeQueryError::RoleTooLong.to_string(),
"semantic node query role exceeds 64 UTF-8 bytes"
);
assert_eq!(
SemanticNodeQueryError::AccessibleNameTooLong.to_string(),
"semantic node query accessible name exceeds 512 UTF-8 bytes"
);
}
Loading