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 @@ -11,13 +11,15 @@ mod browser_registry;
#[cfg(test)]
mod browser_registry_coverage;
mod contracts;
mod semantic_action_binding;
mod semantic_action_target;
mod semantic_observation;

pub use browser_registry::{
BrowserAuthorityRegistry, BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES,
};
pub use contracts::*;
pub use semantic_action_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
76 changes: 76 additions & 0 deletions crates/originweave-core/src/semantic_action_binding.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
use std::fmt;

use crate::{
ActionRequest, BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeHandleError, Origin,
SemanticNodeActionTarget,
};

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

impl SemanticNodeActionBinding {
/// Bind a semantic node target to a business request from the same current document origin.
pub fn new(
target: SemanticNodeActionTarget,
request: ActionRequest,
) -> Result<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 browser authority immediately before a later dispatch boundary.
pub fn validate_current(
&self,
current_session: BrowserSessionId,
current_context: BrowsingContextId,
current_origin: &Origin,
current_epoch: DocumentEpoch,
) -> Result<(), NodeHandleError> {
self.target.validate_current(
current_session,
current_context,
current_origin,
current_epoch,
)
}
}

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

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

impl std::error::Error for SemanticNodeActionBindingError {}
144 changes: 144 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,144 @@
use std::collections::BTreeSet;

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

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

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

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("https://app.example")?,
DocumentEpoch::new(3).map_err(|error| error.to_string())?,
17,
)
.map_err(|error| error.to_string())?;

SemanticNodeObservation::new(SemanticNodeObservationInput {
handle,
parent: None,
children: Vec::new(),
role: "button".to_owned(),
accessible_name: "Continue".to_owned(),
visible_text: Some("Continue".to_owned()),
enabled: true,
visible: true,
selected: None,
supported_actions: BTreeSet::from([NodeActionKind::Click]),
evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]),
})
.map_err(|error| error.to_string())
}

fn action_request(source: Origin, target: Origin) -> Result<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 observed = observation()?;
let target = SemanticNodeActionTarget::from_observation(&observed, NodeActionKind::Click)
.map_err(|error| error.to_string())?;
let request = action_request(
origin("https://app.example")?,
origin("https://next.example")?,
)?;

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

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

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

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

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

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

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

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

assert_eq!(
binding
.validate_current(
BrowserSessionId::new(7).map_err(|error| error.to_string())?,
BrowsingContextId::new(11).map_err(|error| error.to_string())?,
&origin("https://app.example")?,
current_epoch,
)
.err(),
Some(NodeHandleError::StaleDocumentEpoch {
observed: observed_epoch,
current: current_epoch,
})
);
Ok(())
}

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