diff --git a/crates/originweave-core/src/semantic_action_target.rs b/crates/originweave-core/src/semantic_action_target.rs index 9fbdd00f..ebda26bd 100644 --- a/crates/originweave-core/src/semantic_action_target.rs +++ b/crates/originweave-core/src/semantic_action_target.rs @@ -60,15 +60,41 @@ impl SemanticNodeActionTarget { current_epoch, ) } + + /// 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 { @@ -80,6 +106,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 00000000..4e4dff01 --- /dev/null +++ b/crates/originweave-core/tests/semantic_action_current_observation.rs @@ -0,0 +1,84 @@ +use std::collections::BTreeSet; + +use originweave_core::{ + BrowserSessionId, BrowsingContextId, DocumentEpoch, NodeActionKind, ObservationChannel, + ObservedNodeHandle, Origin, SemanticNodeActionTarget, SemanticNodeActionTargetError, + SemanticNodeObservation, SemanticNodeObservationInput, +}; + +fn origin(value: &str) -> Result { + Origin::parse(value).map_err(|error| format!("{error:?}")) +} + +fn observation( + node_id: u64, + enabled: bool, + supported_actions: BTreeSet, +) -> Result { + SemanticNodeObservation::new(SemanticNodeObservationInput { + 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())?, + node_id, + ) + .map_err(|error| error.to_string())?, + 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]), + }) + .map_err(|error| error.to_string()) +} + +#[test] +fn current_semantic_observation_revalidates_exact_target_action_state() -> Result<(), String> { + let initial = observation(17, true, BTreeSet::from([NodeActionKind::Click]))?; + let target = SemanticNodeActionTarget::from_observation(&initial, NodeActionKind::Click) + .map_err(|error| error.to_string())?; + + let current = observation(17, true, BTreeSet::from([NodeActionKind::Click]))?; + target + .validate_current_observation(¤t) + .map_err(|error| error.to_string())?; + + let disabled = observation(17, false, BTreeSet::from([NodeActionKind::Click]))?; + assert_eq!( + target.validate_current_observation(&disabled), + Err(SemanticNodeActionTargetError::NodeNotEnabled) + ); + + let action_removed = observation(17, true, BTreeSet::new())?; + assert_eq!( + target.validate_current_observation(&action_removed), + Err(SemanticNodeActionTargetError::UnsupportedAction) + ); + + let other_node = observation(18, 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 initial = observation(17, false, BTreeSet::from([NodeActionKind::ScrollIntoView]))?; + let target = + SemanticNodeActionTarget::from_observation(&initial, NodeActionKind::ScrollIntoView) + .map_err(|error| error.to_string())?; + let current = observation(17, 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 af095a0c..21b55e3f 100644 --- a/crates/originweave-core/tests/semantic_node_action_target.rs +++ b/crates/originweave-core/tests/semantic_node_action_target.rs @@ -209,4 +209,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" + ); }