From e88e53a4945a0a745fac303b8bcfb980af2bfef3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 09:31:17 +0900 Subject: [PATCH 1/5] test(evidence): require exact node identity for node-state success --- .../tests/verified_action_outcome.rs | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/originweave-evidence/tests/verified_action_outcome.rs b/crates/originweave-evidence/tests/verified_action_outcome.rs index d610c5db..b5fcc37e 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) } @@ -204,3 +218,45 @@ fn post_condition_kinds_cover_first_browser_vertical_slice_evidence() { assert_eq!(evidence.post_condition(), kind); } } + +#[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" + ); +} From 915d2ce2f99b499914fe5c4a994578229605951a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 09:38:24 +0900 Subject: [PATCH 2/5] test(evidence): make node identity non-bypassable --- .../tests/verified_action_outcome.rs | 55 +++++++++++++------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/crates/originweave-evidence/tests/verified_action_outcome.rs b/crates/originweave-evidence/tests/verified_action_outcome.rs index b5fcc37e..d9d5f76d 100644 --- a/crates/originweave-evidence/tests/verified_action_outcome.rs +++ b/crates/originweave-evidence/tests/verified_action_outcome.rs @@ -51,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 @@ -92,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), @@ -114,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), @@ -135,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(), @@ -143,6 +141,30 @@ 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_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, @@ -162,13 +184,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, @@ -197,10 +220,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, ] { @@ -213,9 +235,10 @@ 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); } } From 6f3e5acc7f04e77c0c003c854836feec895959c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 09:38:59 +0900 Subject: [PATCH 3/5] feat(evidence): bind node-state success to exact target --- .../src/action_outcome.rs | 111 ++++++++++++++---- 1 file changed, 90 insertions(+), 21 deletions(-) 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(()) +} From 59f9893c68cdd8b454f1500dab4f190ad3510c56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 09:41:49 +0900 Subject: [PATCH 4/5] style(evidence): apply canonical rustfmt --- crates/originweave-evidence/tests/verified_action_outcome.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/originweave-evidence/tests/verified_action_outcome.rs b/crates/originweave-evidence/tests/verified_action_outcome.rs index d9d5f76d..d2ba3bd4 100644 --- a/crates/originweave-evidence/tests/verified_action_outcome.rs +++ b/crates/originweave-evidence/tests/verified_action_outcome.rs @@ -145,10 +145,7 @@ fn generic_constructor_cannot_bypass_node_identity_binding() { ) .expect_err("node-state success must require exact node authority"); - assert_eq!( - error, - VerifiedActionOutcomeError::NodeStateTargetRequired - ); + assert_eq!(error, VerifiedActionOutcomeError::NodeStateTargetRequired); assert_eq!( error.to_string(), "node-state post-condition requires the exact governed action target node" From 8ec18b8701104cf3f3764334601c69c1497297b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 09:45:28 +0900 Subject: [PATCH 5/5] test(evidence): cover node-state validation propagation --- .../tests/verified_action_outcome.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/originweave-evidence/tests/verified_action_outcome.rs b/crates/originweave-evidence/tests/verified_action_outcome.rs index d2ba3bd4..c29fb00b 100644 --- a/crates/originweave-evidence/tests/verified_action_outcome.rs +++ b/crates/originweave-evidence/tests/verified_action_outcome.rs @@ -152,6 +152,23 @@ fn generic_constructor_cannot_bypass_node_identity_binding() { ); } +#[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);