diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 703f69ca9..0407a9f75 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -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; @@ -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, diff --git a/crates/originweave-core/src/semantic_action_binding.rs b/crates/originweave-core/src/semantic_action_binding.rs new file mode 100644 index 000000000..b92b8010a --- /dev/null +++ b/crates/originweave-core/src/semantic_action_binding.rs @@ -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 { + 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 {} diff --git a/crates/originweave-core/tests/semantic_node_action_binding.rs b/crates/originweave-core/tests/semantic_node_action_binding.rs new file mode 100644 index 000000000..4122a40bf --- /dev/null +++ b/crates/originweave-core/tests/semantic_node_action_binding.rs @@ -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::parse(value).map_err(|error| format!("{error:?}")) +} + +fn observation_fixture() -> Result { + 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]), + }, + ®istry, + ) + .map_err(|error| error.to_string())?; + + Ok(ObservationFixture { + registry, + session, + context, + observation, + }) +} + +fn action_request(source: Origin, target: Origin) -> Result { + 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" + ); +}