Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ All notable changes to OriginWeave are documented in this file. The format follo
- Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits.
- Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state.
- Authority-bound, bounded semantic node observations with typed node-local action evidence and explicit observation-channel provenance for the first Chromium vertical slice; observation metadata grants no execution authority.
- Bounded typed semantic node queries over reviewed role, accessible-name, and node-action evidence, without exposing raw DOM/protocol selector languages or granting execution authority.
- Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement.
- Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge.
- Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation.
Expand Down
3 changes: 2 additions & 1 deletion crates/originweave-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ pub use contracts::*;
pub use semantic_observation::{
MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES,
MAX_VISIBLE_TEXT_BYTES, NodeActionKind, ObservationChannel, SemanticNodeObservation,
SemanticNodeObservationError, SemanticNodeObservationInput,
SemanticNodeObservationError, SemanticNodeObservationInput, SemanticNodeQuery,
SemanticNodeQueryError,
};
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 @@ -209,6 +209,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_relationship(
handle: &ObservedNodeHandle,
related: &ObservedNodeHandle,
Expand Down
103 changes: 103 additions & 0 deletions crates/originweave-core/tests/semantic_node_query.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
use std::collections::BTreeSet;

use originweave_core::{
BrowserSessionId, BrowsingContextId, DocumentEpoch, MAX_ACCESSIBLE_NAME_BYTES,
MAX_SEMANTIC_ROLE_BYTES, NodeActionKind, ObservationChannel, ObservedNodeHandle, Origin,
SemanticNodeObservation, SemanticNodeObservationInput, SemanticNodeQuery,
SemanticNodeQueryError,
};

fn observation() -> Result<SemanticNodeObservation, String> {
let handle = ObservedNodeHandle::new(
BrowserSessionId::new(7).map_err(|error| error.to_string())?,
BrowsingContextId::new(11).map_err(|error| error.to_string())?,
Origin::parse("https://example.com").map_err(|error| format!("{error:?}"))?,
DocumentEpoch::new(3).map_err(|error| error.to_string())?,
17,
)
.map_err(|error| error.to_string())?;

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

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

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

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

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

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

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

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