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
2 changes: 2 additions & 0 deletions crates/originweave-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod browser_registry_coverage;
mod contract_errors;
mod contracts;
mod extension_authority;
mod semantic_action_binding;
mod semantic_action_target;
mod semantic_observation;

Expand All @@ -31,6 +32,7 @@ pub use extension_authority::{
AgentTaskId, AgentTaskIdError, ExtensionAccessDecision, ExtensionAccessRequest,
ExtensionAgentGrant, evaluate_extension_access,
};
pub use semantic_action_binding::{SemanticNodeActionBinding, SemanticNodeActionBindingError};
pub use semantic_action_target::{SemanticNodeActionTarget, SemanticNodeActionTargetError};
pub use semantic_observation::{
MAX_ACCESSIBLE_NAME_BYTES, MAX_SEMANTIC_CHILDREN, MAX_SEMANTIC_ROLE_BYTES,
Expand Down
67 changes: 67 additions & 0 deletions crates/originweave-core/src/semantic_action_binding.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use std::fmt;

use crate::{
ActionRequest, BrowserAuthorityRegistry, BrowserRegistryError, SemanticNodeActionTarget,
};

/// One semantic node target explicitly paired with the business action request it would serve.
///
/// The binding prevents independently validated browser-node authority and business intent from
/// being combined across different source documents. It does not grant policy authority, map a
/// node-local action to business risk, authorize a destination, or execute browser input.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SemanticNodeActionBinding {
target: SemanticNodeActionTarget,
request: ActionRequest,
}

impl SemanticNodeActionBinding {
/// Bind a semantic node target to a business request from the same current document origin.
pub fn new(
target: SemanticNodeActionTarget,
request: ActionRequest,
) -> Result<Self, SemanticNodeActionBindingError> {
if target.handle().origin() != request.source_origin() {
return Err(SemanticNodeActionBindingError::SourceOriginMismatch);
}
Ok(Self { target, request })
}

/// Return the exact authority-bound semantic node target.
#[must_use]
pub const fn target(&self) -> &SemanticNodeActionTarget {
&self.target
}

/// Return the independently classified business action request.
#[must_use]
pub const fn request(&self) -> &ActionRequest {
&self.request
}

/// Revalidate exact registry-owned browser authority immediately before a later dispatch.
pub fn validate_current(
&self,
registry: &BrowserAuthorityRegistry,
) -> Result<(), BrowserRegistryError> {
self.target.validate_current(registry)
}
}

/// A bounded failure to pair browser-node authority with the requested business action.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SemanticNodeActionBindingError {
/// The business request belongs to a different source origin than the observed node.
SourceOriginMismatch,
}

impl fmt::Display for SemanticNodeActionBindingError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SourceOriginMismatch => formatter
.write_str("semantic node origin does not match action request source origin"),
}
}
}

impl std::error::Error for SemanticNodeActionBindingError {}
202 changes: 202 additions & 0 deletions crates/originweave-core/tests/semantic_node_action_binding.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
use std::collections::BTreeSet;

use originweave_core::{
ActionIntentDigest, ActionKind, ActionRequest, BrowserAuthorityRegistry, BrowserRegistryError,
BrowserSessionId, BrowsingContextId, InstructionSource, NodeActionKind, ObservationChannel,
Origin, SecretDelivery, SemanticNodeActionBinding, SemanticNodeActionBindingError,
SemanticNodeActionTarget, SemanticNodeObservation, SemanticNodeObservationInput,
};

const VALID_INTENT: &str =
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

struct ObservationFixture {
registry: BrowserAuthorityRegistry,
session: BrowserSessionId,
context: BrowsingContextId,
observation: SemanticNodeObservation,
}

fn origin(value: &str) -> Result<Origin, String> {
Origin::parse(value).map_err(|error| format!("{error:?}"))
}

fn observation_fixture() -> Result<ObservationFixture, String> {
let mut registry = BrowserAuthorityRegistry::new();
let session = registry
.register_session("semantic-binding-session")
.map_err(|error| error.to_string())?;
let context = registry
.register_context(session, "semantic-binding-context")
.map_err(|error| error.to_string())?;
let source_origin = origin("https://app.example")?;
let handle = registry
.bind_node(session, context, &source_origin, "semantic-binding-node")
.map_err(|error| error.to_string())?;
let observation = SemanticNodeObservation::new(
SemanticNodeObservationInput {
handle,
parent: None,
children: Vec::new(),
role: "button".to_owned(),
accessible_name: "Continue".to_owned(),
visible_text: Some("Continue".to_owned()),
enabled: true,
visible: true,
selected: None,
supported_actions: BTreeSet::from([NodeActionKind::Click]),
evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]),
},
&registry,
)
.map_err(|error| error.to_string())?;

