diff --git a/crates/originweave-evidence/src/action_outcome.rs b/crates/originweave-evidence/src/action_outcome.rs index ed4520f9..5bc7dd26 100644 --- a/crates/originweave-evidence/src/action_outcome.rs +++ b/crates/originweave-evidence/src/action_outcome.rs @@ -30,6 +30,8 @@ pub enum VerifiedActionOutcomeError { /// Monotonic timestamp recorded when the post-condition was observed. observed_at_milliseconds: u64, }, + /// Node-state provenance belongs to an origin other than the governed action target. + PostConditionOriginMismatch, } impl Display for VerifiedActionOutcomeError { @@ -44,6 +46,9 @@ impl Display for VerifiedActionOutcomeError { formatter, "post-condition observation at {observed_at_milliseconds} ms predates action dispatch at {dispatched_at_milliseconds} ms" ), + Self::PostConditionOriginMismatch => formatter.write_str( + "node-state post-condition provenance must match the governed action target origin", + ), } } } @@ -53,10 +58,11 @@ impl Error for VerifiedActionOutcomeError {} /// Credential-safe evidence that a typed action completed its verified post-condition. /// /// Construction is intentionally fail-closed: a command acknowledgement, an -/// unverified observation, rejected provenance, or an observation timestamp -/// earlier than the action dispatch 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. +/// 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. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerifiedActionOutcomeEvidence { action: ActionKind, @@ -73,6 +79,8 @@ impl VerifiedActionOutcomeEvidence { /// /// 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. pub fn new( action: ActionKind, target_origin: Origin, @@ -91,6 +99,11 @@ impl VerifiedActionOutcomeEvidence { observed_at_milliseconds, }); } + if post_condition == PostConditionKind::NodeStateChanged + && provenance.source_origin() != &target_origin + { + return Err(VerifiedActionOutcomeError::PostConditionOriginMismatch); + } Ok(Self { action, target_origin, diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index 184390f1..2839fd6b 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -257,6 +257,7 @@ fn redact_all_values(values: BTreeMap) -> BTreeMap &Origin { + &self.source_origin + } + /// Return the channel-specific evidence locator. #[must_use] pub fn source_locator(&self) -> &str { @@ -326,26 +332,28 @@ impl ProvenanceRecord { } } -fn valid_source_url(source_url: &str) -> bool { +fn parse_source_origin(source_url: &str) -> Option { if source_url.is_empty() || source_url .chars() .any(|character| character.is_control() || character.is_whitespace()) || source_url.contains(['?', '#', '\\']) { - return false; + return None; } - let Some((scheme, remainder)) = source_url.split_once("://") else { - return false; + let (scheme, remainder) = source_url.split_once("://")?; + let authority_end = match remainder.find('/') { + Some(index) => index, + None => remainder.len(), }; - let authority_end = remainder.find('/').unwrap_or(remainder.len()); let authority = &remainder[..authority_end]; let origin_text = format!("{scheme}://{authority}"); - if Origin::parse(&origin_text).is_err() { - return false; - } + let origin = Origin::parse(&origin_text).ok()?; let path = &remainder[authority_end..]; - path.is_empty() || validate_path(path).is_ok() + if !path.is_empty() && validate_path(path).is_err() { + return None; + } + Some(origin) } fn valid_sha256(source_hash: &str) -> bool { diff --git a/crates/originweave-evidence/tests/verified_action_outcome.rs b/crates/originweave-evidence/tests/verified_action_outcome.rs index 4cf33ac3..d610c5db 100644 --- a/crates/originweave-evidence/tests/verified_action_outcome.rs +++ b/crates/originweave-evidence/tests/verified_action_outcome.rs @@ -22,8 +22,12 @@ fn origin() -> Origin { } fn provenance(result: VerificationResult) -> ProvenanceRecord { + provenance_at("https://app.example/receipt", result) +} + +fn provenance_at(source_url: &str, result: VerificationResult) -> ProvenanceRecord { ProvenanceRecord::new( - "https://app.example/receipt", + source_url, "dom:#receipt-status", VALID_SOURCE_HASH, EvidenceSourceKind::DomTree, @@ -116,6 +120,51 @@ fn post_condition_observation_cannot_predate_action_dispatch() { ); } +#[test] +fn node_state_post_condition_provenance_must_match_the_action_target_origin() { + let error = VerifiedActionOutcomeEvidence::new( + ActionKind::Submit, + origin(), + intent(), + PostConditionKind::NodeStateChanged, + DISPATCHED_AT_MILLISECONDS, + OBSERVED_AT_MILLISECONDS, + provenance_at( + "https://attacker.example/receipt", + VerificationResult::Verified, + ), + ) + .expect_err("a different origin cannot prove the target node changed"); + + assert_eq!( + error, + VerifiedActionOutcomeError::PostConditionOriginMismatch + ); + assert_eq!( + error.to_string(), + "node-state post-condition provenance must match the governed action target origin" + ); +} + +#[test] +fn node_state_origin_comparison_uses_canonical_origin_semantics() { + let evidence = VerifiedActionOutcomeEvidence::new( + ActionKind::Submit, + origin(), + intent(), + PostConditionKind::NodeStateChanged, + DISPATCHED_AT_MILLISECONDS, + OBSERVED_AT_MILLISECONDS, + provenance_at( + "https://APP.EXAMPLE:443/receipt", + VerificationResult::Verified, + ), + ) + .expect("canonical equivalent source origin should be accepted"); + + assert_eq!(evidence.target_origin(), &origin()); +} + #[test] fn same_monotonic_tick_is_allowed_for_coarse_clock_sources() { let evidence = VerifiedActionOutcomeEvidence::new(