diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39..c1ce550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding. - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. +- Credential-safe verified action-outcome evidence that binds action kind, canonical target origin, complete action-intent digest, bounded browser post-condition kind, same-clock dispatch and observation timestamps, and exact provenance; construction rejects unverified or rejected post-conditions and any observation timestamp that predates action dispatch while allowing equal coarse-clock ticks. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. diff --git a/crates/originweave-evidence/src/action_outcome.rs b/crates/originweave-evidence/src/action_outcome.rs new file mode 100644 index 0000000..ed4520f --- /dev/null +++ b/crates/originweave-evidence/src/action_outcome.rs @@ -0,0 +1,146 @@ +use std::error::Error; +use std::fmt::{self, Display, Formatter}; + +use originweave_core::{ActionIntentDigest, ActionKind, Origin}; + +use crate::{ProvenanceRecord, VerificationResult}; + +/// A bounded observable state transition that may prove a browser action completed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum PostConditionKind { + /// The canonical browser URL changed to the expected resulting location. + UrlChanged, + /// A governed semantic node reached the expected state after the action. + NodeStateChanged, + /// A browser dialog entered the expected visible or closed state. + DialogStateChanged, + /// A bounded network-side mutation attributable to the action was observed. + NetworkMutationObserved, +} + +/// A failure to construct successful action evidence from post-condition proof. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerifiedActionOutcomeError { + /// The supplied provenance did not independently verify the post-condition. + PostConditionNotVerified, + /// The claimed post-condition observation happened before action dispatch. + PostConditionPredatesDispatch { + /// Monotonic timestamp recorded when the governed action was dispatched. + dispatched_at_milliseconds: u64, + /// Monotonic timestamp recorded when the post-condition was observed. + observed_at_milliseconds: u64, + }, +} + +impl Display for VerifiedActionOutcomeError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::PostConditionNotVerified => formatter + .write_str("action success requires an independently verified post-condition"), + Self::PostConditionPredatesDispatch { + dispatched_at_milliseconds, + observed_at_milliseconds, + } => write!( + formatter, + "post-condition observation at {observed_at_milliseconds} ms predates action dispatch at {dispatched_at_milliseconds} ms" + ), + } + } +} + +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. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedActionOutcomeEvidence { + action: ActionKind, + target_origin: Origin, + intent_digest: ActionIntentDigest, + post_condition: PostConditionKind, + dispatched_at_milliseconds: u64, + observed_at_milliseconds: u64, + provenance: ProvenanceRecord, +} + +impl VerifiedActionOutcomeEvidence { + /// Create successful action evidence from verified, temporally 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. + pub fn new( + action: ActionKind, + target_origin: Origin, + intent_digest: ActionIntentDigest, + post_condition: PostConditionKind, + dispatched_at_milliseconds: u64, + 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, + }); + } + Ok(Self { + action, + target_origin, + intent_digest, + post_condition, + dispatched_at_milliseconds, + observed_at_milliseconds, + provenance, + }) + } + + /// Return the typed action whose completion was verified. + #[must_use] + pub const fn action(&self) -> ActionKind { + self.action + } + + /// Return the canonical origin affected by the verified action. + #[must_use] + pub const fn target_origin(&self) -> &Origin { + &self.target_origin + } + + /// Return the digest of the complete canonical action intent. + #[must_use] + pub const fn intent_digest(&self) -> &ActionIntentDigest { + &self.intent_digest + } + + /// Return the bounded post-condition that was independently verified. + #[must_use] + pub const fn post_condition(&self) -> PostConditionKind { + self.post_condition + } + + /// Return the monotonic timestamp recorded when action dispatch began. + #[must_use] + pub const fn dispatched_at_milliseconds(&self) -> u64 { + self.dispatched_at_milliseconds + } + + /// Return the monotonic timestamp recorded when the post-condition was observed. + #[must_use] + pub const fn observed_at_milliseconds(&self) -> u64 { + self.observed_at_milliseconds + } + + /// Return the exact provenance record that verified the post-condition. + #[must_use] + pub const fn provenance(&self) -> &ProvenanceRecord { + &self.provenance + } +} diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index ad183e9..184390f 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -7,8 +7,12 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod action_outcome; mod sensitive_access; +pub use action_outcome::{ + PostConditionKind, VerifiedActionOutcomeError, VerifiedActionOutcomeEvidence, +}; pub use sensitive_access::{ MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome, diff --git a/crates/originweave-evidence/tests/verified_action_outcome.rs b/crates/originweave-evidence/tests/verified_action_outcome.rs new file mode 100644 index 0000000..4cf33ac --- /dev/null +++ b/crates/originweave-evidence/tests/verified_action_outcome.rs @@ -0,0 +1,157 @@ +#![allow(clippy::expect_used)] + +use originweave_core::{ActionIntentDigest, ActionKind, Origin}; +use originweave_evidence::{ + EvidenceSourceKind, PostConditionKind, ProvenanceRecord, VerificationResult, + VerifiedActionOutcomeError, VerifiedActionOutcomeEvidence, +}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const VALID_SOURCE_HASH: &str = + "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; +const DISPATCHED_AT_MILLISECONDS: u64 = 1_000; +const OBSERVED_AT_MILLISECONDS: u64 = 1_025; + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn origin() -> Origin { + Origin::parse("https://app.example").expect("valid test origin") +} + +fn provenance(result: VerificationResult) -> ProvenanceRecord { + ProvenanceRecord::new( + "https://app.example/receipt", + "dom:#receipt-status", + VALID_SOURCE_HASH, + EvidenceSourceKind::DomTree, + result, + ) + .expect("valid provenance") +} + +#[test] +fn verified_post_condition_can_create_action_success_evidence() { + let target = origin(); + let evidence = VerifiedActionOutcomeEvidence::new( + ActionKind::Submit, + target.clone(), + intent(), + PostConditionKind::NodeStateChanged, + DISPATCHED_AT_MILLISECONDS, + OBSERVED_AT_MILLISECONDS, + provenance(VerificationResult::Verified), + ) + .expect("verified 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.dispatched_at_milliseconds(), + DISPATCHED_AT_MILLISECONDS + ); + assert_eq!( + evidence.observed_at_milliseconds(), + OBSERVED_AT_MILLISECONDS + ); + assert_eq!( + evidence.provenance().verification_result(), + VerificationResult::Verified + ); +} + +#[test] +fn unverified_or_rejected_post_condition_cannot_be_recorded_as_success() { + for result in [VerificationResult::Unverified, VerificationResult::Rejected] { + let error = VerifiedActionOutcomeEvidence::new( + ActionKind::Submit, + origin(), + intent(), + PostConditionKind::NodeStateChanged, + DISPATCHED_AT_MILLISECONDS, + OBSERVED_AT_MILLISECONDS, + provenance(result), + ) + .expect_err("non-verified post-condition must fail closed"); + + assert_eq!(error, VerifiedActionOutcomeError::PostConditionNotVerified); + assert_eq!( + error.to_string(), + "action success requires an independently verified post-condition" + ); + assert!(std::error::Error::source(&error).is_none()); + } +} + +#[test] +fn post_condition_observation_cannot_predate_action_dispatch() { + let error = VerifiedActionOutcomeEvidence::new( + ActionKind::Submit, + origin(), + intent(), + PostConditionKind::NodeStateChanged, + 2_000, + 1_999, + provenance(VerificationResult::Verified), + ) + .expect_err("pre-dispatch observation cannot prove action success"); + + assert_eq!( + error, + VerifiedActionOutcomeError::PostConditionPredatesDispatch { + dispatched_at_milliseconds: 2_000, + observed_at_milliseconds: 1_999, + } + ); + assert_eq!( + error.to_string(), + "post-condition observation at 1999 ms predates action dispatch at 2000 ms" + ); +} + +#[test] +fn same_monotonic_tick_is_allowed_for_coarse_clock_sources() { + let evidence = VerifiedActionOutcomeEvidence::new( + ActionKind::Submit, + origin(), + intent(), + PostConditionKind::NetworkMutationObserved, + 4_000, + 4_000, + provenance(VerificationResult::Verified), + ) + .expect("coarse monotonic clocks may observe within the dispatch tick"); + + assert_eq!(evidence.dispatched_at_milliseconds(), 4_000); + assert_eq!(evidence.observed_at_milliseconds(), 4_000); +} + +#[test] +fn post_condition_kinds_cover_first_browser_vertical_slice_evidence() { + for kind in [ + PostConditionKind::UrlChanged, + PostConditionKind::NodeStateChanged, + PostConditionKind::DialogStateChanged, + PostConditionKind::NetworkMutationObserved, + ] { + let evidence = VerifiedActionOutcomeEvidence::new( + ActionKind::Submit, + origin(), + intent(), + kind, + DISPATCHED_AT_MILLISECONDS, + OBSERVED_AT_MILLISECONDS, + provenance(VerificationResult::Verified), + ) + .expect("supported post-condition should admit verified evidence"); + + assert_eq!(evidence.post_condition(), kind); + } +}