Ok(ObservationFixture {
registry,
session,
context,
observation,
})
}

fn action_request(source: Origin, target: Origin) -> Result<ActionRequest, String> {
let intent = ActionIntentDigest::parse(VALID_INTENT).map_err(|error| format!("{error:?}"))?;
Ok(ActionRequest::new(
ActionKind::Navigate,
source,
target,
InstructionSource::User,
SecretDelivery::None,
intent,
))
}

#[test]
fn node_action_binding_preserves_node_target_and_business_request() -> Result<(), String> {
let fixture = observation_fixture()?;
let target =
SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click)
.map_err(|error| error.to_string())?;
let request = action_request(
origin("https://app.example")?,
origin("https://next.example")?,
)?;

let binding = SemanticNodeActionBinding::new(target.clone(), request.clone())
.map_err(|error| error.to_string())?;

assert_eq!(binding.target(), &target);
assert_eq!(binding.request(), &request);
Ok(())
}

#[test]
fn node_action_binding_rejects_request_from_another_document_origin() -> Result<(), String> {
let fixture = observation_fixture()?;
let target =
SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click)
.map_err(|error| error.to_string())?;
let request = action_request(
origin("https://other.example")?,
origin("https://next.example")?,
)?;

assert_eq!(
SemanticNodeActionBinding::new(target, request).err(),
Some(SemanticNodeActionBindingError::SourceOriginMismatch)
);
Ok(())
}

#[test]
fn node_action_binding_does_not_conflate_source_node_with_navigation_target() -> Result<(), String>
{
let fixture = observation_fixture()?;
let target =
SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click)
.map_err(|error| error.to_string())?;
let destination = origin("https://destination.example")?;
let request = action_request(origin("https://app.example")?, destination.clone())?;

let binding =
SemanticNodeActionBinding::new(target, request).map_err(|error| error.to_string())?;

assert_eq!(binding.request().target_origin(), &destination);
Ok(())
}

#[test]
fn node_action_binding_revalidates_registry_owned_authority_before_dispatch() -> Result<(), String>
{
let fixture = observation_fixture()?;
let target =
SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click)
.map_err(|error| error.to_string())?;
let request = action_request(
origin("https://app.example")?,
origin("https://next.example")?,
)?;
let binding =
SemanticNodeActionBinding::new(target, request).map_err(|error| error.to_string())?;

binding
.validate_current(&fixture.registry)
.map_err(|error| error.to_string())?;
Ok(())
}

#[test]
fn node_action_binding_rejects_stale_document_before_dispatch() -> Result<(), String> {
let mut fixture = observation_fixture()?;
let target =
SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click)
.map_err(|error| error.to_string())?;
let request = action_request(
origin("https://app.example")?,
origin("https://next.example")?,
)?;
let binding =
SemanticNodeActionBinding::new(target, request).map_err(|error| error.to_string())?;
fixture
.registry
.advance_document(fixture.context)
.map_err(|error| error.to_string())?;

assert_eq!(
binding.validate_current(&fixture.registry).err(),
Some(BrowserRegistryError::UnknownNodeAuthority)
);
Ok(())
}

#[test]
fn node_action_binding_rejects_retired_session_before_dispatch() -> Result<(), String> {
let mut fixture = observation_fixture()?;
let target =
SemanticNodeActionTarget::from_observation(&fixture.observation, NodeActionKind::Click)
.map_err(|error| error.to_string())?;
let request = action_request(
origin("https://app.example")?,
origin("https://next.example")?,
)?;
let binding =
SemanticNodeActionBinding::new(target, request).map_err(|error| error.to_string())?;
fixture
.registry
.remove_session(fixture.session)
.map_err(|error| error.to_string())?;

assert_eq!(
binding.validate_current(&fixture.registry).err(),
Some(BrowserRegistryError::UnknownBrowserSession)
);
Ok(())
}

#[test]
fn node_action_binding_error_is_stable_and_credential_free() {
assert_eq!(
SemanticNodeActionBindingError::SourceOriginMismatch.to_string(),
"semantic node origin does not match action request source origin"
);
}
Loading