diff --git a/crates/originweave-core/src/semantic_action_target.rs b/crates/originweave-core/src/semantic_action_target.rs index 1fe02a990..6b4d1ea55 100644 --- a/crates/originweave-core/src/semantic_action_target.rs +++ b/crates/originweave-core/src/semantic_action_target.rs @@ -55,15 +55,41 @@ impl SemanticNodeActionTarget { ) -> Result<(), BrowserRegistryError> { registry.validate_node_handle(&self.handle) } + + /// Revalidate this target against one freshly observed exact semantic node. + /// + /// The caller is responsible for obtaining the current observation from a trusted adapter + /// immediately before use. This check prevents an older target from ignoring changed node + /// identity, supported-action, or enabled-state evidence. + pub fn validate_current_observation( + &self, + current_observation: &SemanticNodeObservation, + ) -> Result<(), SemanticNodeActionTargetError> { + if current_observation.handle() != &self.handle { + return Err(SemanticNodeActionTargetError::ObservationAuthorityMismatch); + } + if !current_observation + .supported_actions() + .contains(&self.action) + { + return Err(SemanticNodeActionTargetError::UnsupportedAction); + } + if self.action != NodeActionKind::ScrollIntoView && !current_observation.is_enabled() { + return Err(SemanticNodeActionTargetError::NodeNotEnabled); + } + Ok(()) + } } -/// A bounded validation failure when deriving one semantic node action target. +/// A bounded validation failure when deriving or revalidating one semantic node action target. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SemanticNodeActionTargetError { /// The requested action was not advertised by the semantic observation. UnsupportedAction, /// The observation reported the target disabled for an interactive action. NodeNotEnabled, + /// The current observation describes a different OriginWeave-owned node authority. + ObservationAuthorityMismatch, } impl fmt::Display for SemanticNodeActionTargetError { @@ -75,6 +101,9 @@ impl fmt::Display for SemanticNodeActionTargetError { Self::NodeNotEnabled => { formatter.write_str("semantic node is not enabled for the requested action") } + Self::ObservationAuthorityMismatch => { + formatter.write_str("current semantic observation does not match the action target") + } } } } diff --git a/crates/originweave-core/tests/semantic_action_current_observation.rs b/crates/originweave-core/tests/semantic_action_current_observation.rs new file mode 100644 index 000000000..12c8c0b85 --- /dev/null +++ b/crates/originweave-core/tests/semantic_action_current_observation.rs @@ -0,0 +1,142 @@ +use std::collections::BTreeSet; + +use originweave_core::{ + BrowserAuthorityRegistry, NodeActionKind, ObservationChannel, ObservedNodeHandle, Origin, + SemanticNodeActionTarget, SemanticNodeActionTargetError, SemanticNodeObservation, + SemanticNodeObservationInput, +}; + +struct ObservationAuthorityFixture { + registry: BrowserAuthorityRegistry, + handle: ObservedNodeHandle, + other_handle: ObservedNodeHandle, +} + +fn authority_fixture() -> Result { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry + .register_session("current-observation-session") + .map_err(|error| error.to_string())?; + let context = registry + .register_context(session, "current-observation-context") + .map_err(|error| error.to_string())?; + let origin = Origin::parse("https://app.example").map_err(|error| format!("{error:?}"))?; + let handle = registry + .bind_node(session, context, &origin, "current-observation-node") + .map_err(|error| error.to_string())?; + let other_handle = registry + .bind_node(session, context, &origin, "other-current-observation-node") + .map_err(|error| error.to_string())?; + Ok(ObservationAuthorityFixture { + registry, + handle, + other_handle, + }) +} + +fn observation( + registry: &BrowserAuthorityRegistry, + handle: ObservedNodeHandle, + enabled: bool, + supported_actions: BTreeSet, +) -> Result { + 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, + visible: true, + selected: None, + supported_actions, + evidence_channels: BTreeSet::from([ObservationChannel::Accessibility]), + }, + registry, + ) + .map_err(|error| error.to_string()) +} + +#[test] +fn current_semantic_observation_revalidates_exact_target_action_state() -> Result<(), String> { + let fixture = authority_fixture()?; + let initial = observation( + &fixture.registry, + fixture.handle.clone(), + true, + BTreeSet::from([NodeActionKind::Click]), + )?; + let target = SemanticNodeActionTarget::from_observation(&initial, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + + let current = observation( + &fixture.registry, + fixture.handle.clone(), + true, + BTreeSet::from([NodeActionKind::Click]), + )?; + target + .validate_current_observation(¤t) + .map_err(|error| error.to_string())?; + + let disabled = observation( + &fixture.registry, + fixture.handle.clone(), + false, + BTreeSet::from([NodeActionKind::Click]), + )?; + assert_eq!( + target.validate_current_observation(&disabled), + Err(SemanticNodeActionTargetError::NodeNotEnabled) + ); + + let action_removed = observation( + &fixture.registry, + fixture.handle.clone(), + true, + BTreeSet::new(), + )?; + assert_eq!( + target.validate_current_observation(&action_removed), + Err(SemanticNodeActionTargetError::UnsupportedAction) + ); + + let other_node = observation( + &fixture.registry, + fixture.other_handle, + true, + BTreeSet::from([NodeActionKind::Click]), + )?; + assert_eq!( + target.validate_current_observation(&other_node), + Err(SemanticNodeActionTargetError::ObservationAuthorityMismatch) + ); + Ok(()) +} + +#[test] +fn scroll_revalidation_preserves_non_enabled_scroll_boundary() -> Result<(), String> { + let fixture = authority_fixture()?; + let initial = observation( + &fixture.registry, + fixture.handle.clone(), + false, + BTreeSet::from([NodeActionKind::ScrollIntoView]), + )?; + let target = + SemanticNodeActionTarget::from_observation(&initial, NodeActionKind::ScrollIntoView) + .map_err(|error| error.to_string())?; + let current = observation( + &fixture.registry, + fixture.handle, + false, + BTreeSet::from([NodeActionKind::ScrollIntoView]), + )?; + + target + .validate_current_observation(¤t) + .map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/crates/originweave-core/tests/semantic_node_action_target.rs b/crates/originweave-core/tests/semantic_node_action_target.rs index 5ff305782..cc7c0d587 100644 --- a/crates/originweave-core/tests/semantic_node_action_target.rs +++ b/crates/originweave-core/tests/semantic_node_action_target.rs @@ -181,4 +181,8 @@ fn node_action_target_error_is_stable_and_credential_free() { SemanticNodeActionTargetError::NodeNotEnabled.to_string(), "semantic node is not enabled for the requested action" ); + assert_eq!( + SemanticNodeActionTargetError::ObservationAuthorityMismatch.to_string(), + "current semantic observation does not match the action target" + ); }