Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions crates/originweave-evidence/src/action_outcome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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",
),
}
}
}
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
30 changes: 19 additions & 11 deletions crates/originweave-evidence/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ fn redact_all_values(values: BTreeMap<String, String>) -> BTreeMap<String, Strin
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvenanceRecord {
source_url: String,
source_origin: Origin,
source_locator: String,
source_hash: String,
source_kind: EvidenceSourceKind,
Expand All @@ -277,9 +278,9 @@ impl ProvenanceRecord {
{
return Err(EvidenceError::LimitExceeded);
}
if !valid_source_url(source_url) {
let Some(source_origin) = parse_source_origin(source_url) else {
return Err(EvidenceError::InvalidSourceUrl);
}
};
if source_locator.is_empty() {
return Err(EvidenceError::EmptyLocator);
}
Expand All @@ -288,6 +289,7 @@ impl ProvenanceRecord {
}
Ok(Self {
source_url: source_url.to_owned(),
source_origin,
source_locator: source_locator.to_owned(),
source_hash: source_hash.to_owned(),
source_kind,
Expand All @@ -301,6 +303,10 @@ impl ProvenanceRecord {
&self.source_url
}

pub(crate) const fn source_origin(&self) -> &Origin {
&self.source_origin
}

/// Return the channel-specific evidence locator.
#[must_use]
pub fn source_locator(&self) -> &str {
Expand All @@ -326,26 +332,28 @@ impl ProvenanceRecord {
}
}

fn valid_source_url(source_url: &str) -> bool {
fn parse_source_origin(source_url: &str) -> Option<Origin> {
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 {
Expand Down
51 changes: 50 additions & 1 deletion crates/originweave-evidence/tests/verified_action_outcome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading