commit 662f79fd5a61302aa09481f95059f0e43bf24506
parent ba67c988ee14f2cfd45057b54f4d9c88175090c0
Author: triesap <tyson@radroots.org>
Date: Mon, 24 Aug 2026 10:03:57 +0000
refactor(rhi): sign reconciliation attestations
Diffstat:
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(¤t, &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,
)