lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 51f0db035bb25558da50fa3a3379c0646ee24f0b
parent da1baf441e0eaff1f18d049aefc7d0af74d7d90b
Author: triesap <tyson@radroots.org>
Date:   Sun, 16 Aug 2026 22:44:14 +0000

trade: freeze immutable evidence reports

- Intent: Add bounded canonical RHI evidence reports, typed projection and statement digests, and sealed supersession references.

- Why: Deterministic reconciliation needs an immutable manifest-bound report before event construction, signing, storage, and publication can be implemented.

- Verification: Extbuild package tests, exact RHI vectors, feature checks, Clippy, Rustdoc, workspace checks, contract guards, and API regeneration passed.

- Risk: Reports commit to separately retained projections and manifests; event tags, signature validation, persistence, publication, and prototype removal remain later checkpoints.

Diffstat:
Mcontracts/api_baselines/radroots_trade.txt | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/trade/README.md | 33+++++++++++++++++++++++++++++----
Mcrates/trade/src/evidence.rs | 12++++++++++++
Acrates/trade/src/evidence_report.rs | 1008+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/trade/src/lib.rs | 3+++
Mcrates/trade/tests/package_boundary.rs | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 1255 insertions(+), 4 deletions(-)

diff --git a/contracts/api_baselines/radroots_trade.txt b/contracts/api_baselines/radroots_trade.txt @@ -1,5 +1,25 @@ pub mod radroots_trade pub mod radroots_trade::evidence +pub enum radroots_trade::evidence::RadrootsRhiEvidenceReportError +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::DuplicateReasonCode +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::FixedFieldMismatch +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::IncompleteSupersession +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::InvalidDigest +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::InvalidIdentifier +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::InvalidOutcome +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::InvalidReasonCode +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::InvalidTradeGeneration +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::Malformed +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::ManifestMismatch +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::NonCanonical +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::OutcomeNotPermitted +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::ReasonCodeCountOutOfRange +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::ReportTooLarge +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::StatementDigestMismatch +pub radroots_trade::evidence::RadrootsRhiEvidenceReportError::UnsupportedVersion +impl core::error::Error for radroots_trade::evidence::RadrootsRhiEvidenceReportError +impl core::fmt::Display for radroots_trade::evidence::RadrootsRhiEvidenceReportError +pub fn radroots_trade::evidence::RadrootsRhiEvidenceReportError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub enum radroots_trade::evidence::RadrootsTradeAttestationResultV1 pub radroots_trade::evidence::RadrootsTradeAttestationResultV1::Invalid pub radroots_trade::evidence::RadrootsTradeAttestationResultV1::Valid @@ -53,6 +73,52 @@ pub radroots_trade::evidence::RadrootsTradeEvidenceStateV1::Complete pub radroots_trade::evidence::RadrootsTradeEvidenceStateV1::Missing pub radroots_trade::evidence::RadrootsTradeEvidenceStateV1::QueryPartial pub radroots_trade::evidence::RadrootsTradeEvidenceStateV1::UnsupportedVersion +pub struct radroots_trade::evidence::RadrootsRhiEvidenceReasonCodeV1(_) +impl radroots_trade::evidence::RadrootsRhiEvidenceReasonCodeV1 +pub fn radroots_trade::evidence::RadrootsRhiEvidenceReasonCodeV1::as_str(&self) -> &str +pub fn radroots_trade::evidence::RadrootsRhiEvidenceReasonCodeV1::parse(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_trade::evidence::RadrootsRhiEvidenceReportError> +impl core::fmt::Debug for radroots_trade::evidence::RadrootsRhiEvidenceReasonCodeV1 +pub fn radroots_trade::evidence::RadrootsRhiEvidenceReasonCodeV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_trade::evidence::RadrootsRhiEvidenceReportV1 +impl radroots_trade::evidence::RadrootsRhiEvidenceReportV1 +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::attestation_method(&self) -> &'static str +pub fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::canonical_content(&self) -> &str +pub fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::canonical_statement_payload(&self) -> &str +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::claim_mutation_id(&self) -> radroots_event::id::MutationId +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::contract_id(&self) -> &'static str +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::contract_version(&self) -> u16 +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::evidence_manifest_digest(&self) -> radroots_trade::evidence::RadrootsTradeEvidenceManifestDigestV1 +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::evidence_policy_digest(&self) -> radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1 +pub fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::from_canonical_content(&[u8]) -> core::result::Result<Self, radroots_trade::evidence::RadrootsRhiEvidenceReportError> +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::issuer_public_key(&self) -> radroots_identity::key::PublicKey +pub fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::new<R>(radroots_identity::key::PublicKey, radroots_event::id::MutationId, radroots_trade::evidence::RadrootsTradeEvidenceOutcomeV1, R, radroots_trade::evidence::RadrootsTradeEvidenceProjectionDigestV1, &radroots_trade::evidence::RadrootsTradeEvidenceManifestV1, core::option::Option<radroots_trade::evidence::RadrootsRhiEvidenceSupersessionV1>) -> core::result::Result<Self, radroots_trade::evidence::RadrootsRhiEvidenceReportError> where R: core::iter::traits::collect::IntoIterator<Item = radroots_trade::evidence::RadrootsRhiEvidenceReasonCodeV1> +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::observed_at_unix_s(&self) -> u64 +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::outcome(&self) -> radroots_trade::evidence::RadrootsTradeEvidenceOutcomeV1 +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::projection_digest(&self) -> radroots_trade::evidence::RadrootsTradeEvidenceProjectionDigestV1 +pub fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::reason_codes(&self) -> &[radroots_trade::evidence::RadrootsRhiEvidenceReasonCodeV1] +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::reducer_contract_id(&self) -> &'static str +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::reducer_contract_version(&self) -> u16 +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::statement_digest(&self) -> radroots_trade::evidence::RadrootsRhiEvidenceStatementDigestV1 +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::supersession(&self) -> core::option::Option<radroots_trade::evidence::RadrootsRhiEvidenceSupersessionV1> +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::trade_generation(&self) -> core::num::nonzero::NonZeroU64 +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::trade_id(&self) -> &radroots_event::id::TradeId +pub fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::validate_against_manifest(&self, &radroots_trade::evidence::RadrootsTradeEvidenceManifestV1) -> core::result::Result<(), radroots_trade::evidence::RadrootsRhiEvidenceReportError> +impl core::fmt::Debug for radroots_trade::evidence::RadrootsRhiEvidenceReportV1 +pub fn radroots_trade::evidence::RadrootsRhiEvidenceReportV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_trade::evidence::RadrootsRhiEvidenceStatementDigestV1(_) +impl radroots_trade::evidence::RadrootsRhiEvidenceStatementDigestV1 +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceStatementDigestV1::as_bytes(&self) -> &[u8; 32] +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceStatementDigestV1::from_bytes([u8; 32]) -> Self +pub fn radroots_trade::evidence::RadrootsRhiEvidenceStatementDigestV1::to_hex(&self) -> alloc::string::String +impl core::fmt::Debug for radroots_trade::evidence::RadrootsRhiEvidenceStatementDigestV1 +pub fn radroots_trade::evidence::RadrootsRhiEvidenceStatementDigestV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_trade::evidence::RadrootsRhiEvidenceSupersessionV1 +impl radroots_trade::evidence::RadrootsRhiEvidenceSupersessionV1 +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceSupersessionV1::event_id(&self) -> radroots_event::id::EventId +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceSupersessionV1::new(radroots_trade::evidence::RadrootsRhiEvidenceStatementDigestV1, radroots_event::id::EventId) -> Self +pub const fn radroots_trade::evidence::RadrootsRhiEvidenceSupersessionV1::report_id(&self) -> radroots_trade::evidence::RadrootsRhiEvidenceStatementDigestV1 +impl core::fmt::Debug for radroots_trade::evidence::RadrootsRhiEvidenceSupersessionV1 +pub fn radroots_trade::evidence::RadrootsRhiEvidenceSupersessionV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct radroots_trade::evidence::RadrootsTradeAttestationRecordV1 impl radroots_trade::evidence::RadrootsTradeAttestationRecordV1 pub const fn radroots_trade::evidence::RadrootsTradeAttestationRecordV1::claim_mutation_id(&self) -> &radroots_event::id::MutationId @@ -110,6 +176,14 @@ pub fn radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1::sha256(&[u pub fn radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1::to_hex(&self) -> alloc::string::String impl core::fmt::Debug for radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1 pub fn radroots_trade::evidence::RadrootsTradeEvidencePolicyDigestV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct radroots_trade::evidence::RadrootsTradeEvidenceProjectionDigestV1(_) +impl radroots_trade::evidence::RadrootsTradeEvidenceProjectionDigestV1 +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceProjectionDigestV1::as_bytes(&self) -> &[u8; 32] +pub const fn radroots_trade::evidence::RadrootsTradeEvidenceProjectionDigestV1::from_bytes([u8; 32]) -> Self +pub fn radroots_trade::evidence::RadrootsTradeEvidenceProjectionDigestV1::sha256(&[u8]) -> Self +pub fn radroots_trade::evidence::RadrootsTradeEvidenceProjectionDigestV1::to_hex(&self) -> alloc::string::String +impl core::fmt::Debug for radroots_trade::evidence::RadrootsTradeEvidenceProjectionDigestV1 +pub fn radroots_trade::evidence::RadrootsTradeEvidenceProjectionDigestV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1(_) impl radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1 pub const fn radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1::as_bytes(&self) -> &[u8; 32] @@ -156,6 +230,12 @@ pub fn radroots_trade::evidence::RadrootsTradeSignedEventDigestV1::sha256(&[u8]) pub fn radroots_trade::evidence::RadrootsTradeSignedEventDigestV1::to_hex(&self) -> alloc::string::String impl core::fmt::Debug for radroots_trade::evidence::RadrootsTradeSignedEventDigestV1 pub fn radroots_trade::evidence::RadrootsTradeSignedEventDigestV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub const radroots_trade::evidence::RADROOTS_RHI_EVIDENCE_ATTESTATION_METHOD: &str +pub const radroots_trade::evidence::RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_ID: &str +pub const radroots_trade::evidence::RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_VERSION: u16 +pub const radroots_trade::evidence::RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES: usize +pub const radroots_trade::evidence::RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_REASON_CODES: usize +pub const radroots_trade::evidence::RADROOTS_RHI_EVIDENCE_REPORT_REASON_CODE_MAXIMUM_BYTES: usize pub const radroots_trade::evidence::RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_ID: &str pub const radroots_trade::evidence::RADROOTS_TRADE_EVIDENCE_MANIFEST_CONTRACT_VERSION: u16 pub const radroots_trade::evidence::RADROOTS_TRADE_EVIDENCE_MANIFEST_MAXIMUM_BYTES: usize diff --git a/crates/trade/README.md b/crates/trade/README.md @@ -15,7 +15,7 @@ New code should enter through these modules: | Module | Responsibility | | --- | --- | -| `evidence` | Immutable mutation, private-term, and attestation observations plus bounded evidence-manifest models. | +| `evidence` | Immutable mutation, private-term, and attestation observations plus bounded evidence-manifest and RHI report models. | | `model` | Trade projection state and validated business identifiers. | | `reducer` | Deterministic reduction, evidence precedence, and conflict reporting. | | `validation` | Validation-error ownership for canonical trade inputs. | @@ -95,6 +95,31 @@ source, exact duplicates reject, and per-source admitted counts must match the frozen inventory. Building or parsing a manifest performs no I/O, source query, reduction, signing, persistence, or publication. +## Immutable evidence reports + +`evidence::RadrootsRhiEvidenceReportV1` binds one immutable report to an +issuer, claim mutation, outcome and sorted stable reason codes, the exact +reducer contract, a typed projection digest, and the policy, manifest, +observation time, trade, and nonzero generation frozen by an accepted evidence +manifest. Definitive Valid or Invalid reports require `ScopeSatisfied` +coverage; all other coverage states permit only Indeterminate. + +The report freezes the exact RFC 8785 JSON statement payload reserved by the +service-event contract. Its statement digest is SHA-256 over the fixed +`radroots:rhi-evidence-attestation-statement:v1` NUL-terminated domain followed +by those canonical bytes. The final canonical content adds equal `report_id` +and `statement_digest` fields without making the digest self-referential. +Supersession is a sealed both-or-neither report/event pair. Strict parsing caps +input at 16 KiB, rejects unknown, duplicate, missing, null, noncanonical, and +fixed-field drift, and reproduces the two governed current and superseding +vectors exactly. + +The report commits to a separately retained projection and evidence manifest; +it does not independently prove either record, the claim's existence, issuer +authority, a signature, or a Nostr event. Event construction, structural tags, +signature validation, storage, and publication remain outside this model-only +boundary. + ## Workflow planning `WorkflowPlan::prepare` validates a canonical proposal, decision, revision, @@ -114,7 +139,7 @@ or proof that referenced private material exists. | --- | --- | --- | | `std` | yes | Standard-library integration for the portable model and errors. | | `serde` | yes | Serialization support for native and versioned trade values. | -| `json` | yes | Executable JSON conformance vectors, deterministic projection digests, and immutable evidence manifests; enables `serde`. | +| `json` | yes | Executable JSON conformance vectors, deterministic projection and statement digests, and immutable evidence manifests/reports; enables `serde`. | `--no-default-features` keeps the allocation-backed trade model, reducer, and workflow planner available in `no_std` environments. Features are additive; @@ -126,8 +151,8 @@ of the Release V1 capability vocabulary. Serde represents validated values; deserialization does not perform actor authorization, signature verification, record lookup, or delivery. Canonical -JSON stability applies only to the explicitly governed reducer and workflow -conformance vectors. Rust data layout, +JSON stability applies only to the explicitly governed reducer, workflow, and +RHI evidence-report contracts. Rust data layout, debug formatting, and the pre-release public API are not wire contracts. Versioned `V1` names identify serialized or algorithm-contract generations. diff --git a/crates/trade/src/evidence.rs b/crates/trade/src/evidence.rs @@ -214,6 +214,18 @@ pub use crate::evidence_manifest::{ RadrootsTradeSignedEventDigestV1, }; +#[cfg(feature = "json")] +pub use crate::evidence_report::{ + RADROOTS_RHI_EVIDENCE_ATTESTATION_METHOD, RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_ID, + RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_VERSION, + RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES, + RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_REASON_CODES, + RADROOTS_RHI_EVIDENCE_REPORT_REASON_CODE_MAXIMUM_BYTES, RadrootsRhiEvidenceReasonCodeV1, + RadrootsRhiEvidenceReportError, RadrootsRhiEvidenceReportV1, + RadrootsRhiEvidenceStatementDigestV1, RadrootsRhiEvidenceSupersessionV1, + RadrootsTradeEvidenceProjectionDigestV1, +}; + #[cfg(test)] mod tests { #[cfg(not(feature = "std"))] diff --git a/crates/trade/src/evidence_report.rs b/crates/trade/src/evidence_report.rs @@ -0,0 +1,1008 @@ +#![forbid(unsafe_code)] + +//! Immutable RHI evidence reports derived from governed trade evidence. +//! +//! Reports bind a separately retained evidence manifest and projection. They +//! do not retrieve evidence, validate signatures, build events, persist state, +//! or publish anything. + +use alloc::{boxed::Box, string::String, vec::Vec}; +use core::{fmt, num::NonZeroU64}; + +use radroots_event::id::{EventId, MutationId, TradeId}; +use radroots_identity::PublicKey; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use crate::{ + evidence::RadrootsTradeEvidenceOutcomeV1, + evidence_manifest::{ + RadrootsTradeEvidenceManifestDigestV1, RadrootsTradeEvidenceManifestV1, + RadrootsTradeEvidencePolicyDigestV1, + }, + trade_contract_v1::{RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION}, +}; + +pub const RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_ID: &str = "radroots.rhi.evidence_attestation.v1"; +pub const RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_VERSION: u16 = 1; +pub const RADROOTS_RHI_EVIDENCE_ATTESTATION_METHOD: &str = "signed_evidence_snapshot"; +pub const RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_REASON_CODES: usize = 16; +pub const RADROOTS_RHI_EVIDENCE_REPORT_REASON_CODE_MAXIMUM_BYTES: usize = 64; +pub const RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES: usize = 16 * 1024; + +const STATEMENT_DIGEST_DOMAIN: &[u8] = b"radroots:rhi-evidence-attestation-statement:v1\0"; + +macro_rules! define_digest { + ($name:ident) => { + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name([u8; 32]); + + impl $name { + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub fn sha256(bytes: &[u8]) -> Self { + Self(Sha256::digest(bytes).into()) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn to_hex(&self) -> String { + hex::encode(self.0) + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(concat!(stringify!($name), "(<redacted>)")) + } + } + }; +} + +define_digest!(RadrootsTradeEvidenceProjectionDigestV1); + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsRhiEvidenceStatementDigestV1([u8; 32]); + +impl RadrootsRhiEvidenceStatementDigestV1 { + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn to_hex(&self) -> String { + hex::encode(self.0) + } +} + +impl fmt::Debug for RadrootsRhiEvidenceStatementDigestV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RadrootsRhiEvidenceStatementDigestV1(<redacted>)") + } +} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsRhiEvidenceReasonCodeV1(String); + +impl RadrootsRhiEvidenceReasonCodeV1 { + pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsRhiEvidenceReportError> { + let value = value.as_ref(); + let bytes = value.as_bytes(); + if bytes.is_empty() + || bytes.len() > RADROOTS_RHI_EVIDENCE_REPORT_REASON_CODE_MAXIMUM_BYTES + || !bytes[0].is_ascii_lowercase() + || !bytes[bytes.len() - 1].is_ascii_alphanumeric() + || bytes + .iter() + .any(|byte| !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_')) + { + return Err(RadrootsRhiEvidenceReportError::InvalidReasonCode); + } + Ok(Self(String::from(value))) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl fmt::Debug for RadrootsRhiEvidenceReasonCodeV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RadrootsRhiEvidenceReasonCodeV1(<redacted>)") + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RadrootsRhiEvidenceSupersessionV1 { + report_id: RadrootsRhiEvidenceStatementDigestV1, + event_id: EventId, +} + +impl RadrootsRhiEvidenceSupersessionV1 { + pub const fn new(report_id: RadrootsRhiEvidenceStatementDigestV1, event_id: EventId) -> Self { + Self { + report_id, + event_id, + } + } + + pub const fn report_id(&self) -> RadrootsRhiEvidenceStatementDigestV1 { + self.report_id + } + + pub const fn event_id(&self) -> EventId { + self.event_id + } +} + +impl fmt::Debug for RadrootsRhiEvidenceSupersessionV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RadrootsRhiEvidenceSupersessionV1(<redacted>)") + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct RadrootsRhiEvidenceReportV1 { + issuer_public_key: PublicKey, + trade_id: TradeId, + claim_mutation_id: MutationId, + outcome: RadrootsTradeEvidenceOutcomeV1, + reason_codes: Box<[RadrootsRhiEvidenceReasonCodeV1]>, + projection_digest: RadrootsTradeEvidenceProjectionDigestV1, + evidence_manifest_digest: RadrootsTradeEvidenceManifestDigestV1, + evidence_policy_digest: RadrootsTradeEvidencePolicyDigestV1, + observed_at_unix_s: u64, + supersession: Option<RadrootsRhiEvidenceSupersessionV1>, + trade_generation: NonZeroU64, + statement_digest: RadrootsRhiEvidenceStatementDigestV1, + canonical_statement_payload: Box<str>, + canonical_content: Box<str>, +} + +impl RadrootsRhiEvidenceReportV1 { + pub fn new<R>( + issuer_public_key: PublicKey, + claim_mutation_id: MutationId, + outcome: RadrootsTradeEvidenceOutcomeV1, + reason_codes: R, + projection_digest: RadrootsTradeEvidenceProjectionDigestV1, + manifest: &RadrootsTradeEvidenceManifestV1, + supersession: Option<RadrootsRhiEvidenceSupersessionV1>, + ) -> Result<Self, RadrootsRhiEvidenceReportError> + where + R: IntoIterator<Item = RadrootsRhiEvidenceReasonCodeV1>, + { + if !manifest.coverage().permits(outcome) { + return Err(RadrootsRhiEvidenceReportError::OutcomeNotPermitted); + } + let reason_codes = normalize_reason_codes(reason_codes)?; + Self::from_fields(ReportFields { + issuer_public_key, + trade_id: *manifest.trade_id(), + claim_mutation_id, + outcome, + reason_codes, + projection_digest, + evidence_manifest_digest: manifest.digest(), + evidence_policy_digest: manifest.evidence_policy_digest(), + observed_at_unix_s: manifest.observed_at_unix_s(), + supersession, + trade_generation: manifest.trade_generation(), + }) + } + + pub fn from_canonical_content(content: &[u8]) -> Result<Self, RadrootsRhiEvidenceReportError> { + if content.is_empty() { + return Err(RadrootsRhiEvidenceReportError::Malformed); + } + if content.len() > RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES { + return Err(RadrootsRhiEvidenceReportError::ReportTooLarge); + } + let raw: RawReport = serde_json::from_slice(content) + .map_err(|_| RadrootsRhiEvidenceReportError::Malformed)?; + if raw.contract_version != RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_VERSION { + return Err(RadrootsRhiEvidenceReportError::UnsupportedVersion); + } + if raw.contract_id != RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_ID + || raw.reducer_contract_id != RADROOTS_TRADE_REDUCER_CONTRACT_ID + || raw.reducer_contract_version != RADROOTS_TRADE_REDUCER_VERSION + || raw.attestation_method != RADROOTS_RHI_EVIDENCE_ATTESTATION_METHOD + { + return Err(RadrootsRhiEvidenceReportError::FixedFieldMismatch); + } + + let report_id = parse_digest(&raw.report_id)?; + let statement_digest = parse_digest(&raw.statement_digest)?; + if report_id != statement_digest { + return Err(RadrootsRhiEvidenceReportError::StatementDigestMismatch); + } + let supersession = parse_supersession( + raw.supersedes_report_id.as_deref(), + raw.supersedes_event_id.as_deref(), + )?; + let reason_codes = parse_reason_codes(raw.reason_codes)?; + let fields = ReportFields { + issuer_public_key: PublicKey::from_hex(&raw.issuer_pubkey) + .map_err(|_| RadrootsRhiEvidenceReportError::InvalidIdentifier)?, + trade_id: TradeId::parse(&raw.trade_id) + .map_err(|_| RadrootsRhiEvidenceReportError::InvalidIdentifier)?, + claim_mutation_id: MutationId::parse(&raw.claim_mutation_id) + .map_err(|_| RadrootsRhiEvidenceReportError::InvalidIdentifier)?, + outcome: parse_outcome(&raw.outcome)?, + reason_codes, + projection_digest: RadrootsTradeEvidenceProjectionDigestV1::from_bytes(parse_hex_32( + &raw.projection_digest, + )?), + evidence_manifest_digest: RadrootsTradeEvidenceManifestDigestV1::from_bytes( + parse_hex_32(&raw.evidence_manifest_digest)?, + ), + evidence_policy_digest: RadrootsTradeEvidencePolicyDigestV1::from_bytes(parse_hex_32( + &raw.evidence_policy_digest, + )?), + observed_at_unix_s: raw.observed_at_unix_s, + supersession, + trade_generation: NonZeroU64::new(raw.trade_generation) + .ok_or(RadrootsRhiEvidenceReportError::InvalidTradeGeneration)?, + }; + let report = Self::from_fields(fields)?; + if report.statement_digest != statement_digest { + return Err(RadrootsRhiEvidenceReportError::StatementDigestMismatch); + } + if report.canonical_content.as_bytes() != content { + return Err(RadrootsRhiEvidenceReportError::NonCanonical); + } + Ok(report) + } + + fn from_fields(fields: ReportFields) -> Result<Self, RadrootsRhiEvidenceReportError> { + let canonical_statement_payload = canonical_statement_payload(&fields)?; + let statement_digest = statement_digest(canonical_statement_payload.as_bytes()); + let canonical_content = canonical_report_content(&fields, statement_digest)?; + if canonical_content.len() > RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES { + return Err(RadrootsRhiEvidenceReportError::ReportTooLarge); + } + Ok(Self { + issuer_public_key: fields.issuer_public_key, + trade_id: fields.trade_id, + claim_mutation_id: fields.claim_mutation_id, + outcome: fields.outcome, + reason_codes: fields.reason_codes, + projection_digest: fields.projection_digest, + evidence_manifest_digest: fields.evidence_manifest_digest, + evidence_policy_digest: fields.evidence_policy_digest, + observed_at_unix_s: fields.observed_at_unix_s, + supersession: fields.supersession, + trade_generation: fields.trade_generation, + statement_digest, + canonical_statement_payload: canonical_statement_payload.into_boxed_str(), + canonical_content: canonical_content.into_boxed_str(), + }) + } + + pub fn validate_against_manifest( + &self, + manifest: &RadrootsTradeEvidenceManifestV1, + ) -> Result<(), RadrootsRhiEvidenceReportError> { + if self.trade_id != *manifest.trade_id() + || self.trade_generation != manifest.trade_generation() + || self.evidence_manifest_digest != manifest.digest() + || self.evidence_policy_digest != manifest.evidence_policy_digest() + || self.observed_at_unix_s != manifest.observed_at_unix_s() + { + return Err(RadrootsRhiEvidenceReportError::ManifestMismatch); + } + if !manifest.coverage().permits(self.outcome) { + return Err(RadrootsRhiEvidenceReportError::OutcomeNotPermitted); + } + Ok(()) + } + + pub const fn contract_id(&self) -> &'static str { + RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_ID + } + + pub const fn contract_version(&self) -> u16 { + RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_VERSION + } + + pub const fn issuer_public_key(&self) -> PublicKey { + self.issuer_public_key + } + + pub const fn trade_id(&self) -> &TradeId { + &self.trade_id + } + + pub const fn claim_mutation_id(&self) -> MutationId { + self.claim_mutation_id + } + + pub const fn outcome(&self) -> RadrootsTradeEvidenceOutcomeV1 { + self.outcome + } + + pub fn reason_codes(&self) -> &[RadrootsRhiEvidenceReasonCodeV1] { + &self.reason_codes + } + + pub const fn projection_digest(&self) -> RadrootsTradeEvidenceProjectionDigestV1 { + self.projection_digest + } + + pub const fn evidence_manifest_digest(&self) -> RadrootsTradeEvidenceManifestDigestV1 { + self.evidence_manifest_digest + } + + pub const fn evidence_policy_digest(&self) -> RadrootsTradeEvidencePolicyDigestV1 { + self.evidence_policy_digest + } + + pub const fn observed_at_unix_s(&self) -> u64 { + self.observed_at_unix_s + } + + pub const fn supersession(&self) -> Option<RadrootsRhiEvidenceSupersessionV1> { + self.supersession + } + + pub const fn trade_generation(&self) -> NonZeroU64 { + self.trade_generation + } + + pub const fn reducer_contract_id(&self) -> &'static str { + RADROOTS_TRADE_REDUCER_CONTRACT_ID + } + + pub const fn reducer_contract_version(&self) -> u16 { + RADROOTS_TRADE_REDUCER_VERSION + } + + pub const fn attestation_method(&self) -> &'static str { + RADROOTS_RHI_EVIDENCE_ATTESTATION_METHOD + } + + pub const fn statement_digest(&self) -> RadrootsRhiEvidenceStatementDigestV1 { + self.statement_digest + } + + pub fn canonical_statement_payload(&self) -> &str { + &self.canonical_statement_payload + } + + pub fn canonical_content(&self) -> &str { + &self.canonical_content + } +} + +impl fmt::Debug for RadrootsRhiEvidenceReportV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RadrootsRhiEvidenceReportV1") + .field("outcome", &self.outcome) + .field("reason_code_count", &self.reason_codes.len()) + .field("has_supersession", &self.supersession.is_some()) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsRhiEvidenceReportError { + ReasonCodeCountOutOfRange, + InvalidReasonCode, + DuplicateReasonCode, + OutcomeNotPermitted, + ReportTooLarge, + Malformed, + UnsupportedVersion, + FixedFieldMismatch, + InvalidIdentifier, + InvalidDigest, + InvalidOutcome, + InvalidTradeGeneration, + IncompleteSupersession, + StatementDigestMismatch, + NonCanonical, + ManifestMismatch, +} + +impl fmt::Display for RadrootsRhiEvidenceReportError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::ReasonCodeCountOutOfRange => "evidence report reason-code count is out of range", + Self::InvalidReasonCode => "evidence report reason code is invalid", + Self::DuplicateReasonCode => "evidence report contains a duplicate reason code", + Self::OutcomeNotPermitted => "evidence report outcome is not permitted by coverage", + Self::ReportTooLarge => "evidence report exceeds its canonical byte limit", + Self::Malformed => "evidence report encoding is malformed", + Self::UnsupportedVersion => "evidence report version is unsupported", + Self::FixedFieldMismatch => "evidence report fixed field does not match the contract", + Self::InvalidIdentifier => "evidence report identifier is invalid", + Self::InvalidDigest => "evidence report digest is invalid", + Self::InvalidOutcome => "evidence report outcome is invalid", + Self::InvalidTradeGeneration => "evidence report trade generation is invalid", + Self::IncompleteSupersession => "evidence report supersession is incomplete", + Self::StatementDigestMismatch => "evidence report statement digest does not match", + Self::NonCanonical => "evidence report encoding is not canonical", + Self::ManifestMismatch => "evidence report does not match the evidence manifest", + }) + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsRhiEvidenceReportError {} + +struct ReportFields { + issuer_public_key: PublicKey, + trade_id: TradeId, + claim_mutation_id: MutationId, + outcome: RadrootsTradeEvidenceOutcomeV1, + reason_codes: Box<[RadrootsRhiEvidenceReasonCodeV1]>, + projection_digest: RadrootsTradeEvidenceProjectionDigestV1, + evidence_manifest_digest: RadrootsTradeEvidenceManifestDigestV1, + evidence_policy_digest: RadrootsTradeEvidencePolicyDigestV1, + observed_at_unix_s: u64, + supersession: Option<RadrootsRhiEvidenceSupersessionV1>, + trade_generation: NonZeroU64, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawReport { + attestation_method: String, + claim_mutation_id: String, + contract_id: String, + contract_version: u16, + evidence_manifest_digest: String, + evidence_policy_digest: String, + issuer_pubkey: String, + observed_at_unix_s: u64, + outcome: String, + projection_digest: String, + reason_codes: Vec<String>, + reducer_contract_id: String, + reducer_contract_version: u16, + report_id: String, + statement_digest: String, + supersedes_event_id: Option<String>, + supersedes_report_id: Option<String>, + trade_generation: u64, + trade_id: String, +} + +fn normalize_reason_codes( + reason_codes: impl IntoIterator<Item = RadrootsRhiEvidenceReasonCodeV1>, +) -> Result<Box<[RadrootsRhiEvidenceReasonCodeV1]>, RadrootsRhiEvidenceReportError> { + let mut values = Vec::new(); + for value in reason_codes { + if values.len() == RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_REASON_CODES { + return Err(RadrootsRhiEvidenceReportError::ReasonCodeCountOutOfRange); + } + values.push(value); + } + if values.is_empty() { + return Err(RadrootsRhiEvidenceReportError::ReasonCodeCountOutOfRange); + } + values.sort(); + if values.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(RadrootsRhiEvidenceReportError::DuplicateReasonCode); + } + Ok(values.into_boxed_slice()) +} + +fn parse_reason_codes( + values: Vec<String>, +) -> Result<Box<[RadrootsRhiEvidenceReasonCodeV1]>, RadrootsRhiEvidenceReportError> { + if values.len() > RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_REASON_CODES { + return Err(RadrootsRhiEvidenceReportError::ReasonCodeCountOutOfRange); + } + let original = values.iter().map(String::as_str).collect::<Vec<_>>(); + let reason_codes = normalize_reason_codes( + values + .iter() + .map(RadrootsRhiEvidenceReasonCodeV1::parse) + .collect::<Result<Vec<_>, _>>()?, + )?; + if original.iter().copied().ne(reason_codes + .iter() + .map(RadrootsRhiEvidenceReasonCodeV1::as_str)) + { + return Err(RadrootsRhiEvidenceReportError::NonCanonical); + } + Ok(reason_codes) +} + +fn parse_supersession( + report_id: Option<&str>, + event_id: Option<&str>, +) -> Result<Option<RadrootsRhiEvidenceSupersessionV1>, RadrootsRhiEvidenceReportError> { + match (report_id, event_id) { + (None, None) => Ok(None), + (Some(report_id), Some(event_id)) => Ok(Some(RadrootsRhiEvidenceSupersessionV1::new( + parse_digest(report_id)?, + EventId::parse(event_id) + .map_err(|_| RadrootsRhiEvidenceReportError::InvalidIdentifier)?, + ))), + _ => Err(RadrootsRhiEvidenceReportError::IncompleteSupersession), + } +} + +fn parse_outcome( + value: &str, +) -> Result<RadrootsTradeEvidenceOutcomeV1, RadrootsRhiEvidenceReportError> { + match value { + "valid" => Ok(RadrootsTradeEvidenceOutcomeV1::Valid), + "invalid" => Ok(RadrootsTradeEvidenceOutcomeV1::Invalid), + "indeterminate" => Ok(RadrootsTradeEvidenceOutcomeV1::Indeterminate), + _ => Err(RadrootsRhiEvidenceReportError::InvalidOutcome), + } +} + +const fn outcome_name(outcome: RadrootsTradeEvidenceOutcomeV1) -> &'static str { + match outcome { + RadrootsTradeEvidenceOutcomeV1::Valid => "valid", + RadrootsTradeEvidenceOutcomeV1::Invalid => "invalid", + RadrootsTradeEvidenceOutcomeV1::Indeterminate => "indeterminate", + } +} + +fn parse_digest( + value: &str, +) -> Result<RadrootsRhiEvidenceStatementDigestV1, RadrootsRhiEvidenceReportError> { + Ok(RadrootsRhiEvidenceStatementDigestV1::from_bytes( + parse_hex_32(value)?, + )) +} + +fn parse_hex_32(value: &str) -> Result<[u8; 32], RadrootsRhiEvidenceReportError> { + if value.len() != 64 + || value + .bytes() + .any(|byte| !(byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) + { + return Err(RadrootsRhiEvidenceReportError::InvalidDigest); + } + let decoded = hex::decode(value).map_err(|_| RadrootsRhiEvidenceReportError::InvalidDigest)?; + decoded + .try_into() + .map_err(|_| RadrootsRhiEvidenceReportError::InvalidDigest) +} + +fn statement_digest(payload: &[u8]) -> RadrootsRhiEvidenceStatementDigestV1 { + let mut digest = Sha256::new(); + digest.update(STATEMENT_DIGEST_DOMAIN); + digest.update(payload); + RadrootsRhiEvidenceStatementDigestV1::from_bytes(digest.finalize().into()) +} + +fn canonical_statement_payload( + fields: &ReportFields, +) -> Result<String, RadrootsRhiEvidenceReportError> { + canonical_json(statement_value(fields)) +} + +fn canonical_report_content( + fields: &ReportFields, + statement_digest: RadrootsRhiEvidenceStatementDigestV1, +) -> Result<String, RadrootsRhiEvidenceReportError> { + let mut value = statement_value(fields); + let object = value + .as_object_mut() + .ok_or(RadrootsRhiEvidenceReportError::Malformed)?; + object.insert("report_id".into(), json!(statement_digest.to_hex())); + object.insert("statement_digest".into(), json!(statement_digest.to_hex())); + canonical_json(value) +} + +fn statement_value(fields: &ReportFields) -> Value { + let (supersedes_report_id, supersedes_event_id) = + fields + .supersession + .map_or((Value::Null, Value::Null), |supersession| { + ( + json!(supersession.report_id.to_hex()), + json!(supersession.event_id.to_hex()), + ) + }); + json!({ + "contract_id": RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_ID, + "contract_version": RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_VERSION, + "issuer_pubkey": fields.issuer_public_key.to_hex(), + "trade_id": fields.trade_id.to_hex(), + "claim_mutation_id": fields.claim_mutation_id.to_hex(), + "outcome": outcome_name(fields.outcome), + "reason_codes": fields.reason_codes.iter().map(RadrootsRhiEvidenceReasonCodeV1::as_str).collect::<Vec<_>>(), + "reducer_contract_id": RADROOTS_TRADE_REDUCER_CONTRACT_ID, + "reducer_contract_version": RADROOTS_TRADE_REDUCER_VERSION, + "projection_digest": fields.projection_digest.to_hex(), + "evidence_manifest_digest": fields.evidence_manifest_digest.to_hex(), + "evidence_policy_digest": fields.evidence_policy_digest.to_hex(), + "observed_at_unix_s": fields.observed_at_unix_s, + "attestation_method": RADROOTS_RHI_EVIDENCE_ATTESTATION_METHOD, + "supersedes_report_id": supersedes_report_id, + "supersedes_event_id": supersedes_event_id, + "trade_generation": fields.trade_generation.get(), + }) +} + +fn canonical_json(value: Value) -> Result<String, RadrootsRhiEvidenceReportError> { + radroots_event::trade::canonical_jcs_value(&value) + .map_err(|_| RadrootsRhiEvidenceReportError::Malformed) +} + +#[cfg(test)] +mod tests { + use alloc::{format, vec}; + + use super::*; + use crate::evidence::{ + RadrootsTradeEvidenceCoverageV1, RadrootsTradeEvidenceManifestSourceResultV1, + RadrootsTradeEvidenceScopePrerequisitesV1, RadrootsTradeEvidenceSourceCompletionV1, + RadrootsTradeEvidenceSourceRequirementV1, RadrootsTradeEvidenceSourceResultDigestV1, + RadrootsTradeEvidenceSourceResultV1, + }; + + const CURRENT_REPORT: &str = "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44\",\"statement_digest\":\"461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44\",\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}"; + + const CURRENT_STATEMENT: &str = "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000000,\"outcome\":\"indeterminate\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"required_source_incomplete\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":7,\"trade_id\":\"11111111111111111111111111111111\"}"; + + const SUPERSEDING_REPORT: &str = "{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"4444444444444444444444444444444444444444444444444444444444444444\",\"evidence_policy_digest\":\"5555555555555555555555555555555555555555555555555555555555555555\",\"issuer_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"observed_at_unix_s\":1800000100,\"outcome\":\"valid\",\"projection_digest\":\"6666666666666666666666666666666666666666666666666666666666666666\",\"reason_codes\":[\"scope_satisfied\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807\",\"statement_digest\":\"61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807\",\"supersedes_event_id\":\"8888888888888888888888888888888888888888888888888888888888888888\",\"supersedes_report_id\":\"7777777777777777777777777777777777777777777777777777777777777777\",\"trade_generation\":8,\"trade_id\":\"11111111111111111111111111111111\"}"; + + fn manifest(coverage: RadrootsTradeEvidenceCoverageV1) -> RadrootsTradeEvidenceManifestV1 { + let completion = match coverage { + RadrootsTradeEvidenceCoverageV1::ScopeSatisfied => { + RadrootsTradeEvidenceSourceCompletionV1::Complete + } + RadrootsTradeEvidenceCoverageV1::Unsupported => { + RadrootsTradeEvidenceSourceCompletionV1::Unsupported + } + RadrootsTradeEvidenceCoverageV1::Partial => { + RadrootsTradeEvidenceSourceCompletionV1::Complete + } + RadrootsTradeEvidenceCoverageV1::Missing => { + RadrootsTradeEvidenceSourceCompletionV1::Incomplete + } + }; + let scope = if matches!(coverage, RadrootsTradeEvidenceCoverageV1::ScopeSatisfied) { + RadrootsTradeEvidenceScopePrerequisitesV1::Satisfied + } else { + RadrootsTradeEvidenceScopePrerequisitesV1::Unsatisfied + }; + let manifest = RadrootsTradeEvidenceManifestV1::new( + TradeId::from_bytes([0x11; 16]), + NonZeroU64::new(7).unwrap(), + RadrootsTradeEvidencePolicyDigestV1::from_bytes([0x55; 32]), + 1_800_000_000, + scope, + [RadrootsTradeEvidenceManifestSourceResultV1::new( + crate::evidence::RadrootsTradeEvidenceSourceIdV1::parse("relay_a").unwrap(), + RadrootsTradeEvidenceSourceResultV1::new( + RadrootsTradeEvidenceSourceRequirementV1::Required, + completion, + 0, + ) + .unwrap(), + RadrootsTradeEvidenceSourceResultDigestV1::from_bytes([0x33; 32]), + )], + [], + ) + .unwrap(); + assert_eq!(manifest.coverage(), coverage); + manifest + } + + fn report( + coverage: RadrootsTradeEvidenceCoverageV1, + outcome: RadrootsTradeEvidenceOutcomeV1, + ) -> Result<RadrootsRhiEvidenceReportV1, RadrootsRhiEvidenceReportError> { + RadrootsRhiEvidenceReportV1::new( + PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df") + .unwrap(), + MutationId::from_bytes([0x22; 32]), + outcome, + [RadrootsRhiEvidenceReasonCodeV1::parse("scope_satisfied").unwrap()], + RadrootsTradeEvidenceProjectionDigestV1::from_bytes([0x66; 32]), + &manifest(coverage), + None, + ) + } + + fn report_with_reasons( + manifest: &RadrootsTradeEvidenceManifestV1, + reasons: impl IntoIterator<Item = RadrootsRhiEvidenceReasonCodeV1>, + ) -> Result<RadrootsRhiEvidenceReportV1, RadrootsRhiEvidenceReportError> { + RadrootsRhiEvidenceReportV1::new( + PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df") + .unwrap(), + MutationId::from_bytes([0x22; 32]), + RadrootsTradeEvidenceOutcomeV1::Valid, + reasons, + RadrootsTradeEvidenceProjectionDigestV1::from_bytes([0x66; 32]), + manifest, + None, + ) + } + + #[test] + fn fixed_report_vectors_match_statement_hashes_and_supersession() { + let current = + RadrootsRhiEvidenceReportV1::from_canonical_content(CURRENT_REPORT.as_bytes()) + .expect("current report vector"); + assert_eq!(current.canonical_statement_payload(), CURRENT_STATEMENT); + assert_eq!(current.canonical_content(), CURRENT_REPORT); + assert_eq!( + current.statement_digest().to_hex(), + "461acfea579481f6aadba47c31abc07eaa700f66abc074b8926ba41266082b44" + ); + assert!(current.supersession().is_none()); + + let superseding = + RadrootsRhiEvidenceReportV1::from_canonical_content(SUPERSEDING_REPORT.as_bytes()) + .expect("superseding report vector"); + assert_eq!( + superseding.statement_digest().to_hex(), + "61af3caa6a3a14ff7bb026e9f294826d1d1957a5aff3d814156acee9cf0d5807" + ); + assert!(superseding.supersession().is_some()); + } + + #[test] + fn construction_is_canonical_manifest_bound_and_permutation_invariant() { + let manifest = manifest(RadrootsTradeEvidenceCoverageV1::ScopeSatisfied); + let a = RadrootsRhiEvidenceReportV1::new( + PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df") + .unwrap(), + MutationId::from_bytes([0x22; 32]), + RadrootsTradeEvidenceOutcomeV1::Valid, + [ + RadrootsRhiEvidenceReasonCodeV1::parse("source_complete").unwrap(), + RadrootsRhiEvidenceReasonCodeV1::parse("scope_satisfied").unwrap(), + ], + RadrootsTradeEvidenceProjectionDigestV1::from_bytes([0x66; 32]), + &manifest, + None, + ) + .unwrap(); + let b = RadrootsRhiEvidenceReportV1::new( + a.issuer_public_key(), + a.claim_mutation_id(), + a.outcome(), + a.reason_codes().iter().cloned().rev(), + a.projection_digest(), + &manifest, + None, + ) + .unwrap(); + assert_eq!(a.canonical_content(), b.canonical_content()); + assert_eq!(a.statement_digest(), b.statement_digest()); + a.validate_against_manifest(&manifest).unwrap(); + assert_eq!( + RadrootsRhiEvidenceReportV1::from_canonical_content(a.canonical_content().as_bytes()), + Ok(a) + ); + } + + #[test] + fn coverage_outcome_matrix_fails_closed() { + use RadrootsTradeEvidenceCoverageV1::{Missing, Partial, ScopeSatisfied, Unsupported}; + use RadrootsTradeEvidenceOutcomeV1::{Indeterminate, Invalid, Valid}; + + for coverage in [Missing, Partial, ScopeSatisfied, Unsupported] { + for outcome in [Valid, Invalid, Indeterminate] { + let result = report(coverage, outcome); + assert_eq!(result.is_ok(), coverage.permits(outcome)); + } + } + } + + #[test] + fn reason_codes_are_bounded_validated_sorted_and_unique() { + for invalid in ["", "Upper", "ends_", "has-hyphen", "has space"] { + assert_eq!( + RadrootsRhiEvidenceReasonCodeV1::parse(invalid), + Err(RadrootsRhiEvidenceReportError::InvalidReasonCode) + ); + } + assert!(RadrootsRhiEvidenceReasonCodeV1::parse("a".repeat(64)).is_ok()); + assert_eq!( + RadrootsRhiEvidenceReasonCodeV1::parse("a".repeat(65)), + Err(RadrootsRhiEvidenceReportError::InvalidReasonCode) + ); + + let manifest = manifest(RadrootsTradeEvidenceCoverageV1::ScopeSatisfied); + assert_eq!( + report_with_reasons(&manifest, Vec::new()), + Err(RadrootsRhiEvidenceReportError::ReasonCodeCountOutOfRange) + ); + let maximum = (0..16) + .map(|index| RadrootsRhiEvidenceReasonCodeV1::parse(format!("reason_{index:02}"))) + .collect::<Result<Vec<_>, _>>() + .unwrap(); + assert!(report_with_reasons(&manifest, maximum.clone()).is_ok()); + assert_eq!( + report_with_reasons( + &manifest, + maximum.into_iter().chain(core::iter::repeat( + RadrootsRhiEvidenceReasonCodeV1::parse("overflow").unwrap(), + )), + ), + Err(RadrootsRhiEvidenceReportError::ReasonCodeCountOutOfRange) + ); + let duplicate = RadrootsRhiEvidenceReasonCodeV1::parse("same").unwrap(); + assert_eq!( + report_with_reasons(&manifest, [duplicate.clone(), duplicate]), + Err(RadrootsRhiEvidenceReportError::DuplicateReasonCode) + ); + } + + #[test] + fn parser_rejects_noncanonical_malformed_and_unbound_reports() { + let mut value: Value = serde_json::from_str(CURRENT_REPORT).unwrap(); + value["report_id"] = json!("00".repeat(32)); + assert_eq!( + RadrootsRhiEvidenceReportV1::from_canonical_content( + canonical_json(value).unwrap().as_bytes() + ), + Err(RadrootsRhiEvidenceReportError::StatementDigestMismatch) + ); + assert_eq!( + RadrootsRhiEvidenceReportV1::from_canonical_content( + format!(" {CURRENT_REPORT}").as_bytes() + ), + Err(RadrootsRhiEvidenceReportError::NonCanonical) + ); + assert_eq!( + RadrootsRhiEvidenceReportV1::from_canonical_content( + CURRENT_REPORT + .replacen( + "\"contract_version\":1", + "\"contract_version\":1,\"contract_version\":1", + 1, + ) + .as_bytes() + ), + Err(RadrootsRhiEvidenceReportError::Malformed) + ); + assert_eq!( + RadrootsRhiEvidenceReportV1::from_canonical_content( + CURRENT_REPORT + .replacen( + "\"contract_version\":1", + "\"contract_version\":1,\"unknown\":true", + 1, + ) + .as_bytes() + ), + Err(RadrootsRhiEvidenceReportError::Malformed) + ); + assert_eq!( + RadrootsRhiEvidenceReportV1::from_canonical_content( + CURRENT_REPORT + .replacen( + "\"supersedes_report_id\":null", + &format!("\"supersedes_report_id\":\"{}\"", "77".repeat(32)), + 1, + ) + .as_bytes() + ), + Err(RadrootsRhiEvidenceReportError::IncompleteSupersession) + ); + assert_eq!( + RadrootsRhiEvidenceReportV1::from_canonical_content(&vec![ + b'x'; + RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES + + 1 + ]), + Err(RadrootsRhiEvidenceReportError::ReportTooLarge) + ); + assert_eq!( + RadrootsRhiEvidenceReportV1::from_canonical_content(&vec![ + b'x'; + RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES + ]), + Err(RadrootsRhiEvidenceReportError::Malformed) + ); + } + + #[test] + fn parser_rejects_every_fixed_identity_and_reason_drift() { + for (from, to, error) in [ + ( + "\"contract_version\":1", + "\"contract_version\":2", + RadrootsRhiEvidenceReportError::UnsupportedVersion, + ), + ( + "radroots.rhi.evidence_attestation.v1", + "radroots.rhi.evidence_attestation.v2", + RadrootsRhiEvidenceReportError::FixedFieldMismatch, + ), + ( + "signed_evidence_snapshot", + "unsigned_evidence_snapshot", + RadrootsRhiEvidenceReportError::FixedFieldMismatch, + ), + ( + "\"outcome\":\"indeterminate\"", + "\"outcome\":\"complete\"", + RadrootsRhiEvidenceReportError::InvalidOutcome, + ), + ( + "\"trade_generation\":7", + "\"trade_generation\":0", + RadrootsRhiEvidenceReportError::InvalidTradeGeneration, + ), + ( + "\"projection_digest\":\"6666", + "\"projection_digest\":\"GG66", + RadrootsRhiEvidenceReportError::InvalidDigest, + ), + ] { + assert_eq!( + RadrootsRhiEvidenceReportV1::from_canonical_content( + CURRENT_REPORT.replacen(from, to, 1).as_bytes() + ), + Err(error) + ); + } + + assert_eq!( + RadrootsRhiEvidenceReportV1::from_canonical_content( + CURRENT_REPORT + .replacen( + "[\"required_source_incomplete\"]", + "[\"z_reason\",\"a_reason\"]", + 1, + ) + .as_bytes() + ), + Err(RadrootsRhiEvidenceReportError::NonCanonical) + ); + assert_eq!( + RadrootsRhiEvidenceReportV1::from_canonical_content( + CURRENT_REPORT + .replacen("[\"required_source_incomplete\"]", "[\"same\",\"same\"]", 1,) + .as_bytes() + ), + Err(RadrootsRhiEvidenceReportError::DuplicateReasonCode) + ); + + let parsed = + RadrootsRhiEvidenceReportV1::from_canonical_content(CURRENT_REPORT.as_bytes()).unwrap(); + assert_eq!( + parsed.validate_against_manifest(&manifest( + RadrootsTradeEvidenceCoverageV1::ScopeSatisfied + )), + Err(RadrootsRhiEvidenceReportError::ManifestMismatch) + ); + } + + #[test] + fn diagnostics_redact_identifiers_digests_and_content() { + let report = report( + RadrootsTradeEvidenceCoverageV1::ScopeSatisfied, + RadrootsTradeEvidenceOutcomeV1::Valid, + ) + .unwrap(); + let debug = format!("{report:?}"); + for secret in [ + report.trade_id().to_hex(), + report.claim_mutation_id().to_hex(), + report.statement_digest().to_hex(), + ] { + assert!(!debug.contains(&secret)); + } + assert_eq!( + RadrootsRhiEvidenceReportError::ManifestMismatch.to_string(), + "evidence report does not match the evidence manifest" + ); + assert!(std::error::Error::source(&RadrootsRhiEvidenceReportError::Malformed).is_none()); + } +} diff --git a/crates/trade/src/lib.rs b/crates/trade/src/lib.rs @@ -11,6 +11,9 @@ mod test_fixtures; #[cfg(feature = "json")] mod evidence_manifest; +#[cfg(feature = "json")] +mod evidence_report; + pub mod evidence; pub mod model; pub mod reducer; diff --git a/crates/trade/tests/package_boundary.rs b/crates/trade/tests/package_boundary.rs @@ -6,6 +6,7 @@ use radroots_trade::{evidence as _, model as _, reducer as _, validation as _, w const MANIFEST: &str = include_str!("../Cargo.toml"); const EVIDENCE: &str = include_str!("../src/evidence.rs"); const EVIDENCE_MANIFEST: &str = include_str!("../src/evidence_manifest.rs"); +const EVIDENCE_REPORT: &str = include_str!("../src/evidence_report.rs"); const MODEL: &str = include_str!("../src/model.rs"); const OPERATIONS: &str = include_str!("../../../contracts/operations.toml"); const ROOT: &str = include_str!("../src/lib.rs"); @@ -15,6 +16,11 @@ const CANONICAL_REDUCER_VECTORS: &str = const PACKAGED_REDUCER_VECTORS: &str = include_str!("fixtures/reduce_records.v1.json"); const CANONICAL_WORKFLOW_VECTORS: &str = include_str!("../../../contracts/conformance/vectors/trade/prepare_workflow.v1.json"); +const CANONICAL_RHI_REPORT_VECTORS: &str = include_str!( + "../../../contracts/conformance/vectors/rhi/evidence_attestation_decision.v1.json" +); +const SERVICE_EVENT_DECISION: &str = + include_str!("../../../contracts/architecture/decisions/services_hardening_events.v1.json"); const PACKAGED_WORKFLOW_VECTORS: &str = include_str!("fixtures/prepare_workflow.v1.json"); const WORKFLOW: &str = include_str!("../src/workflow.rs"); const PACKAGE_TIERS: &str = include_str!("../../../contracts/releases/package_tiers.toml"); @@ -365,6 +371,117 @@ fn immutable_evidence_manifest_is_sealed_bounded_and_side_effect_free() { } #[test] +fn immutable_evidence_report_is_sealed_bounded_and_vector_bound() { + use radroots_trade::evidence::{ + RADROOTS_RHI_EVIDENCE_ATTESTATION_METHOD, RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_ID, + RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_VERSION, + RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES, + RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_REASON_CODES, + RADROOTS_RHI_EVIDENCE_REPORT_REASON_CODE_MAXIMUM_BYTES, RadrootsRhiEvidenceReasonCodeV1, + RadrootsRhiEvidenceReportError, RadrootsRhiEvidenceReportV1, + RadrootsRhiEvidenceStatementDigestV1, RadrootsRhiEvidenceSupersessionV1, + RadrootsTradeEvidenceProjectionDigestV1, + }; + + fn assert_portable<T: Clone + core::fmt::Debug + Eq + Send + Sync>() {} + assert_portable::<RadrootsRhiEvidenceReasonCodeV1>(); + assert_portable::<RadrootsRhiEvidenceReportError>(); + assert_portable::<RadrootsRhiEvidenceReportV1>(); + assert_portable::<RadrootsRhiEvidenceStatementDigestV1>(); + assert_portable::<RadrootsRhiEvidenceSupersessionV1>(); + assert_portable::<RadrootsTradeEvidenceProjectionDigestV1>(); + + assert_eq!( + RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_ID, + "radroots.rhi.evidence_attestation.v1" + ); + assert_eq!(RADROOTS_RHI_EVIDENCE_REPORT_CONTRACT_VERSION, 1); + assert_eq!( + RADROOTS_RHI_EVIDENCE_ATTESTATION_METHOD, + "signed_evidence_snapshot" + ); + assert_eq!(RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_REASON_CODES, 16); + assert_eq!(RADROOTS_RHI_EVIDENCE_REPORT_REASON_CODE_MAXIMUM_BYTES, 64); + assert_eq!( + RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES, + 16 * 1024 + ); + + for required in [ + "requires_both_references_or_neither", + "report_id_equals_statement_digest", + "reason_codes_sorted_unique", + ] { + assert!( + SERVICE_EVENT_DECISION.contains(required), + "service-event decision is missing {required}" + ); + } + assert!(EVIDENCE_REPORT.contains("radroots:rhi-evidence-attestation-statement:v1\\0")); + + let vectors: serde_json::Value = + serde_json::from_str(CANONICAL_RHI_REPORT_VECTORS).expect("RHI report vectors"); + for vector in vectors["vectors"] + .as_array() + .expect("RHI report vector array") + .iter() + .filter(|vector| vector["kind"] == "rhi.evidence_attestation.valid") + { + let expected = &vector["expected"]; + let content = expected["canonical_event_content_utf8"] + .as_str() + .expect("canonical report content"); + let report = RadrootsRhiEvidenceReportV1::from_canonical_content(content.as_bytes()) + .expect("canonical report parses"); + assert_eq!( + report.canonical_statement_payload(), + expected["canonical_statement_payload_utf8"] + .as_str() + .expect("canonical statement payload") + ); + assert_eq!( + report.statement_digest().to_hex(), + expected["statement_digest"] + .as_str() + .expect("statement digest") + ); + assert_eq!( + report.statement_digest().to_hex(), + expected["report_id"].as_str().expect("report identity") + ); + } + for forbidden in [ + "std::fs", + "std::net", + "sqlx", + "tokio", + "reqwest", + "radroots_transport", + ".sign(", + ".publish(", + ] { + assert!( + !EVIDENCE_REPORT.contains(forbidden), + "report acquired forbidden side effect or upward dependency: {forbidden}" + ); + } + for field in [ + "pub issuer_public_key:", + "pub reason_codes:", + "pub canonical_statement_payload:", + "pub canonical_content:", + ] { + assert!( + !EVIDENCE_REPORT.contains(field), + "report field escaped: {field}" + ); + } + assert!(ROOT.contains("mod evidence_report;")); + assert!(!ROOT.contains("pub mod evidence_report;")); + assert!(!PUBLIC_API.contains("RadrootsRhiEvidenceStatementDigestV1::sha256")); +} + +#[test] fn workflow_plan_is_root_exported_and_side_effect_free() { let _: Option<radroots_trade::WorkflowPlan> = None; let _: Option<radroots_trade::Error> = None; @@ -423,6 +540,7 @@ fn package_documentation_and_reviewed_api_baseline_are_complete() { "## Deterministic reduction", "## Evidence coverage and outcome", "## Immutable evidence manifests", + "## Immutable evidence reports", "## Workflow planning", "## Features", "## Serialization and versioning", @@ -449,6 +567,11 @@ fn package_documentation_and_reviewed_api_baseline_are_complete() { "pub struct radroots_trade::evidence::RadrootsTradeEvidenceProvenanceDigestV1", "pub struct radroots_trade::evidence::RadrootsTradeEvidenceSourceResultDigestV1", "pub struct radroots_trade::evidence::RadrootsTradeSignedEventDigestV1", + "pub struct radroots_trade::evidence::RadrootsRhiEvidenceReasonCodeV1", + "pub struct radroots_trade::evidence::RadrootsRhiEvidenceReportV1", + "pub struct radroots_trade::evidence::RadrootsRhiEvidenceStatementDigestV1", + "pub struct radroots_trade::evidence::RadrootsRhiEvidenceSupersessionV1", + "pub struct radroots_trade::evidence::RadrootsTradeEvidenceProjectionDigestV1", "pub mod radroots_trade::model", "pub mod radroots_trade::reducer", "pub mod radroots_trade::validation",