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:
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",