diff --git a/crates/originweave-evidence/src/action_outcome.rs b/crates/originweave-evidence/src/action_outcome.rs index 5bc7dd26..a607f588 100644 --- a/crates/originweave-evidence/src/action_outcome.rs +++ b/crates/originweave-evidence/src/action_outcome.rs @@ -1,7 +1,7 @@ use std::error::Error; use std::fmt::{self, Display, Formatter}; -use originweave_core::{ActionIntentDigest, ActionKind, Origin}; +use originweave_core::{ActionIntentDigest, ActionKind, ObservedNodeHandle, Origin}; use crate::{ProvenanceRecord, VerificationResult}; @@ -30,8 +30,12 @@ pub enum VerifiedActionOutcomeError { /// Monotonic timestamp recorded when the post-condition was observed. observed_at_milliseconds: u64, }, + /// Node-state success used the generic constructor without exact node authority. + NodeStateTargetRequired, /// Node-state provenance belongs to an origin other than the governed action target. PostConditionOriginMismatch, + /// The observed node differs from the exact governed action target node. + PostConditionNodeMismatch, } impl Display for VerifiedActionOutcomeError { @@ -46,9 +50,15 @@ impl Display for VerifiedActionOutcomeError { formatter, "post-condition observation at {observed_at_milliseconds} ms predates action dispatch at {dispatched_at_milliseconds} ms" ), + Self::NodeStateTargetRequired => formatter.write_str( + "node-state post-condition requires the exact governed action target node", + ), Self::PostConditionOriginMismatch => formatter.write_str( "node-state post-condition provenance must match the governed action target origin", ), + Self::PostConditionNodeMismatch => formatter.write_str( + "node-state post-condition must observe the exact governed action target node", + ), } } } @@ -59,14 +69,15 @@ impl Error for VerifiedActionOutcomeError {} /// /// Construction is intentionally fail-closed: a command acknowledgement, an /// unverified observation, rejected provenance, an observation timestamp earlier -/// than the action dispatch, or node-state provenance from a different origin -/// cannot be represented by this type as successful action completion. Equal -/// dispatch and observation timestamps are permitted because a bounded adapter -/// may use a coarse monotonic clock. +/// than the action dispatch, or a node-state observation that is not bound to the +/// exact governed [`ObservedNodeHandle`] cannot be represented by this type as +/// successful action completion. Equal dispatch and observation timestamps are +/// permitted because a bounded adapter may use a coarse monotonic clock. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerifiedActionOutcomeEvidence { action: ActionKind, target_origin: Origin, + target_node: Option, intent_digest: ActionIntentDigest, post_condition: PostConditionKind, dispatched_at_milliseconds: u64, @@ -75,12 +86,13 @@ pub struct VerifiedActionOutcomeEvidence { } impl VerifiedActionOutcomeEvidence { - /// Create successful action evidence from verified, temporally ordered provenance. + /// Create successful non-node action evidence from verified, ordered provenance. /// /// Both timestamps must come from the same monotonic clock domain. The /// observation may share the dispatch tick, but it may never predate it. - /// `NodeStateChanged` provenance must also originate from the canonical - /// action target; URL and network outcomes are not constrained by that rule. + /// `NodeStateChanged` is deliberately rejected here because origin-only + /// provenance cannot identify the exact node that was governed; callers must + /// use [`Self::new_node_state`] for that post-condition. pub fn new( action: ActionKind, target_origin: Origin, @@ -90,23 +102,18 @@ impl VerifiedActionOutcomeEvidence { observed_at_milliseconds: u64, provenance: ProvenanceRecord, ) -> Result { - if provenance.verification_result() != VerificationResult::Verified { - return Err(VerifiedActionOutcomeError::PostConditionNotVerified); - } - if observed_at_milliseconds < dispatched_at_milliseconds { - return Err(VerifiedActionOutcomeError::PostConditionPredatesDispatch { - dispatched_at_milliseconds, - observed_at_milliseconds, - }); - } - if post_condition == PostConditionKind::NodeStateChanged - && provenance.source_origin() != &target_origin - { - return Err(VerifiedActionOutcomeError::PostConditionOriginMismatch); + validate_common_post_condition( + dispatched_at_milliseconds, + observed_at_milliseconds, + &provenance, + )?; + if post_condition == PostConditionKind::NodeStateChanged { + return Err(VerifiedActionOutcomeError::NodeStateTargetRequired); } Ok(Self { action, target_origin, + target_node: None, intent_digest, post_condition, dispatched_at_milliseconds, @@ -115,6 +122,45 @@ impl VerifiedActionOutcomeEvidence { }) } + /// Create node-state success evidence bound to one exact governed node. + /// + /// `target_node` is the node authority used by the governed action; + /// `observed_node` is the independently observed node whose post-condition + /// was verified. Both must be exactly equal across browser session, browsing + /// context, canonical origin, document epoch, and node identifier. Provenance + /// must also originate from that canonical target origin. + pub fn new_node_state( + action: ActionKind, + target_node: ObservedNodeHandle, + intent_digest: ActionIntentDigest, + dispatched_at_milliseconds: u64, + observed_at_milliseconds: u64, + observed_node: ObservedNodeHandle, + provenance: ProvenanceRecord, + ) -> Result { + validate_common_post_condition( + dispatched_at_milliseconds, + observed_at_milliseconds, + &provenance, + )?; + if provenance.source_origin() != target_node.origin() { + return Err(VerifiedActionOutcomeError::PostConditionOriginMismatch); + } + if observed_node != target_node { + return Err(VerifiedActionOutcomeError::PostConditionNodeMismatch); + } + Ok(Self { + action, + target_origin: target_node.origin().clone(), + target_node: Some(target_node), + intent_digest, + post_condition: PostConditionKind::NodeStateChanged, + dispatched_at_milliseconds, + observed_at_milliseconds, + provenance, + }) + } + /// Return the typed action whose completion was verified. #[must_use] pub const fn action(&self) -> ActionKind { @@ -127,6 +173,12 @@ impl VerifiedActionOutcomeEvidence { &self.target_origin } + /// Return the exact governed node for node-state evidence, when applicable. + #[must_use] + pub const fn target_node(&self) -> Option<&ObservedNodeHandle> { + self.target_node.as_ref() + } + /// Return the digest of the complete canonical action intent. #[must_use] pub const fn intent_digest(&self) -> &ActionIntentDigest { @@ -157,3 +209,20 @@ impl VerifiedActionOutcomeEvidence { &self.provenance } } + +fn validate_common_post_condition( + dispatched_at_milliseconds: u64, + observed_at_milliseconds: u64, + provenance: &ProvenanceRecord, +) -> Result<(), VerifiedActionOutcomeError> { + if provenance.verification_result() != VerificationResult::Verified { + return Err(VerifiedActionOutcomeError::PostConditionNotVerified); + } + if observed_at_milliseconds < dispatched_at_milliseconds { + return Err(VerifiedActionOutcomeError::PostConditionPredatesDispatch { + dispatched_at_milliseconds, + observed_at_milliseconds, + }); + } + Ok(()) +} diff --git a/crates/originweave-evidence/tests/verified_action_outcome.rs b/crates/originweave-evidence/tests/verified_action_outcome.rs index d610c5db..c29fb00b 100644 --- a/crates/originweave-evidence/tests/verified_action_outcome.rs +++ b/crates/originweave-evidence/tests/verified_action_outcome.rs @@ -1,6 +1,9 @@ #![allow(clippy::expect_used)] -use originweave_core::{ActionIntentDigest, ActionKind, Origin}; +use originweave_core::{ + ActionIntentDigest, ActionKind, BrowserSessionId, BrowsingContextId, DocumentEpoch, + ObservedNodeHandle, Origin, +}; use originweave_evidence::{ EvidenceSourceKind, PostConditionKind, ProvenanceRecord, VerificationResult, VerifiedActionOutcomeError, VerifiedActionOutcomeEvidence, @@ -21,6 +24,17 @@ fn origin() -> Origin { Origin::parse("https://app.example").expect("valid test origin") } +fn node(node_id: u64) -> ObservedNodeHandle { + ObservedNodeHandle::new( + BrowserSessionId::new(7).expect("valid browser session"), + BrowsingContextId::new(11).expect("valid browsing context"), + origin(), + DocumentEpoch::new(13).expect("valid document epoch"), + node_id, + ) + .expect("valid observed node") +} + fn provenance(result: VerificationResult) -> ProvenanceRecord { provenance_at("https://app.example/receipt", result) } @@ -37,26 +51,24 @@ fn provenance_at(source_url: &str, result: VerificationResult) -> ProvenanceReco } #[test] -fn verified_post_condition_can_create_action_success_evidence() { +fn verified_non_node_post_condition_can_create_action_success_evidence() { let target = origin(); let evidence = VerifiedActionOutcomeEvidence::new( ActionKind::Submit, target.clone(), intent(), - PostConditionKind::NodeStateChanged, + PostConditionKind::UrlChanged, DISPATCHED_AT_MILLISECONDS, OBSERVED_AT_MILLISECONDS, provenance(VerificationResult::Verified), ) - .expect("verified post-condition should admit success evidence"); + .expect("verified non-node post-condition should admit success evidence"); assert_eq!(evidence.action(), ActionKind::Submit); assert_eq!(evidence.target_origin(), &target); assert_eq!(evidence.intent_digest(), &intent()); - assert_eq!( - evidence.post_condition(), - PostConditionKind::NodeStateChanged - ); + assert_eq!(evidence.post_condition(), PostConditionKind::UrlChanged); + assert_eq!(evidence.target_node(), None); assert_eq!( evidence.dispatched_at_milliseconds(), DISPATCHED_AT_MILLISECONDS @@ -78,7 +90,7 @@ fn unverified_or_rejected_post_condition_cannot_be_recorded_as_success() { ActionKind::Submit, origin(), intent(), - PostConditionKind::NodeStateChanged, + PostConditionKind::UrlChanged, DISPATCHED_AT_MILLISECONDS, OBSERVED_AT_MILLISECONDS, provenance(result), @@ -100,7 +112,7 @@ fn post_condition_observation_cannot_predate_action_dispatch() { ActionKind::Submit, origin(), intent(), - PostConditionKind::NodeStateChanged, + PostConditionKind::UrlChanged, 2_000, 1_999, provenance(VerificationResult::Verified), @@ -121,7 +133,7 @@ fn post_condition_observation_cannot_predate_action_dispatch() { } #[test] -fn node_state_post_condition_provenance_must_match_the_action_target_origin() { +fn generic_constructor_cannot_bypass_node_identity_binding() { let error = VerifiedActionOutcomeEvidence::new( ActionKind::Submit, origin(), @@ -129,6 +141,44 @@ fn node_state_post_condition_provenance_must_match_the_action_target_origin() { PostConditionKind::NodeStateChanged, DISPATCHED_AT_MILLISECONDS, OBSERVED_AT_MILLISECONDS, + provenance(VerificationResult::Verified), + ) + .expect_err("node-state success must require exact node authority"); + + assert_eq!(error, VerifiedActionOutcomeError::NodeStateTargetRequired); + assert_eq!( + error.to_string(), + "node-state post-condition requires the exact governed action target node" + ); +} + +#[test] +fn node_state_common_validation_fails_before_node_identity_binding() { + let target_node = node(17); + let error = VerifiedActionOutcomeEvidence::new_node_state( + ActionKind::Submit, + target_node.clone(), + intent(), + DISPATCHED_AT_MILLISECONDS, + OBSERVED_AT_MILLISECONDS, + target_node, + provenance(VerificationResult::Unverified), + ) + .expect_err("unverified node-state provenance must fail before identity checks"); + + assert_eq!(error, VerifiedActionOutcomeError::PostConditionNotVerified); +} + +#[test] +fn node_state_post_condition_provenance_must_match_the_action_target_origin() { + let target_node = node(17); + let error = VerifiedActionOutcomeEvidence::new_node_state( + ActionKind::Submit, + target_node.clone(), + intent(), + DISPATCHED_AT_MILLISECONDS, + OBSERVED_AT_MILLISECONDS, + target_node, provenance_at( "https://attacker.example/receipt", VerificationResult::Verified, @@ -148,13 +198,14 @@ fn node_state_post_condition_provenance_must_match_the_action_target_origin() { #[test] fn node_state_origin_comparison_uses_canonical_origin_semantics() { - let evidence = VerifiedActionOutcomeEvidence::new( + let target_node = node(17); + let evidence = VerifiedActionOutcomeEvidence::new_node_state( ActionKind::Submit, - origin(), + target_node.clone(), intent(), - PostConditionKind::NodeStateChanged, DISPATCHED_AT_MILLISECONDS, OBSERVED_AT_MILLISECONDS, + target_node, provenance_at( "https://APP.EXAMPLE:443/receipt", VerificationResult::Verified, @@ -183,10 +234,9 @@ fn same_monotonic_tick_is_allowed_for_coarse_clock_sources() { } #[test] -fn post_condition_kinds_cover_first_browser_vertical_slice_evidence() { +fn non_node_post_condition_kinds_remain_supported() { for kind in [ PostConditionKind::UrlChanged, - PostConditionKind::NodeStateChanged, PostConditionKind::DialogStateChanged, PostConditionKind::NetworkMutationObserved, ] { @@ -199,8 +249,51 @@ fn post_condition_kinds_cover_first_browser_vertical_slice_evidence() { OBSERVED_AT_MILLISECONDS, provenance(VerificationResult::Verified), ) - .expect("supported post-condition should admit verified evidence"); + .expect("supported non-node post-condition should admit verified evidence"); assert_eq!(evidence.post_condition(), kind); + assert_eq!(evidence.target_node(), None); } } + +#[test] +fn node_state_success_binds_the_exact_action_target_node() { + let target_node = node(17); + let evidence = VerifiedActionOutcomeEvidence::new_node_state( + ActionKind::Submit, + target_node.clone(), + intent(), + DISPATCHED_AT_MILLISECONDS, + OBSERVED_AT_MILLISECONDS, + target_node.clone(), + provenance(VerificationResult::Verified), + ) + .expect("the exact observed target node should prove its node-state post-condition"); + + assert_eq!(evidence.target_origin(), target_node.origin()); + assert_eq!(evidence.target_node(), Some(&target_node)); + assert_eq!( + evidence.post_condition(), + PostConditionKind::NodeStateChanged + ); +} + +#[test] +fn same_origin_different_node_cannot_prove_node_state_success() { + let error = VerifiedActionOutcomeEvidence::new_node_state( + ActionKind::Submit, + node(17), + intent(), + DISPATCHED_AT_MILLISECONDS, + OBSERVED_AT_MILLISECONDS, + node(18), + provenance(VerificationResult::Verified), + ) + .expect_err("a different same-origin node must not prove the action target changed"); + + assert_eq!(error, VerifiedActionOutcomeError::PostConditionNodeMismatch); + assert_eq!( + error.to_string(), + "node-state post-condition must observe the exact governed action target node" + ); +}