rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit 662f79fd5a61302aa09481f95059f0e43bf24506
parent ba67c988ee14f2cfd45057b54f4d9c88175090c0
Author: triesap <tyson@radroots.org>
Date:   Mon, 24 Aug 2026 10:03:57 +0000

refactor(rhi): sign reconciliation attestations

Diffstat:
MAGENTS.md | 10++++++++++
MREADME | 26++++++++++++++++++++++++++
Mcontracts/api_baselines/rhi.txt | 42++++++++++++++++++++++++++++++++++++++++++
Acontracts/conformance/vectors/reconciliation_attestation_signed_event.v1.json | 1+
Acontracts/services_hardening/reconciliation_attestation.v1.json | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/identity_envelope.rs | 44+++++++++++++++++++++++++++++++++++++++++++-
Msrc/lib.rs | 7+++++++
Asrc/reconciliation_attestation.rs | 550+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/package_boundary.rs | 55++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Atests/services_hardening_reconciliation_attestation_contract.rs | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_reconciliation_jobs.rs | 436++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
11 files changed, 1390 insertions(+), 15 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -181,6 +181,16 @@ - Build and sign through typed governed APIs, then revalidate issuer/author, exact unsigned fields, event ID, signature, canonical report binding, and applicable Nostr semantics before persistence or publication. +- Construct a signed reconciliation attestation only by consuming the sealed + finalization fence and the independently verified encrypted service identity. + Accept authored time and exactly 32 bytes of Schnorr auxiliary randomness + only through injected authorities. Retain the exact independently verified + signed JSON bytes and their SHA-256; never rebuild, reserialize, or re-sign + them after the boundary succeeds. +- Accept an attestation supersession input only when it is derived from a prior + sealed verified RHI attestation, and rerun the shared report/event binding and + ordering validator before exposing the successor. This pure signing boundary + has no SQLite, filesystem, relay, network, task, or publication authority. - One generation-fenced transaction commits attempt/source results, immutable manifest/projection/report, supersession, exact serialized signed event bytes/digest, immutable target set and initial outbox when required, accepted diff --git a/README b/README @@ -295,6 +295,32 @@ no source, relay, network, filesystem, task, clock, or entropy operation. The exact machine contract is [`reconciliation_finalization.v1.json`](contracts/services_hardening/reconciliation_finalization.v1.json). +## Canonical signed reconciliation attestation + +`build_rhi_signed_evidence_attestation` consumes one sealed Step 195 +finalization fence and derives the shared canonical RHI evidence report only +from its immutable manifest, projection, evaluation, and claim identity. The +report issuer is the independently verified encrypted service identity. The +optional supersession reference can be derived only from an earlier sealed, +verified signed-attestation result, and the shared ordering validator rejects +stale or misbound successors. + +The event body and exact kind-3441 structural tags come only from the promoted +typed `radroots_event_codec` builder. Authored time and the 32 bytes of Schnorr +auxiliary randomness are injected explicitly. The boundary then independently +reparses the bounded signed NIP-01 JSON, recomputes its event identifier, +verifies its signature and exact plan fields, decodes the typed attestation, +reparses the canonical report, and revalidates its exact manifest binding. + +The sealed result owns the finalization fence, canonical report, verified event +identifier, exact signed bytes, and the SHA-256 of those bytes. Later +persistence must retain those bytes without rebuilding, reserializing, or +re-signing them. This checkpoint performs no SQLite, filesystem, relay, +network, task, ambient-clock, or ambient-entropy operation. Schema-v7 storage, +the generation-fenced final transaction, publication, and job finalization +remain with Steps 198 and 199. The exact machine contract is +[`reconciliation_attestation.v1.json`](contracts/services_hardening/reconciliation_attestation.v1.json). + ## Existing-state runtime foundation `open_rhi_runtime_foundation` opens only an already initialized database from diff --git a/contracts/api_baselines/rhi.txt b/contracts/api_baselines/rhi.txt @@ -145,6 +145,17 @@ pub rhi::RhiReconciliationAttemptErrorKind::PolicyMismatch pub rhi::RhiReconciliationAttemptErrorKind::ResultInventory impl rhi::RhiReconciliationAttemptErrorKind pub const fn rhi::RhiReconciliationAttemptErrorKind::code(self) -> &'static str +pub enum rhi::RhiReconciliationAttestationErrorKind +pub rhi::RhiReconciliationAttestationErrorKind::EntropyUnavailable +pub rhi::RhiReconciliationAttestationErrorKind::EventPlanInvalid +pub rhi::RhiReconciliationAttestationErrorKind::IdentityMismatch +pub rhi::RhiReconciliationAttestationErrorKind::InvalidInput +pub rhi::RhiReconciliationAttestationErrorKind::ReportInvalid +pub rhi::RhiReconciliationAttestationErrorKind::SigningFailed +pub rhi::RhiReconciliationAttestationErrorKind::SupersessionInvalid +pub rhi::RhiReconciliationAttestationErrorKind::VerificationFailed +impl rhi::RhiReconciliationAttestationErrorKind +pub const fn rhi::RhiReconciliationAttestationErrorKind::code(self) -> &'static str pub enum rhi::RhiReconciliationCommandV1 pub rhi::RhiReconciliationCommandV1::Jobs pub rhi::RhiReconciliationCommandV1::Refresh @@ -555,6 +566,11 @@ impl rhi::RhiEncryptedIdentityProvisioningMaterial pub fn rhi::RhiEncryptedIdentityProvisioningMaterial::new([u8; 32], [u8; 32], [u8; 24], [u8; 24]) -> core::result::Result<Self, rhi::RhiEncryptedIdentityEnvelopeError> impl core::fmt::Debug for rhi::RhiEncryptedIdentityProvisioningMaterial pub fn rhi::RhiEncryptedIdentityProvisioningMaterial::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiEvidenceAttestationSupersession +impl rhi::RhiEvidenceAttestationSupersession +pub fn rhi::RhiEvidenceAttestationSupersession::from_attestation(&rhi::RhiSignedEvidenceAttestation) -> Self +impl core::fmt::Debug for rhi::RhiEvidenceAttestationSupersession +pub fn rhi::RhiEvidenceAttestationSupersession::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiEvidenceManifestRepository<'host> impl rhi::RhiEvidenceManifestRepository<'_> pub const fn rhi::RhiEvidenceManifestRepository<'_>::descriptor(&self) -> rhi::RhiStateRepositoryDescriptor @@ -678,6 +694,15 @@ pub fn rhi::RhiReconciliationAttemptResults::new<I>(&rhi::RhiReconciliationAttem pub fn rhi::RhiReconciliationAttemptResults::results(&self) -> &[rhi::RhiReconciliationSourceResult] impl core::fmt::Debug for rhi::RhiReconciliationAttemptResults pub fn rhi::RhiReconciliationAttemptResults::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiReconciliationAttestationError +impl rhi::RhiReconciliationAttestationError +pub const fn rhi::RhiReconciliationAttestationError::code(self) -> &'static str +pub const fn rhi::RhiReconciliationAttestationError::kind(self) -> rhi::RhiReconciliationAttestationErrorKind +impl core::error::Error for rhi::RhiReconciliationAttestationError +impl core::fmt::Debug for rhi::RhiReconciliationAttestationError +pub fn rhi::RhiReconciliationAttestationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for rhi::RhiReconciliationAttestationError +pub fn rhi::RhiReconciliationAttestationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiReconciliationCommitError impl rhi::RhiReconciliationCommitError pub const fn rhi::RhiReconciliationCommitError::code(self) -> &'static str @@ -1002,6 +1027,20 @@ pub const fn rhi::RhiSignedEventRepository<'_>::descriptor(&self) -> rhi::RhiSta pub const fn rhi::RhiSignedEventRepository<'_>::kind(&self) -> rhi::RhiStateRepositoryKind impl core::fmt::Debug for rhi::RhiSignedEventRepository<'_> pub fn rhi::RhiSignedEventRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiSignedEvidenceAttestation +impl rhi::RhiSignedEvidenceAttestation +pub fn rhi::RhiSignedEvidenceAttestation::canonical_report_bytes(&self) -> &[u8] +pub const fn rhi::RhiSignedEvidenceAttestation::contract_version(&self) -> u32 +pub const fn rhi::RhiSignedEvidenceAttestation::coverage(&self) -> rhi::RhiReconciliationCoverage +pub const fn rhi::RhiSignedEvidenceAttestation::created_at_unix_seconds(&self) -> u64 +pub const fn rhi::RhiSignedEvidenceAttestation::event_id(&self) -> &[u8; 32] +pub const fn rhi::RhiSignedEvidenceAttestation::has_supersession(&self) -> bool +pub const fn rhi::RhiSignedEvidenceAttestation::outcome(&self) -> rhi::RhiReconciliationOutcome +pub fn rhi::RhiSignedEvidenceAttestation::signed_event_bytes(&self) -> &[u8] +pub const fn rhi::RhiSignedEvidenceAttestation::signed_event_sha256(&self) -> &[u8; 32] +pub fn rhi::RhiSignedEvidenceAttestation::statement_digest(&self) -> [u8; 32] +impl core::fmt::Debug for rhi::RhiSignedEvidenceAttestation +pub fn rhi::RhiSignedEvidenceAttestation::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiSourceCompletionRepository<'host> impl rhi::RhiSourceCompletionRepository<'_> pub const fn rhi::RhiSourceCompletionRepository<'_>::descriptor(&self) -> rhi::RhiStateRepositoryDescriptor @@ -1299,6 +1338,7 @@ pub const rhi::RHI_MIGRATION_CATALOG_SHA256: [u8; 32] pub const rhi::RHI_PROVIDER_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_ATTEMPT_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_ATTEMPT_MAX_SOURCES: usize +pub const rhi::RHI_RECONCILIATION_ATTESTATION_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_COMMIT_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_FINALIZATION_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_JOB_CONTRACT_VERSION: u32 @@ -1307,6 +1347,7 @@ pub const rhi::RHI_RECONCILIATION_MANIFEST_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_OUTCOME_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_REPLAY_CONTRACT_VERSION: u32 +pub const rhi::RHI_RECONCILIATION_SIGNED_ATTESTATION_MAX_BYTES: usize pub const rhi::RHI_RUNTIME_ADAPTER_CONTRACT_VERSION: u32 pub const rhi::RHI_RUNTIME_FOUNDATION_CONTRACT_VERSION: u32 pub const rhi::RHI_RUNTIME_JITTER_MAX_ENTROPY_DRAWS: usize @@ -1358,6 +1399,7 @@ pub fn rhi::CanonicalRhiIdentityAccess::open_existing(&self, &rhi::RhiIdentityEn pub fn rhi::admit_rhi_trade_mutation_event(rhi::RhiTradeMutationAdmissionLimits, &[u8], rhi::RhiTradeMutationObservedAtUnixSeconds, rhi::RhiTradeMutationAuthoredTimePolicy) -> core::result::Result<rhi::RhiAdmittedTradeMutationEvent, rhi::RhiTradeMutationAdmissionError> pub async fn rhi::apply_rhi_configuration(&rhi::RhiRuntimeContext, &rhi::RhiConfigDocumentV1, &rhi::RhiConfigDocumentV1, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiConfigApplyOutcome, rhi::RhiConfigApplyError> pub fn rhi::attest_projection_claim(&radroots_trade::trade_contract_v1::RadrootsTradeProjectionV1, &radroots_event::id::MutationId, &rhi::TradeAgreementAttestationPolicy) -> core::result::Result<rhi::TradeAgreementAttestationReportV1, rhi::TradeAgreementAttestationError> +pub fn rhi::build_rhi_signed_evidence_attestation(rhi::RhiReconciliationFinalizationFence, &rhi::RhiDecryptedIdentity, radroots_service_host::time::UnixTimeSeconds, &dyn radroots_service_host::entropy::EntropySource, core::option::Option<rhi::RhiEvidenceAttestationSupersession>) -> core::result::Result<rhi::RhiSignedEvidenceAttestation, rhi::RhiReconciliationAttestationError> pub fn rhi::evaluate_rhi_reconciliation_claim(rhi::RhiReconciliationProjection, radroots_event::id::MutationId) -> rhi::RhiReconciliationEvaluation pub async fn rhi::finalize_rhi_state_restore(rhi::RhiStagedStateRestore) -> core::result::Result<(), rhi::RhiStateMaintenanceError> pub async fn rhi::ingest_rhi_trade_source(&rhi::RhiStateRepositories<'_>, &rhi::RhiTransportAdapters, &rhi::RhiConfigDocumentV1, &str, radroots_event::id::TradeId, rhi::RhiTradeSourceAttempt) -> core::result::Result<rhi::RhiTradeSourceIngestOutcome, rhi::RhiTradeSourceIngestError> diff --git a/contracts/conformance/vectors/reconciliation_attestation_signed_event.v1.json b/contracts/conformance/vectors/reconciliation_attestation_signed_event.v1.json @@ -0,0 +1 @@ +{"id":"f1a2a41d73b42ba54be19c716d6b49a9e5d608ae2a9c96347155d1773b8b8a1b","pubkey":"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f","created_at":1784347207,"kind":3441,"tags":[["contract","radroots.rhi.evidence_attestation.v1"],["d","11111111111111111111111111111111"],["x","05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799","claim"],["x","57f37a23876c486d8a620d1564c3ff5cf23a50022da0585191aa7fe18a8da069","statement"],["t","radroots:rhi-outcome:indeterminate"]],"content":"{\"attestation_method\":\"signed_evidence_snapshot\",\"claim_mutation_id\":\"05623ca85936e3faeaeff1c7165926382d1534cfe7fe9650e8c029405d44a799\",\"contract_id\":\"radroots.rhi.evidence_attestation.v1\",\"contract_version\":1,\"evidence_manifest_digest\":\"de8da00f5df00c2519a14b67b091fb03a1bb6178783c933a23be1334f15ec641\",\"evidence_policy_digest\":\"43f083e29fcff4f90e66b546c49f74b0205ecb148beb352adb5a211d3e5f86b2\",\"issuer_pubkey\":\"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f\",\"observed_at_unix_s\":1784347206,\"outcome\":\"indeterminate\",\"projection_digest\":\"7ba5b7d652d8f153eaf2bd6c078ced2a2ec25a2dab0ecef331b6860ecd852861\",\"reason_codes\":[\"agreement_claim_missing\"],\"reducer_contract_id\":\"radroots.trade.reducer.v1\",\"reducer_contract_version\":1,\"report_id\":\"57f37a23876c486d8a620d1564c3ff5cf23a50022da0585191aa7fe18a8da069\",\"statement_digest\":\"57f37a23876c486d8a620d1564c3ff5cf23a50022da0585191aa7fe18a8da069\",\"supersedes_event_id\":null,\"supersedes_report_id\":null,\"trade_generation\":2,\"trade_id\":\"11111111111111111111111111111111\"}","sig":"a2ec90e11a04ccafc995ef124abedbb26b0451203b5c64a40728c00d73afed24bd92d871a472cc3d150787937a274f6f6248f9aefe5e4e759274f620ab667116"} diff --git a/contracts/services_hardening/reconciliation_attestation.v1.json b/contracts/services_hardening/reconciliation_attestation.v1.json @@ -0,0 +1,93 @@ +{ + "schema": "radroots.rhi.reconciliation-attestation", + "schema_version": 1, + "contract_version": 1, + "purpose": "canonical_signed_evidence_attestation_from_one_generation_fenced_finalization_preflight", + "input": { + "fence_authority": "sealed_step_195_finalization_fence_consumed_and_retained", + "identity_authority": "independently_verified_governed_decrypted_service_identity", + "authored_time": "caller_injected_integer_utc_seconds", + "entropy": "exactly_32_injected_bytes_for_schnorr_auxiliary_randomness", + "supersession": "optional_reference_derived_only_from_one_prior_verified_RhiSignedEvidenceAttestation" + }, + "report": { + "builder": "radroots_trade::evidence::RadrootsRhiEvidenceReportV1::new", + "contract_id": "radroots.rhi.evidence_attestation.v1", + "contract_version": 1, + "attestation_method": "signed_evidence_snapshot", + "issuer": "exact_verified_service_public_identity", + "trade_and_claim": "exact_sealed_evaluation_identity", + "projection_manifest_policy_observation_and_generation": "exact_sealed_evaluation_and_manifest_values", + "outcome_and_reasons": "exact_closed_RHI_evaluation_mapping", + "report_id": "domain_separated_statement_digest", + "maximum_canonical_bytes": 16384, + "canonical_encoding": "shared_radroots_trade_JCS_contract" + }, + "event": { + "typed_body": "AuthoredEventBody::from_rhi_evidence_attestation", + "typed_plan": "AuthoredEventPlan::bind", + "kind": 3441, + "author": "exact_report_issuer", + "created_at": "exact_injected_authored_time", + "content": "exact_canonical_report_bytes", + "tags": "exact_typed_canonical_inventory_with_no_caller_extensions", + "signature": "BIP340_Schnorr_over_exact_planned_NIP01_event_id", + "maximum_signed_json_bytes": 32768 + }, + "conformance_vector": "contracts/conformance/vectors/reconciliation_attestation_signed_event.v1.json", + "independent_verification": [ + "bounded_original_signed_JSON", + "strict_NIP01_wire_admission_with_no_extra_fields", + "event_id_recomputation", + "Schnorr_signature", + "exact_planned_event_id_author_created_at_kind_tags_and_content", + "typed_RHI_attestation_decode_and_structural_tag_binding", + "canonical_shared_report_reparse", + "exact_manifest_binding", + "complete_report_equality", + "governed_supersession_ordering_when_present" + ], + "result": { + "type": "RhiSignedEvidenceAttestation", + "sealed": true, + "clone": false, + "serialize": false, + "caller_forgeable": false, + "retains_finalization_fence": true, + "retains_canonical_report": true, + "retains_exact_signed_event_bytes": true, + "retains_signed_event_sha256": true, + "debug": "coverage_outcome_supersession_presence_and_byte_count_only", + "errors": "crate_owned_source_free_redacted" + }, + "effects": { + "sqlite": false, + "filesystem": false, + "source_or_relay": false, + "network": false, + "task_spawn": false, + "ambient_clock": false, + "ambient_entropy": false, + "publication": false, + "job_finalization": false + }, + "forbidden": [ + "caller_supplied_report_fields", + "caller_supplied_event_kind_or_tags", + "ambient_randomness_or_time", + "unsigned_or_unverified_result", + "issuer_author_mismatch", + "report_manifest_mismatch", + "unbound_or_stale_supersession", + "rebuild_reserialize_or_resign_after_success", + "persistence_or_relay_submission" + ], + "deferred": [ + "state_schema_v7_manifest_report_event_outbox_and_finalization_storage", + "atomic_generation_fenced_finalization_transaction", + "durable_target_snapshot_and_publication_attempt_state", + "relay_submission_and_recovery", + "job_finalization", + "integration_wave_qualification" + ] +} diff --git a/src/identity_envelope.rs b/src/identity_envelope.rs @@ -8,7 +8,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; -use nostr::{Keys, SecretKey}; +use nostr::{Event, Keys, SecretKey, UnsignedEvent}; use radroots_runtime_paths::ServiceCredentialArtifactName; use radroots_secrets::context::{ EnvelopeContext, EnvelopePurpose, EnvelopeSubject, PayloadSchemaId, @@ -374,6 +374,48 @@ impl RhiDecryptedIdentity { pub fn public_identity(&self) -> &RhiExpectedPublicIdentity { &self.public_identity } + + pub(crate) fn sign_nostr_event( + &self, + unsigned: UnsignedEvent, + auxiliary: &[u8; 32], + ) -> Result<Event, ()> { + let secret_key = SecretKey::from_slice(&self.secret[..]).map_err(|_| ())?; + let signing = EphemeralSigningKey::new(secret_key); + let actual = nostr::PublicKey::from( + nostr::secp256k1::XOnlyPublicKey::from_keypair(&signing.keypair).0, + ); + if actual.to_hex() != self.public_identity.as_hex() { + return Err(()); + } + let event_id = unsigned.id.as_ref().ok_or(())?; + let message = nostr::secp256k1::Message::from_digest(event_id.to_bytes()); + let signature = + nostr::SECP256K1.sign_schnorr_with_aux_rand(&message, &signing.keypair, auxiliary); + unsigned.add_signature(signature).map_err(|_| ()) + } +} + +struct EphemeralSigningKey { + secret_key: SecretKey, + keypair: nostr::secp256k1::Keypair, +} + +impl EphemeralSigningKey { + fn new(secret_key: SecretKey) -> Self { + let keypair = nostr::secp256k1::Keypair::from_secret_key(nostr::SECP256K1, &secret_key); + Self { + secret_key, + keypair, + } + } +} + +impl Drop for EphemeralSigningKey { + fn drop(&mut self) { + self.secret_key.non_secure_erase(); + self.keypair.non_secure_erase(); + } } impl fmt::Debug for RhiDecryptedIdentity { diff --git a/src/lib.rs b/src/lib.rs @@ -9,6 +9,7 @@ mod features; mod identity_credential; mod identity_envelope; mod reconciliation_attempt; +mod reconciliation_attestation; mod reconciliation_commit; mod reconciliation_finalization; mod reconciliation_job; @@ -80,6 +81,12 @@ pub use reconciliation_attempt::{ RhiReconciliationSourceRequestId, RhiReconciliationSourceResult, RhiReconciliationSourceSelectorDigest, }; +pub use reconciliation_attestation::{ + RHI_RECONCILIATION_ATTESTATION_CONTRACT_VERSION, + RHI_RECONCILIATION_SIGNED_ATTESTATION_MAX_BYTES, RhiEvidenceAttestationSupersession, + RhiReconciliationAttestationError, RhiReconciliationAttestationErrorKind, + RhiSignedEvidenceAttestation, build_rhi_signed_evidence_attestation, +}; pub use reconciliation_commit::{ RHI_RECONCILIATION_COMMIT_CONTRACT_VERSION, RhiReconciliationCommitError, RhiReconciliationCommitErrorKind, RhiReconciliationSourceCommitOutcome, diff --git a/src/reconciliation_attestation.rs b/src/reconciliation_attestation.rs @@ -0,0 +1,550 @@ +//! Canonical signed attestation construction from one finalization fence. + +use core::fmt; +use std::error::Error; + +use nostr::{EventBuilder, Kind, PublicKey as NostrPublicKey, Tag, Timestamp}; +use radroots_event::{ + envelope::EventEnvelope, + id::EventId, + wire::{EventWireLimits, Nip01EventWire}, +}; +use radroots_event_codec::{ + authoring::{AuthoredEventBody, AuthoredEventPlan}, + decode::rhi::{ + RadrootsRhiEvidenceAttestationV1, rhi_evidence_attestation_from_event, + validate_rhi_evidence_attestation_supersession, + }, +}; +use radroots_nostr::event::{Verification, verify, verify_id}; +use radroots_service_host::{EntropySource, UnixTimeSeconds}; +use radroots_trade::evidence::{ + RadrootsRhiEvidenceReasonCodeV1, RadrootsRhiEvidenceReportV1, + RadrootsRhiEvidenceSupersessionV1, RadrootsTradeEvidenceProjectionDigestV1, +}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::{ + RhiDecryptedIdentity, RhiReconciliationCoverage, RhiReconciliationFinalizationFence, + RhiReconciliationOutcome, RhiReconciliationReasonCode, +}; + +/// Exact version of the signed reconciliation-attestation boundary. +pub const RHI_RECONCILIATION_ATTESTATION_CONTRACT_VERSION: u32 = 1; + +/// Exact cap for one canonical signed attestation event. +pub const RHI_RECONCILIATION_SIGNED_ATTESTATION_MAX_BYTES: usize = 32 * 1024; + +const MAXIMUM_TAGS: usize = 7; +const MAXIMUM_TAG_ELEMENTS: usize = 21; +const MAXIMUM_TAG_ELEMENT_BYTES: usize = 128; +const MAXIMUM_TAG_BYTES: usize = 1_024; + +/// Stable source-free signed-attestation failure class. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiReconciliationAttestationErrorKind { + InvalidInput, + IdentityMismatch, + EntropyUnavailable, + ReportInvalid, + EventPlanInvalid, + SigningFailed, + VerificationFailed, + SupersessionInvalid, +} + +impl RhiReconciliationAttestationErrorKind { + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidInput => "reconciliation_attestation_input_invalid", + Self::IdentityMismatch => "reconciliation_attestation_identity_mismatch", + Self::EntropyUnavailable => "reconciliation_attestation_entropy_unavailable", + Self::ReportInvalid => "reconciliation_attestation_report_invalid", + Self::EventPlanInvalid => "reconciliation_attestation_event_plan_invalid", + Self::SigningFailed => "reconciliation_attestation_signing_failed", + Self::VerificationFailed => "reconciliation_attestation_verification_failed", + Self::SupersessionInvalid => "reconciliation_attestation_supersession_invalid", + } + } +} + +/// Redacted source-free signed-attestation failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiReconciliationAttestationError { + kind: RhiReconciliationAttestationErrorKind, +} + +impl RhiReconciliationAttestationError { + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> RhiReconciliationAttestationErrorKind { + self.kind + } + + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Display for RhiReconciliationAttestationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + RhiReconciliationAttestationErrorKind::InvalidInput => { + "RHI reconciliation attestation input is invalid" + } + RhiReconciliationAttestationErrorKind::IdentityMismatch => { + "RHI reconciliation attestation identity does not match" + } + RhiReconciliationAttestationErrorKind::EntropyUnavailable => { + "RHI reconciliation attestation entropy is unavailable" + } + RhiReconciliationAttestationErrorKind::ReportInvalid => { + "RHI reconciliation attestation report is invalid" + } + RhiReconciliationAttestationErrorKind::EventPlanInvalid => { + "RHI reconciliation attestation event plan is invalid" + } + RhiReconciliationAttestationErrorKind::SigningFailed => { + "RHI reconciliation attestation signing failed" + } + RhiReconciliationAttestationErrorKind::VerificationFailed => { + "RHI reconciliation attestation verification failed" + } + RhiReconciliationAttestationErrorKind::SupersessionInvalid => { + "RHI reconciliation attestation supersession is invalid" + } + }) + } +} + +impl fmt::Debug for RhiReconciliationAttestationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiReconciliationAttestationError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for RhiReconciliationAttestationError {} + +/// Sealed exact reference to one prior independently verified attestation. +/// +/// Callers cannot supply independent report and event identifiers that were +/// never proven together. +/// +/// ```compile_fail +/// use rhi::RhiEvidenceAttestationSupersession; +/// +/// let _forged = RhiEvidenceAttestationSupersession {}; +/// ``` +pub struct RhiEvidenceAttestationSupersession { + report: RadrootsRhiEvidenceReportV1, + event_id: EventId, +} + +impl RhiEvidenceAttestationSupersession { + /// Derives the only public supersession input from one verified result. + #[must_use] + pub fn from_attestation(attestation: &RhiSignedEvidenceAttestation) -> Self { + Self { + report: attestation.report.clone(), + event_id: EventId::from_bytes(attestation.event_id), + } + } +} + +impl fmt::Debug for RhiEvidenceAttestationSupersession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RhiEvidenceAttestationSupersession([redacted])") + } +} + +/// Sealed exact report and independently verified signed event. +/// +/// This value owns its Step195 fence. Step199 consumes it, reruns the fence +/// inside its final write transaction, and persists these exact bytes without +/// rebuilding or re-signing them. +/// +/// ```compile_fail +/// use rhi::RhiSignedEvidenceAttestation; +/// +/// let _forged = RhiSignedEvidenceAttestation { event_id: [0; 32] }; +/// ``` +/// +/// ```compile_fail +/// use rhi::RhiSignedEvidenceAttestation; +/// +/// fn require_clone<T: Clone>() {} +/// require_clone::<RhiSignedEvidenceAttestation>(); +/// ``` +pub struct RhiSignedEvidenceAttestation { + fence: RhiReconciliationFinalizationFence, + report: RadrootsRhiEvidenceReportV1, + event_id: [u8; 32], + signed_event_bytes: Box<[u8]>, + signed_event_sha256: [u8; 32], + created_at_unix_seconds: u64, +} + +impl RhiSignedEvidenceAttestation { + /// Returns the exact RHI signed-attestation contract version. + #[must_use] + pub const fn contract_version(&self) -> u32 { + RHI_RECONCILIATION_ATTESTATION_CONTRACT_VERSION + } + + /// Returns the exact canonical report bytes. + #[must_use] + pub fn canonical_report_bytes(&self) -> &[u8] { + self.report.canonical_content().as_bytes() + } + + /// Returns the domain-separated statement/report identifier. + #[must_use] + pub fn statement_digest(&self) -> [u8; 32] { + *self.report.statement_digest().as_bytes() + } + + /// Returns the independently verified NIP-01 event identifier. + #[must_use] + pub const fn event_id(&self) -> &[u8; 32] { + &self.event_id + } + + /// Returns the exact signed event bytes that later persistence must retain. + #[must_use] + pub fn signed_event_bytes(&self) -> &[u8] { + &self.signed_event_bytes + } + + /// Returns the SHA-256 digest of the exact signed event bytes. + #[must_use] + pub const fn signed_event_sha256(&self) -> &[u8; 32] { + &self.signed_event_sha256 + } + + /// Returns the injected NIP-01 authored time, distinct from observation time. + #[must_use] + pub const fn created_at_unix_seconds(&self) -> u64 { + self.created_at_unix_seconds + } + + /// Returns the exact evidence coverage retained by the finalization chain. + #[must_use] + pub const fn coverage(&self) -> RhiReconciliationCoverage { + self.fence.evaluation().coverage() + } + + /// Returns the exact claim outcome retained by the finalization chain. + #[must_use] + pub const fn outcome(&self) -> RhiReconciliationOutcome { + self.fence.evaluation().outcome() + } + + /// Returns whether this report explicitly supersedes one verified predecessor. + #[must_use] + pub const fn has_supersession(&self) -> bool { + self.report.supersession().is_some() + } +} + +impl fmt::Debug for RhiSignedEvidenceAttestation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiSignedEvidenceAttestation") + .field("coverage", &self.coverage()) + .field("outcome", &self.outcome()) + .field("has_supersession", &self.has_supersession()) + .field("signed_event_bytes", &self.signed_event_bytes.len()) + .finish_non_exhaustive() + } +} + +/// Builds, signs, and independently verifies one exact evidence attestation. +/// +/// Signing consumes exactly 32 bytes from the injected entropy source. No +/// clock, entropy, source, relay, persistence, task, or network authority is +/// acquired implicitly. +pub fn build_rhi_signed_evidence_attestation( + fence: RhiReconciliationFinalizationFence, + identity: &RhiDecryptedIdentity, + created_at: UnixTimeSeconds, + entropy: &dyn EntropySource, + supersession: Option<RhiEvidenceAttestationSupersession>, +) -> Result<RhiSignedEvidenceAttestation, RhiReconciliationAttestationError> { + let evaluation = fence.evaluation(); + let projection = evaluation.projection(); + let manifest = projection.manifest(); + let projection_digest = projection + .digest() + .ok_or_else(|| failure(RhiReconciliationAttestationErrorKind::InvalidInput))?; + let issuer = identity + .public_identity() + .as_hex() + .parse() + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::IdentityMismatch))?; + let reason_codes = evaluation + .reason_codes() + .iter() + .copied() + .map(report_reason) + .collect::<Result<Vec<_>, _>>()?; + let shared_supersession = supersession.as_ref().map(|prior| { + RadrootsRhiEvidenceSupersessionV1::new(prior.report.statement_digest(), prior.event_id) + }); + let report = RadrootsRhiEvidenceReportV1::new( + issuer, + *evaluation.claim_mutation_id(), + evaluation.outcome(), + reason_codes, + RadrootsTradeEvidenceProjectionDigestV1::from_bytes(projection_digest), + manifest.inner(), + shared_supersession, + ) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::ReportInvalid))?; + report + .validate_against_manifest(manifest.inner()) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::ReportInvalid))?; + let attestation = + RadrootsRhiEvidenceAttestationV1::from_canonical_content(report.canonical_content()) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::ReportInvalid))?; + if let Some(prior) = supersession.as_ref() { + let current = RadrootsRhiEvidenceAttestationV1::from_canonical_content( + prior.report.canonical_content(), + ) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SupersessionInvalid))?; + validate_rhi_evidence_attestation_supersession(&current, &prior.event_id, &attestation) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SupersessionInvalid))?; + } + let body = AuthoredEventBody::from_rhi_evidence_attestation(&attestation) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::EventPlanInvalid))?; + let plan = AuthoredEventPlan::bind(body, created_at.get(), identity.public_identity().as_hex()) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::EventPlanInvalid))?; + + let mut auxiliary = Zeroizing::new([0_u8; 32]); + entropy + .fill_bytes(&mut auxiliary[..]) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::EntropyUnavailable))?; + let signed_event = sign_plan(identity, &plan, &auxiliary)?; + let signed_event_bytes = serde_json::to_vec(&signed_event) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SigningFailed))?; + if signed_event_bytes.len() > RHI_RECONCILIATION_SIGNED_ATTESTATION_MAX_BYTES { + return Err(failure( + RhiReconciliationAttestationErrorKind::SigningFailed, + )); + } + let verified = verify_signed_event(&plan, &report, manifest.inner(), &signed_event_bytes)?; + let signed_event_sha256 = Sha256::digest(&signed_event_bytes).into(); + Ok(RhiSignedEvidenceAttestation { + fence, + report, + event_id: *verified.id().as_bytes(), + signed_event_bytes: signed_event_bytes.into_boxed_slice(), + signed_event_sha256, + created_at_unix_seconds: created_at.get(), + }) +} + +fn report_reason( + reason: RhiReconciliationReasonCode, +) -> Result<RadrootsRhiEvidenceReasonCodeV1, RhiReconciliationAttestationError> { + RadrootsRhiEvidenceReasonCodeV1::parse(reason.code()) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::ReportInvalid)) +} + +fn sign_plan( + identity: &RhiDecryptedIdentity, + plan: &AuthoredEventPlan, + auxiliary: &[u8; 32], +) -> Result<nostr::Event, RhiReconciliationAttestationError> { + let kind = u16::try_from(plan.body().kind()) + .map(Kind::Custom) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::EventPlanInvalid))?; + let tags = plan + .body() + .tags() + .iter() + .cloned() + .map(Tag::parse) + .collect::<Result<Vec<_>, _>>() + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::EventPlanInvalid))?; + let author = NostrPublicKey::from_hex(identity.public_identity().as_hex()) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::IdentityMismatch))?; + let unsigned = EventBuilder::new(kind, plan.body().content()) + .tags(tags) + .custom_created_at(Timestamp::from_secs(plan.created_at())) + .build(author); + if unsigned.id.as_ref().map(|event_id| event_id.to_bytes()) + != Some(*plan.expected_event_id().as_bytes()) + { + return Err(failure( + RhiReconciliationAttestationErrorKind::EventPlanInvalid, + )); + } + identity + .sign_nostr_event(unsigned, auxiliary) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SigningFailed)) +} + +fn verify_signed_event( + plan: &AuthoredEventPlan, + report: &RadrootsRhiEvidenceReportV1, + manifest: &radroots_trade::evidence::RadrootsTradeEvidenceManifestV1, + signed_event_bytes: &[u8], +) -> Result<EventEnvelope, RhiReconciliationAttestationError> { + let event = verify_signed_event_plan(plan, signed_event_bytes)?; + let typed = rhi_evidence_attestation_from_event(&event) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::VerificationFailed))?; + if typed.canonical_content() != report.canonical_content() { + return Err(failure( + RhiReconciliationAttestationErrorKind::VerificationFailed, + )); + } + let reparsed = RadrootsRhiEvidenceReportV1::from_canonical_content(event.content().as_bytes()) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::VerificationFailed))?; + reparsed + .validate_against_manifest(manifest) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::VerificationFailed))?; + if &reparsed != report { + return Err(failure( + RhiReconciliationAttestationErrorKind::VerificationFailed, + )); + } + Ok(event) +} + +fn verify_signed_event_plan( + plan: &AuthoredEventPlan, + signed_event_bytes: &[u8], +) -> Result<EventEnvelope, RhiReconciliationAttestationError> { + if signed_event_bytes.is_empty() + || signed_event_bytes.len() > RHI_RECONCILIATION_SIGNED_ATTESTATION_MAX_BYTES + { + return Err(failure( + RhiReconciliationAttestationErrorKind::VerificationFailed, + )); + } + let source = core::str::from_utf8(signed_event_bytes) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::VerificationFailed))?; + let wire = Nip01EventWire::parse_json_unverified_with_limits(source, signed_event_limits()) + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::VerificationFailed))?; + let event = wire + .into_unverified_envelope() + .map_err(|_| failure(RhiReconciliationAttestationErrorKind::VerificationFailed))?; + if verify_id(&event) != Verification::IdVerified || verify(&event) != Verification::Verified { + return Err(failure( + RhiReconciliationAttestationErrorKind::VerificationFailed, + )); + } + if event.id().as_bytes() != plan.expected_event_id().as_bytes() + || event.author().to_hex() != plan.author().to_hex() + || event.created_at_u64() != plan.created_at() + || event.kind_u32() != plan.body().kind() + || event.tags_as_vec() != plan.body().tags() + || event.content() != plan.body().content() + { + return Err(failure( + RhiReconciliationAttestationErrorKind::VerificationFailed, + )); + } + Ok(event) +} + +const fn signed_event_limits() -> EventWireLimits { + EventWireLimits { + max_raw_json_bytes: RHI_RECONCILIATION_SIGNED_ATTESTATION_MAX_BYTES, + max_content_bytes: + radroots_trade::evidence::RADROOTS_RHI_EVIDENCE_REPORT_MAXIMUM_CANONICAL_BYTES, + max_tag_count: MAXIMUM_TAGS, + max_total_tag_elements: MAXIMUM_TAG_ELEMENTS, + max_tag_element_bytes: MAXIMUM_TAG_ELEMENT_BYTES, + max_total_tag_bytes: MAXIMUM_TAG_BYTES, + max_extra_fields: 0, + max_total_extra_json_bytes: 0, + } +} + +const fn failure(kind: RhiReconciliationAttestationErrorKind) -> RhiReconciliationAttestationError { + RhiReconciliationAttestationError { kind } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SIGNED_VECTOR: &str = include_str!( + "../contracts/conformance/vectors/reconciliation_attestation_signed_event.v1.json" + ); + + fn vector_plan() -> AuthoredEventPlan { + let value: serde_json::Value = serde_json::from_str(SIGNED_VECTOR).expect("signed vector"); + let content = value["content"].as_str().expect("report content"); + let attestation = + RadrootsRhiEvidenceAttestationV1::from_canonical_content(content.as_bytes()) + .expect("typed report"); + AuthoredEventPlan::bind( + AuthoredEventBody::from_rhi_evidence_attestation(&attestation).expect("typed body"), + value["created_at"].as_u64().expect("authored time"), + value["pubkey"].as_str().expect("author"), + ) + .expect("typed plan") + } + + #[test] + fn frozen_vector_and_malicious_signer_output_are_independently_verified() { + let plan = vector_plan(); + verify_signed_event_plan(&plan, SIGNED_VECTOR.trim_end().as_bytes()) + .expect("frozen signed vector"); + + let mut wrong_signature: serde_json::Value = + serde_json::from_str(SIGNED_VECTOR).expect("signed vector"); + wrong_signature["sig"] = serde_json::Value::String("0".repeat(128)); + let bytes = serde_json::to_vec(&wrong_signature).expect("malicious signer wire"); + assert_eq!( + verify_signed_event_plan(&plan, &bytes) + .expect_err("malicious signature") + .kind(), + RhiReconciliationAttestationErrorKind::VerificationFailed + ); + + let mut wrong_author: serde_json::Value = + serde_json::from_str(SIGNED_VECTOR).expect("signed vector"); + wrong_author["pubkey"] = serde_json::Value::String("2".repeat(64)); + let bytes = serde_json::to_vec(&wrong_author).expect("wrong-author wire"); + assert_eq!( + verify_signed_event_plan(&plan, &bytes) + .expect_err("wrong author") + .kind(), + RhiReconciliationAttestationErrorKind::VerificationFailed + ); + } + + #[test] + fn every_public_error_class_is_source_free_and_redacted() { + for kind in [ + RhiReconciliationAttestationErrorKind::InvalidInput, + RhiReconciliationAttestationErrorKind::IdentityMismatch, + RhiReconciliationAttestationErrorKind::EntropyUnavailable, + RhiReconciliationAttestationErrorKind::ReportInvalid, + RhiReconciliationAttestationErrorKind::EventPlanInvalid, + RhiReconciliationAttestationErrorKind::SigningFailed, + RhiReconciliationAttestationErrorKind::VerificationFailed, + RhiReconciliationAttestationErrorKind::SupersessionInvalid, + ] { + let error = failure(kind); + assert_eq!(error.kind(), kind); + assert!(error.code().starts_with("reconciliation_attestation_")); + assert!(Error::source(&error).is_none()); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains("11111111")); + assert!(!rendered.contains("f1a2a41d")); + assert!(!rendered.contains("1b84c556")); + } + } +} diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -13,6 +13,7 @@ const RUNTIME_ADAPTER_CONTRACT: &str = const RUNTIME_FOUNDATION: &str = include_str!("../src/runtime_foundation.rs"); const RECONCILIATION_ATTEMPTS: &str = include_str!("../src/reconciliation_attempt.rs"); const RECONCILIATION_COMMIT: &str = include_str!("../src/reconciliation_commit.rs"); +const RECONCILIATION_ATTESTATION: &str = include_str!("../src/reconciliation_attestation.rs"); const RECONCILIATION_FINALIZATION: &str = include_str!("../src/reconciliation_finalization.rs"); const RECONCILIATION_MANIFEST: &str = include_str!("../src/reconciliation_manifest.rs"); const RECONCILIATION_REDUCER: &str = include_str!("../src/reconciliation_reducer.rs"); @@ -24,6 +25,8 @@ const RECONCILIATION_REPLAY_CONTRACT: &str = include_str!("../contracts/services_hardening/reconciliation_replay.v1.json"); const RECONCILIATION_COMMIT_CONTRACT: &str = include_str!("../contracts/services_hardening/reconciliation_commit.v1.json"); +const RECONCILIATION_ATTESTATION_CONTRACT: &str = + include_str!("../contracts/services_hardening/reconciliation_attestation.v1.json"); const RECONCILIATION_FINALIZATION_CONTRACT: &str = include_str!("../contracts/services_hardening/reconciliation_finalization.v1.json"); const RECONCILIATION_MANIFEST_CONTRACT: &str = @@ -49,6 +52,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/identity_credential.rs"), include_str!("../src/identity_envelope.rs"), include_str!("../src/reconciliation_attempt.rs"), + include_str!("../src/reconciliation_attestation.rs"), include_str!("../src/reconciliation_commit.rs"), include_str!("../src/reconciliation_finalization.rs"), include_str!("../src/reconciliation_job.rs"), @@ -122,6 +126,7 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "identity_credential", "identity_envelope", "reconciliation_attempt", + "reconciliation_attestation", "reconciliation_commit", "reconciliation_finalization", "reconciliation_job", @@ -169,6 +174,12 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "RhiReconciliationAttemptResults", "RhiReconciliationSourceCommitOutcome", "RhiReconciliationCommitErrorKind", + "RhiSignedEvidenceAttestation", + "RhiEvidenceAttestationSupersession", + "RhiReconciliationAttestationErrorKind", + "RHI_RECONCILIATION_ATTESTATION_CONTRACT_VERSION", + "RHI_RECONCILIATION_SIGNED_ATTESTATION_MAX_BYTES", + "build_rhi_signed_evidence_attestation", "RhiReconciliationFinalizationFence", "RhiReconciliationFinalizationErrorKind", "RHI_RECONCILIATION_FINALIZATION_CONTRACT_VERSION", @@ -242,6 +253,44 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { } #[test] +fn reconciliation_attestation_is_typed_signed_verified_and_effect_free() { + let contract: serde_json::Value = serde_json::from_str(RECONCILIATION_ATTESTATION_CONTRACT) + .expect("reconciliation-attestation contract"); + assert_eq!( + contract["schema"], + "radroots.rhi.reconciliation-attestation" + ); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["event"]["kind"], 3_441); + assert_eq!(contract["effects"]["sqlite"], false); + assert_eq!(contract["effects"]["publication"], false); + for required in [ + "RadrootsRhiEvidenceReportV1::new(", + "AuthoredEventBody::from_rhi_evidence_attestation(", + "AuthoredEventPlan::bind(", + "sign_nostr_event(unsigned, auxiliary)", + "Nip01EventWire::parse_json_unverified_with_limits(", + "verify_id(&event)", + "verify(&event)", + "rhi_evidence_attestation_from_event(&event)", + "validate_against_manifest(manifest)", + ] { + assert!( + RECONCILIATION_ATTESTATION.contains(required), + "reconciliation attestation is missing {required}" + ); + } + for forbidden in ["sqlx::", "std::fs", "std::net", "tokio::", "SystemTime"] { + assert!( + !RECONCILIATION_ATTESTATION.contains(forbidden), + "reconciliation attestation gained forbidden authority {forbidden}" + ); + } + assert!(!ROOT.contains("pub mod reconciliation_attestation")); + assert!(!PUBLIC_API.contains("rhi::reconciliation_attestation::")); +} + +#[test] fn reconciliation_commit_is_atomic_bounded_and_sealed() { let contract: serde_json::Value = serde_json::from_str(RECONCILIATION_COMMIT_CONTRACT) .expect("reconciliation-commit contract"); @@ -454,7 +503,7 @@ fn public_errors_are_crate_owned_redacted_and_source_free() { .lines() .filter(|line| line.starts_with("pub struct rhi::") && line.ends_with("Error")) .count(); - assert_eq!(public_error_count, 23); + assert_eq!(public_error_count, 24); } #[test] @@ -881,6 +930,10 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() { "## Generation-fenced finalization preflight", "[`reconciliation_finalization.v1.json`](contracts/services_hardening/reconciliation_finalization.v1.json)", "Step 199 must rerun the same validator inside the final", + "## Canonical signed reconciliation attestation", + "[`reconciliation_attestation.v1.json`](contracts/services_hardening/reconciliation_attestation.v1.json)", + "The event body and exact kind-3441 structural tags", + "without rebuilding, reserializing, or", "Coverage is exactly `Missing`, `Partial`, `ScopeSatisfied`, or `Unsupported`", "Missing, partial, unsupported,", "The Step 192 integration-wave qualification proves that concurrent exact", diff --git a/tests/services_hardening_reconciliation_attestation_contract.rs b/tests/services_hardening_reconciliation_attestation_contract.rs @@ -0,0 +1,141 @@ +#![forbid(unsafe_code)] + +use rhi::{ + RHI_RECONCILIATION_ATTESTATION_CONTRACT_VERSION, + RHI_RECONCILIATION_SIGNED_ATTESTATION_MAX_BYTES, +}; +use serde_json::json; + +const CONTRACT: &str = + include_str!("../contracts/services_hardening/reconciliation_attestation.v1.json"); +const ROOT: &str = include_str!("../src/lib.rs"); +const SOURCE: &str = include_str!("../src/reconciliation_attestation.rs"); +const IDENTITY: &str = include_str!("../src/identity_envelope.rs"); +const README: &str = include_str!("../README"); +const SIGNED_VECTOR: &str = include_str!( + "../contracts/conformance/vectors/reconciliation_attestation_signed_event.v1.json" +); + +#[test] +fn machine_contract_freezes_the_complete_step_196_boundary() { + let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract"); + assert_eq!( + contract["schema"], + "radroots.rhi.reconciliation-attestation" + ); + assert_eq!(contract["schema_version"], 1); + assert_eq!( + contract["contract_version"], + RHI_RECONCILIATION_ATTESTATION_CONTRACT_VERSION + ); + assert_eq!(contract["report"]["maximum_canonical_bytes"], 16_384); + assert_eq!(contract["event"]["kind"], 3_441); + assert_eq!( + contract["event"]["maximum_signed_json_bytes"], + RHI_RECONCILIATION_SIGNED_ATTESTATION_MAX_BYTES + ); + assert_eq!( + contract["conformance_vector"], + "contracts/conformance/vectors/reconciliation_attestation_signed_event.v1.json" + ); + let vector: serde_json::Value = serde_json::from_str(SIGNED_VECTOR).expect("signed vector"); + assert_eq!( + vector["id"], + "f1a2a41d73b42ba54be19c716d6b49a9e5d608ae2a9c96347155d1773b8b8a1b" + ); + assert_eq!(vector["kind"], 3_441); + assert_eq!(vector["tags"].as_array().expect("tags").len(), 5); + assert_eq!( + contract["independent_verification"], + json!([ + "bounded_original_signed_JSON", + "strict_NIP01_wire_admission_with_no_extra_fields", + "event_id_recomputation", + "Schnorr_signature", + "exact_planned_event_id_author_created_at_kind_tags_and_content", + "typed_RHI_attestation_decode_and_structural_tag_binding", + "canonical_shared_report_reparse", + "exact_manifest_binding", + "complete_report_equality", + "governed_supersession_ordering_when_present" + ]) + ); + for effect in [ + "sqlite", + "filesystem", + "source_or_relay", + "network", + "task_spawn", + "ambient_clock", + "ambient_entropy", + "publication", + "job_finalization", + ] { + assert_eq!(contract["effects"][effect], false, "effect {effect}"); + } +} + +#[test] +fn implementation_uses_only_typed_authoring_and_independent_verification() { + assert!(ROOT.contains("mod reconciliation_attestation;")); + assert!(!ROOT.contains("pub mod reconciliation_attestation;")); + for required in [ + "RhiSignedEvidenceAttestation", + "RhiEvidenceAttestationSupersession", + "RhiReconciliationAttestationErrorKind", + "build_rhi_signed_evidence_attestation", + "RHI_RECONCILIATION_SIGNED_ATTESTATION_MAX_BYTES", + ] { + assert!(ROOT.contains(required), "root API is missing {required}"); + } + for required in [ + "RadrootsRhiEvidenceReportV1::new(", + "RadrootsRhiEvidenceAttestationV1::from_canonical_content(", + "validate_rhi_evidence_attestation_supersession(", + "AuthoredEventBody::from_rhi_evidence_attestation(", + "AuthoredEventPlan::bind(", + ".fill_bytes(&mut auxiliary[..])", + "sign_nostr_event(unsigned, auxiliary)", + "Nip01EventWire::parse_json_unverified_with_limits(", + "verify_id(&event)", + "verify(&event)", + "rhi_evidence_attestation_from_event(&event)", + "validate_against_manifest(manifest)", + ] { + assert!( + SOURCE.contains(required), + "implementation is missing {required}" + ); + } + for required in [ + "sign_schnorr_with_aux_rand", + "non_secure_erase()", + "actual.to_hex() != self.public_identity.as_hex()", + ] { + assert!(IDENTITY.contains(required), "signer is missing {required}"); + } + for forbidden in [ + "std::fs", + "std::net", + "sqlx::", + "tokio::", + "SystemTime", + "SystemEntropy", + "OsRng", + "thread_rng", + "INSERT ", + "UPDATE ", + "DELETE ", + "pub fn new(", + "pub const fn new(", + ] { + assert!( + !SOURCE.contains(forbidden), + "attestation boundary gained forbidden authority {forbidden}" + ); + } + assert!(README.contains("## Canonical signed reconciliation attestation")); + assert!(README.contains( + "[`reconciliation_attestation.v1.json`](contracts/services_hardening/reconciliation_attestation.v1.json)" + )); +} diff --git a/tests/services_hardening_reconciliation_jobs.rs b/tests/services_hardening_reconciliation_jobs.rs @@ -3,28 +3,36 @@ use std::{error::Error, fs, os::unix::fs::PermissionsExt, path::Path, time::Duration}; +use nostr::{Keys, SecretKey}; +use radroots_service_host::{EntropyError, EntropySource}; use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; use radroots_storage::event::SourceGeneration; use rhi::{ - RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, - RhiReconciliationAttemptErrorKind, RhiReconciliationAttemptPlan, - RhiReconciliationAttemptResults, RhiReconciliationCommitErrorKind, - RhiReconciliationFinalizationErrorKind, RhiReconciliationJobErrorKind, - RhiReconciliationJobPolicy, RhiReconciliationJobState, RhiReconciliationLease, - RhiReconciliationLeaseOwner, RhiReconciliationRetryDelayMilliseconds, + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiDecryptedIdentity, + RhiEncryptedIdentityProvisioningMaterial, RhiEvidenceAttestationSupersession, + RhiIdentityEnvelopeBinding, RhiReconciliationAttemptErrorKind, RhiReconciliationAttemptPlan, + RhiReconciliationAttemptResults, RhiReconciliationAttestationErrorKind, + RhiReconciliationCommitErrorKind, RhiReconciliationFinalizationErrorKind, + RhiReconciliationJobErrorKind, RhiReconciliationJobPolicy, RhiReconciliationJobState, + RhiReconciliationLease, RhiReconciliationLeaseOwner, RhiReconciliationRetryDelayMilliseconds, RhiReconciliationScopePrerequisites, RhiReconciliationSourceReplayPlan, RhiReconciliationSourceResult, RhiReconciliationUnixMilliseconds, RhiRuntimeContext, RhiStateMetadata, RhiTradeMutationAdmissionLimits, RhiTradeMutationAuthoredTimePolicy, RhiTradeMutationObservedAtUnixSeconds, RhiTradeSourceCompletion, TradeId, UnixTimeSeconds, - admit_rhi_trade_mutation_event, initialize_rhi_state, open_rhi_state_inspection, - open_rhi_state_read_write, parse_rhi_cli_v1_from, parse_rhi_config_v1, - reduce_rhi_reconciliation_manifest, resolve_rhi_runtime_context, + admit_rhi_trade_mutation_event, build_rhi_signed_evidence_attestation, initialize_rhi_state, + open_rhi_state_inspection, open_rhi_state_read_write, parse_rhi_cli_v1_from, + parse_rhi_config_v1, provision_rhi_encrypted_identity, reduce_rhi_reconciliation_manifest, + resolve_rhi_runtime_context, resolve_rhi_wrapping_credential, }; +use sha2::{Digest, Sha256}; use sqlx::{Connection, SqliteConnection, sqlite::SqliteConnectOptions}; const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); const TRADE_VECTOR: &str = include_str!("../contracts/conformance/vectors/trade_ingest_proposal.v1.json"); +const SIGNED_ATTESTATION_VECTOR: &str = include_str!( + "../contracts/conformance/vectors/reconciliation_attestation_signed_event.v1.json" +); fn runtime(root: &Path, instance: &str) -> RhiRuntimeContext { let invocation = parse_rhi_cli_v1_from([ @@ -1583,6 +1591,330 @@ async fn finalization_rejects_cross_attempt_relabelling_and_read_only_hosts() { drop((configuration, metadata, runtime, root)); } +struct FixedAttestationEntropy(u8); + +impl EntropySource for FixedAttestationEntropy { + fn fill_bytes(&self, destination: &mut [u8]) -> Result<(), EntropyError> { + destination.fill(self.0); + Ok(()) + } +} + +struct FailingAttestationEntropy; + +impl EntropySource for FailingAttestationEntropy { + fn fill_bytes(&self, _destination: &mut [u8]) -> Result<(), EntropyError> { + Err(EntropyError::Unavailable) + } +} + +fn attestation_secret() -> [u8; 32] { + [1; 32] +} + +fn attestation_configuration(runtime: &RhiRuntimeContext, expected_public_key: &str) -> String { + EXAMPLE + .replace( + "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt", + runtime + .identity_path() + .to_str() + .expect("UTF-8 identity path"), + ) + .replace(&"2".repeat(64), expected_public_key) +} + +fn provision_attestation_identity( + runtime: &RhiRuntimeContext, + configuration: &rhi::RhiConfigDocumentV1, + metadata: &RhiStateMetadata, +) -> RhiDecryptedIdentity { + fs::create_dir_all(runtime.context().paths().secrets()).expect("secrets directory"); + fs::set_permissions( + runtime.context().paths().secrets(), + fs::Permissions::from_mode(0o700), + ) + .expect("secrets directory mode"); + let credential_path = runtime + .context() + .paths() + .secrets() + .join("service_wrapping_key"); + fs::write(&credential_path, [0x81; 32]).expect("wrapping credential"); + fs::set_permissions(&credential_path, fs::Permissions::from_mode(0o600)) + .expect("wrapping credential mode"); + let binding = RhiIdentityEnvelopeBinding::from_configuration(configuration, metadata) + .expect("identity binding"); + let credential = + resolve_rhi_wrapping_credential(runtime, &binding).expect("wrapping credential open"); + provision_rhi_encrypted_identity( + &binding, + &credential, + RhiEncryptedIdentityProvisioningMaterial::new( + attestation_secret(), + [0x42; 32], + [0x43; 24], + [0x44; 24], + ) + .expect("provisioning material"), + ) + .expect("identity provisioning") +} + +#[tokio::test] +async fn signed_attestation_is_canonical_exact_verified_and_nonmutating() { + let expected_public_key = + Keys::new(SecretKey::from_slice(&attestation_secret()).expect("identity secret")) + .public_key() + .to_hex(); + let (root, runtime, metadata, configuration, host, lease, evaluation) = + finalization_fixture_with_source("attestation-exact", |runtime| { + attestation_configuration(runtime, &expected_public_key) + }) + .await; + let identity = provision_attestation_identity(&runtime, &configuration, &metadata); + let before = finalization_snapshot(&runtime).await; + let fence = host + .repositories() + .reconciliation_attempts() + .prepare_finalization(lease, evaluation, now(1_784_347_206_000)) + .await + .expect("finalization fence"); + let signed = build_rhi_signed_evidence_attestation( + fence, + &identity, + UnixTimeSeconds::new(1_784_347_207), + &FixedAttestationEntropy(0xa5), + None, + ) + .expect("signed attestation"); + + assert_eq!(signed.contract_version(), 1); + assert_eq!(signed.created_at_unix_seconds(), 1_784_347_207); + assert!(!signed.has_supersession()); + assert!(!signed.signed_event_bytes().is_empty()); + assert!(signed.signed_event_bytes().len() <= 32 * 1_024); + assert_eq!( + signed.signed_event_sha256(), + &<[u8; 32]>::from(Sha256::digest(signed.signed_event_bytes())) + ); + let event: serde_json::Value = + serde_json::from_slice(signed.signed_event_bytes()).expect("signed event JSON"); + assert_eq!( + signed.signed_event_bytes(), + SIGNED_ATTESTATION_VECTOR.trim_end().as_bytes() + ); + assert_eq!(event["id"], lower_hex(signed.event_id())); + assert_eq!(event["pubkey"], expected_public_key); + assert_eq!(event["created_at"], 1_784_347_207_u64); + assert_eq!(event["kind"], 3_441); + assert_eq!(event["tags"].as_array().expect("tags").len(), 5); + assert_eq!( + event["content"].as_str().expect("content").as_bytes(), + signed.canonical_report_bytes() + ); + let report: serde_json::Value = + serde_json::from_slice(signed.canonical_report_bytes()).expect("canonical report"); + assert_eq!(report["issuer_pubkey"], expected_public_key); + assert_eq!(report["trade_generation"], 2); + assert_eq!(report["report_id"], lower_hex(&signed.statement_digest())); + assert_eq!(report["statement_digest"], report["report_id"]); + assert!(report["supersedes_report_id"].is_null()); + assert!(report["supersedes_event_id"].is_null()); + let rendered = format!("{signed:?}"); + assert!(!rendered.contains(&lower_hex(signed.event_id()))); + assert!(!rendered.contains(&lower_hex(&signed.statement_digest()))); + assert!(!rendered.contains(&expected_public_key)); + assert_eq!(finalization_snapshot(&runtime).await, before); + + let supersession = RhiEvidenceAttestationSupersession::from_attestation(&signed); + assert_eq!( + format!("{supersession:?}"), + "RhiEvidenceAttestationSupersession([redacted])" + ); + host.close().await.expect("close"); + drop(( + supersession, + signed, + identity, + configuration, + metadata, + runtime, + root, + )); +} + +#[tokio::test] +async fn signed_attestation_fails_closed_when_injected_entropy_is_unavailable() { + let expected_public_key = + Keys::new(SecretKey::from_slice(&attestation_secret()).expect("identity secret")) + .public_key() + .to_hex(); + let (root, runtime, metadata, configuration, host, lease, evaluation) = + finalization_fixture_with_source("attestation-entropy", |runtime| { + attestation_configuration(runtime, &expected_public_key) + }) + .await; + let identity = provision_attestation_identity(&runtime, &configuration, &metadata); + let before = finalization_snapshot(&runtime).await; + let fence = host + .repositories() + .reconciliation_attempts() + .prepare_finalization(lease, evaluation, now(1_784_347_206_000)) + .await + .expect("finalization fence"); + let error = build_rhi_signed_evidence_attestation( + fence, + &identity, + UnixTimeSeconds::new(1_784_347_207), + &FailingAttestationEntropy, + None, + ) + .expect_err("entropy failure"); + assert_eq!( + error.kind(), + RhiReconciliationAttestationErrorKind::EntropyUnavailable + ); + assert_eq!( + error.code(), + "reconciliation_attestation_entropy_unavailable" + ); + assert!(Error::source(&error).is_none()); + assert!(!format!("{error} {error:?}").contains(&expected_public_key)); + assert_eq!(finalization_snapshot(&runtime).await, before); + host.close().await.expect("close"); + drop((identity, configuration, metadata, runtime, root)); +} + +#[tokio::test] +async fn signed_attestation_supersession_is_verified_ordered_and_explicit() { + let expected_public_key = + Keys::new(SecretKey::from_slice(&attestation_secret()).expect("identity secret")) + .public_key() + .to_hex(); + let ( + prior_root, + prior_runtime, + prior_metadata, + prior_config, + prior_host, + prior_lease, + prior_eval, + ) = finalization_fixture_with_source_and_generation("attestation-prior", 1, |runtime| { + attestation_configuration(runtime, &expected_public_key) + }) + .await; + let prior_identity = + provision_attestation_identity(&prior_runtime, &prior_config, &prior_metadata); + let prior_fence = prior_host + .repositories() + .reconciliation_attempts() + .prepare_finalization(prior_lease, prior_eval, now(1_784_347_206_000)) + .await + .expect("prior fence"); + let prior = build_rhi_signed_evidence_attestation( + prior_fence, + &prior_identity, + UnixTimeSeconds::new(1_784_347_207), + &FixedAttestationEntropy(0xa6), + None, + ) + .expect("prior attestation"); + + let (next_root, next_runtime, next_metadata, next_config, next_host, next_lease, next_eval) = + finalization_fixture_with_source_and_generation("attestation-next", 2, |runtime| { + attestation_configuration(runtime, &expected_public_key) + }) + .await; + let next_identity = provision_attestation_identity(&next_runtime, &next_config, &next_metadata); + let next_fence = next_host + .repositories() + .reconciliation_attempts() + .prepare_finalization(next_lease, next_eval, now(1_784_347_206_000)) + .await + .expect("next fence"); + let next = build_rhi_signed_evidence_attestation( + next_fence, + &next_identity, + UnixTimeSeconds::new(1_784_347_208), + &FixedAttestationEntropy(0xa7), + Some(RhiEvidenceAttestationSupersession::from_attestation(&prior)), + ) + .expect("superseding attestation"); + assert!(next.has_supersession()); + let next_event: serde_json::Value = + serde_json::from_slice(next.signed_event_bytes()).expect("next event"); + assert_eq!(next_event["tags"].as_array().expect("next tags").len(), 7); + let next_report: serde_json::Value = + serde_json::from_slice(next.canonical_report_bytes()).expect("next report"); + assert_eq!( + next_report["supersedes_report_id"], + lower_hex(&prior.statement_digest()) + ); + assert_eq!( + next_report["supersedes_event_id"], + lower_hex(prior.event_id()) + ); + assert_eq!(next_report["trade_generation"], 3); + + let ( + stale_root, + stale_runtime, + stale_metadata, + stale_config, + stale_host, + stale_lease, + stale_eval, + ) = finalization_fixture_with_source_and_generation("attestation-stale", 1, |runtime| { + attestation_configuration(runtime, &expected_public_key) + }) + .await; + let stale_identity = + provision_attestation_identity(&stale_runtime, &stale_config, &stale_metadata); + let stale_fence = stale_host + .repositories() + .reconciliation_attempts() + .prepare_finalization(stale_lease, stale_eval, now(1_784_347_206_000)) + .await + .expect("stale fence"); + let error = build_rhi_signed_evidence_attestation( + stale_fence, + &stale_identity, + UnixTimeSeconds::new(1_784_347_209), + &FixedAttestationEntropy(0xa8), + Some(RhiEvidenceAttestationSupersession::from_attestation(&next)), + ) + .expect_err("older generation cannot supersede"); + assert_eq!( + error.kind(), + RhiReconciliationAttestationErrorKind::SupersessionInvalid + ); + assert!(Error::source(&error).is_none()); + + prior_host.close().await.expect("prior close"); + next_host.close().await.expect("next close"); + stale_host.close().await.expect("stale close"); + drop(( + prior, + next, + prior_identity, + next_identity, + stale_identity, + prior_config, + next_config, + stale_config, + prior_metadata, + next_metadata, + stale_metadata, + prior_runtime, + next_runtime, + stale_runtime, + prior_root, + next_root, + stale_root, + )); +} + async fn fixture_connection(runtime: &RhiRuntimeContext) -> SqliteConnection { let options = SqliteConnectOptions::new() .filename(runtime.artifacts().state_database()) @@ -1629,9 +1961,47 @@ async fn finalization_fixture( RhiReconciliationLease, rhi::RhiReconciliationEvaluation, ) { + finalization_fixture_with_source(instance, |_| EXAMPLE.to_owned()).await +} + +async fn finalization_fixture_with_source<F>( + instance: &str, + source: F, +) -> ( + tempfile::TempDir, + RhiRuntimeContext, + RhiStateMetadata, + rhi::RhiConfigDocumentV1, + rhi::RhiStateHost, + RhiReconciliationLease, + rhi::RhiReconciliationEvaluation, +) +where + F: FnOnce(&RhiRuntimeContext) -> String, +{ + finalization_fixture_with_source_and_generation(instance, 1, source).await +} + +async fn finalization_fixture_with_source_and_generation<F>( + instance: &str, + initial_generation: u64, + source: F, +) -> ( + tempfile::TempDir, + RhiRuntimeContext, + RhiStateMetadata, + rhi::RhiConfigDocumentV1, + rhi::RhiStateHost, + RhiReconciliationLease, + rhi::RhiReconciliationEvaluation, +) +where + F: FnOnce(&RhiRuntimeContext) -> String, +{ let started_ms = 1_784_347_200_000; let (root, runtime, metadata, configuration, host, first_lease, first_plan) = - replay_fixture(instance, EXAMPLE, started_ms).await; + replay_fixture_with_source_and_generation(instance, started_ms, initial_generation, source) + .await; let wire = replay_wire(); let first_request = &first_plan.requests()[0]; let first_replay = RhiReconciliationSourceReplayPlan::from_request( @@ -1668,7 +2038,7 @@ async fn finalization_fixture( ) .await .expect("schedule final generation"); - assert_eq!(scheduled.job().input_generation(), 2); + assert_eq!(scheduled.job().input_generation(), initial_generation + 1); let lease = jobs .claim_next(owner(0x92), now(started_ms + 3_000)) .await @@ -1706,7 +2076,7 @@ async fn finalization_fixture( RhiReconciliationScopePrerequisites::Satisfied, ) .expect("final manifest"); - assert_eq!(manifest.trade_generation(), 2); + assert_eq!(manifest.trade_generation(), initial_generation + 1); let projection = reduce_rhi_reconciliation_manifest(manifest).expect("final projection"); let claim = *projection.root_mutation_id().expect("root claim"); let evaluation = rhi::evaluate_rhi_reconciliation_claim(projection, claim); @@ -1774,8 +2144,48 @@ async fn replay_fixture( RhiReconciliationLease, RhiReconciliationAttemptPlan, ) { + replay_fixture_with_source(instance, started_ms, |_| source.to_owned()).await +} + +async fn replay_fixture_with_source<F>( + instance: &str, + started_ms: u64, + source: F, +) -> ( + tempfile::TempDir, + RhiRuntimeContext, + RhiStateMetadata, + rhi::RhiConfigDocumentV1, + rhi::RhiStateHost, + RhiReconciliationLease, + RhiReconciliationAttemptPlan, +) +where + F: FnOnce(&RhiRuntimeContext) -> String, +{ + replay_fixture_with_source_and_generation(instance, started_ms, 1, source).await +} + +async fn replay_fixture_with_source_and_generation<F>( + instance: &str, + started_ms: u64, + initial_generation: u64, + source: F, +) -> ( + tempfile::TempDir, + RhiRuntimeContext, + RhiStateMetadata, + rhi::RhiConfigDocumentV1, + rhi::RhiStateHost, + RhiReconciliationLease, + RhiReconciliationAttemptPlan, +) +where + F: FnOnce(&RhiRuntimeContext) -> String, +{ let root = tempfile::tempdir().expect("root"); let runtime = runtime(root.path(), instance); + let source = source(&runtime); let configuration = parse_rhi_config_v1(source.as_bytes(), rhi::RhiConfigProfile::RepoLocal) .expect("configuration"); let metadata = metadata_from_config(&runtime, &configuration); @@ -1784,7 +2194,7 @@ async fn replay_fixture( write_dirty( &runtime, trade, - 1, + initial_generation, *metadata.evidence_policy_digest().as_bytes(), started_ms / 1_000, )