rhi

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

commit c4bc8f4ab35ae2c066c6e439f68ee42ad1493193
parent bc1a4ac7a8c4e5e4d368ef0e8111c9f5039cd5fb
Author: triesap <tyson@radroots.org>
Date:   Mon, 24 Aug 2026 08:01:42 +0000

reconciliation: freeze immutable evidence manifest

- Consume confirmed reconciliation commits into sealed canonical manifests.
- Bind policy, selectors, source results, signed events, and provenance.
- Freeze machine, package, documentation, and public API contracts.
- Verify all-target tests, Clippy, Rustdoc, and API freshness.

Diffstat:
MAGENTS.md | 8++++++++
MREADME | 26++++++++++++++++++++++++++
Mcontracts/api_baselines/rhi.txt | 34++++++++++++++++++++++++++++++++++
Acontracts/services_hardening/reconciliation_manifest.v1.json | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 6++++++
Msrc/reconciliation_commit.rs | 12++++++++++++
Asrc/reconciliation_manifest.rs | 417+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/package_boundary.rs | 45++++++++++++++++++++++++++++++++++++++++++++-
Mtests/services_hardening_reconciliation_jobs.rs | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Atests/services_hardening_reconciliation_manifest_contract.rs | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 840 insertions(+), 7 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -135,6 +135,14 @@ canonically ordered immutable set with explicit policy digest, reducer version, coverage, and observed-time input; they have no database insertion, relay, scheduling, wall-clock, entropy, or network dependency. +- Mint the immutable reconciliation manifest only by consuming a sealed, + durably confirmed source-commit outcome. Bind each source result to its exact + selector, completion, cursor, timing, and persisted-inventory digest, and + bind each observation to the exact canonical signed-event and first-source + provenance. Do not expose a raw manifest constructor or parser from RHI. +- Keep Step 191 manifest materialization pure and in memory. Step 199 alone + owns durable manifest persistence; reducers, final coverage/outcome, + attestation, publication, and job finalization retain their ordered owners. - Coverage is exactly `Missing`, `Partial`, `ScopeSatisfied`, or `Unsupported`. ScopeSatisfied means only that the configured policy was satisfied; optional evidence never substitutes for required-source completion. diff --git a/README b/README @@ -213,6 +213,32 @@ selector cursor evidence is minted only after durable commit confirmation. The exact machine contract is [`reconciliation_commit.v1.json`](contracts/services_hardening/reconciliation_commit.v1.json). +## Immutable reconciliation manifest + +`RhiReconciliationSourceCommitOutcome::into_evidence_manifest` consumes one +sealed, durably confirmed Step 190 outcome and freezes its exact canonical +mutation, signed-event, first-provenance, and per-source completion inventory +into the shared `radroots.trade.evidence-manifest.v1` encoding. The manifest +binds the claimed trade generation and evidence-policy digest. Each source +result additionally binds its exact selector digest, detailed completion, +timing, cursor inputs, checkpoint eligibility, and Step 190 persisted-inventory +digest. Each observation binds the verified mutation and event identities, the +SHA-256 of the exact canonical signed-event JSON, and a source/selector/policy/ +signature/first-observation provenance digest. + +Sources and observations use the shared canonical ordering, so arrival and +insertion order cannot select truth. The explicit whole-second observation +time cannot precede any committed source finish, and the explicit closed scope- +prerequisite input is included in the immutable bytes. Callers can inspect only +the redacted sealed result's identity, counts, canonical bytes, and digest; they +cannot construct a manifest from uncommitted replay material or supply its +result/provenance digests. Materialization is pure and performs no SQLite, +filesystem, source, relay, network, task, clock, or entropy operation. Durable +manifest persistence remains deferred to Step 199, and reduction, final +coverage/outcome, attestation, and publication retain their later checkpoint +owners. The exact machine contract is +[`reconciliation_manifest.v1.json`](contracts/services_hardening/reconciliation_manifest.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 @@ -178,6 +178,11 @@ pub rhi::RhiReconciliationJobState::Ready pub rhi::RhiReconciliationJobState::Superseded impl rhi::RhiReconciliationJobState pub const fn rhi::RhiReconciliationJobState::code(self) -> &'static str +pub enum rhi::RhiReconciliationManifestErrorKind +pub rhi::RhiReconciliationManifestErrorKind::InvalidCommittedInventory +pub rhi::RhiReconciliationManifestErrorKind::InvalidObservationTime +impl rhi::RhiReconciliationManifestErrorKind +pub const fn rhi::RhiReconciliationManifestErrorKind::code(self) -> &'static str pub enum rhi::RhiReconciliationReplayErrorKind pub rhi::RhiReconciliationReplayErrorKind::InvalidConfiguration pub rhi::RhiReconciliationReplayErrorKind::InvalidInput @@ -187,6 +192,9 @@ pub rhi::RhiReconciliationReplayErrorKind::ResourceLimit pub rhi::RhiReconciliationReplayErrorKind::SignedEventConflict impl rhi::RhiReconciliationReplayErrorKind pub const fn rhi::RhiReconciliationReplayErrorKind::code(self) -> &'static str +pub enum rhi::RhiReconciliationScopePrerequisites +pub rhi::RhiReconciliationScopePrerequisites::Satisfied +pub rhi::RhiReconciliationScopePrerequisites::Unsatisfied pub enum rhi::RhiRuntimeAdapterErrorKind pub rhi::RhiRuntimeAdapterErrorKind::CredentialAccess pub rhi::RhiRuntimeAdapterErrorKind::EntropyUnavailable @@ -711,6 +719,29 @@ impl rhi::RhiReconciliationLeaseOwner pub fn rhi::RhiReconciliationLeaseOwner::from_bytes([u8; 16]) -> core::result::Result<Self, rhi::RhiReconciliationJobError> impl core::fmt::Debug for rhi::RhiReconciliationLeaseOwner pub fn rhi::RhiReconciliationLeaseOwner::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiReconciliationManifest +impl rhi::RhiReconciliationManifest +pub fn rhi::RhiReconciliationManifest::canonical_bytes(&self) -> &[u8] +pub const fn rhi::RhiReconciliationManifest::contract_version(&self) -> u32 +pub fn rhi::RhiReconciliationManifest::digest(&self) -> [u8; 32] +pub fn rhi::RhiReconciliationManifest::observation_count(&self) -> usize +pub const fn rhi::RhiReconciliationManifest::observed_at_unix_seconds(&self) -> u64 +pub const fn rhi::RhiReconciliationManifest::shared_manifest_contract_id(&self) -> &'static str +pub const fn rhi::RhiReconciliationManifest::shared_manifest_contract_version(&self) -> u16 +pub fn rhi::RhiReconciliationManifest::source_count(&self) -> usize +pub const fn rhi::RhiReconciliationManifest::trade_generation(&self) -> u64 +pub const fn rhi::RhiReconciliationManifest::trade_id(&self) -> &radroots_event::id::TradeId +impl core::fmt::Debug for rhi::RhiReconciliationManifest +pub fn rhi::RhiReconciliationManifest::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiReconciliationManifestError +impl rhi::RhiReconciliationManifestError +pub const fn rhi::RhiReconciliationManifestError::code(self) -> &'static str +pub const fn rhi::RhiReconciliationManifestError::kind(self) -> rhi::RhiReconciliationManifestErrorKind +impl core::error::Error for rhi::RhiReconciliationManifestError +impl core::fmt::Debug for rhi::RhiReconciliationManifestError +pub fn rhi::RhiReconciliationManifestError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for rhi::RhiReconciliationManifestError +pub fn rhi::RhiReconciliationManifestError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiReconciliationReplayError impl rhi::RhiReconciliationReplayError pub const fn rhi::RhiReconciliationReplayError::code(self) -> &'static str @@ -735,6 +766,8 @@ pub fn rhi::RhiReconciliationSourceCommitOutcome::committed_cursors(&self) -> &[ pub const fn rhi::RhiReconciliationSourceCommitOutcome::created(&self) -> bool pub const fn rhi::RhiReconciliationSourceCommitOutcome::dirty_generation_advanced(&self) -> bool pub const fn rhi::RhiReconciliationSourceCommitOutcome::source_result_count(&self) -> u32 +impl rhi::RhiReconciliationSourceCommitOutcome +pub fn rhi::RhiReconciliationSourceCommitOutcome::into_evidence_manifest(self, radroots_service_host::time::UnixTimeSeconds, rhi::RhiReconciliationScopePrerequisites) -> core::result::Result<rhi::RhiReconciliationManifest, rhi::RhiReconciliationManifestError> impl core::fmt::Debug for rhi::RhiReconciliationSourceCommitOutcome pub fn rhi::RhiReconciliationSourceCommitOutcome::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiReconciliationSourceCursorEvidence @@ -1191,6 +1224,7 @@ pub const rhi::RHI_RECONCILIATION_ATTEMPT_MAX_SOURCES: usize pub const rhi::RHI_RECONCILIATION_COMMIT_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_JOB_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_JOB_MAX_ACTIVE: u32 +pub const rhi::RHI_RECONCILIATION_MANIFEST_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_REPLAY_CONTRACT_VERSION: u32 pub const rhi::RHI_RUNTIME_ADAPTER_CONTRACT_VERSION: u32 pub const rhi::RHI_RUNTIME_FOUNDATION_CONTRACT_VERSION: u32 diff --git a/contracts/services_hardening/reconciliation_manifest.v1.json b/contracts/services_hardening/reconciliation_manifest.v1.json @@ -0,0 +1,104 @@ +{ + "schema": "radroots.rhi.reconciliation-manifest", + "schema_version": 1, + "contract_version": 1, + "construction_authority": "confirmed_step_190_commit_outcome_only", + "shared_manifest": { + "contract_id": "radroots.trade.evidence-manifest.v1", + "contract_version": 1, + "canonical_ordering": "shared_manifest_source_and_observation_order", + "manifest_digest": "shared_domain_separated_sha256_over_exact_canonical_bytes", + "source_count_maximum": 16, + "observation_count_maximum": 65536, + "canonical_bytes_maximum": 16777216 + }, + "identity": { + "trade": "exact_claimed_job_trade", + "generation": "exact_claimed_job_input_generation_nonzero", + "evidence_policy_digest": "exact_claimed_job_policy_digest", + "source_selector_digest": "bound_inside_each_source_result_digest", + "scope_prerequisites": "explicit_closed_satisfied_or_unsatisfied_input", + "observation_time": "explicit_integer_utc_seconds_not_before_any_source_finish" + }, + "source_result_digest": { + "algorithm": "sha256", + "domain_utf8_then_nul": "radroots.rhi.reconciliation_manifest_source_result.v1", + "ordered_fields": [ + "attempt_id", + "canonical_source_ordinal_u32_be", + "request_id", + "framed_source_id_utf8", + "trade_id", + "required_flag", + "evidence_policy_digest", + "source_selector_digest", + "replay_id", + "framed_detailed_completion_code_utf8", + "started_at_unix_ms_u64_be", + "finished_at_unix_ms_u64_be", + "accepted_event_count_u32_be", + "accepted_event_bytes_u64_be", + "step_190_accepted_inventory_digest", + "duplicate_observation_count_u32_be", + "optional_first_observed_at_unix_s", + "optional_prior_cursor", + "overlap_seconds_u64_be", + "inclusive_since_unix_s_u64_be", + "optional_cursor_candidate", + "checkpoint_eligible_flag" + ], + "step_190_inventory_digest_domain_utf8_then_nul": "radroots.rhi.reconciliation_source_inventory.v1" + }, + "observation": { + "mutation_id": "signature_verified_canonical_mutation_id", + "event_id": "signature_verified_signed_event_id", + "signed_event_digest": "sha256_over_exact_canonical_signed_event_json", + "provenance_digest": { + "algorithm": "sha256", + "domain_utf8_then_nul": "radroots.rhi.evidence_provenance.v1", + "ordered_fields": [ + "framed_source_id_utf8", + "framed_selector_literal_trade_mutation_lineage_v1", + "evidence_policy_digest", + "event_id", + "event_signature", + "first_observed_at_unix_s_u64_be" + ] + } + }, + "result": { + "sealed": true, + "caller_forgeable": false, + "canonical_bytes_available": true, + "manifest_digest_available": true, + "debug": "counts_only_redacted", + "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 + }, + "forbidden": [ + "manifest_from_uncommitted_replay", + "caller_supplied_source_result_digest", + "caller_supplied_provenance_digest", + "arrival_order_selected_truth", + "event_authored_time_relabelled_as_observation_time", + "manifest_persistence", + "reducer_or_final_outcome_authority" + ], + "deferred": [ + "wave_qualification", + "lineage_reducer", + "coverage_and_outcome_projection", + "manifest_persistence", + "attestation", + "publication", + "job_finalization" + ] +} diff --git a/src/lib.rs b/src/lib.rs @@ -11,6 +11,7 @@ mod identity_envelope; mod reconciliation_attempt; mod reconciliation_commit; mod reconciliation_job; +mod reconciliation_manifest; mod reconciliation_replay; mod runtime_adapters; mod runtime_context; @@ -88,6 +89,11 @@ pub use reconciliation_job::{ RhiReconciliationLease, RhiReconciliationLeaseOwner, RhiReconciliationRetryDelayMilliseconds, RhiReconciliationScheduleOutcome, RhiReconciliationUnixMilliseconds, }; +pub use reconciliation_manifest::{ + RHI_RECONCILIATION_MANIFEST_CONTRACT_VERSION, RhiReconciliationManifest, + RhiReconciliationManifestError, RhiReconciliationManifestErrorKind, + RhiReconciliationScopePrerequisites, +}; pub use reconciliation_replay::{ RHI_RECONCILIATION_REPLAY_CONTRACT_VERSION, RhiReconciliationReplayError, RhiReconciliationReplayErrorKind, RhiReconciliationSourceCursorEvidence, diff --git a/src/reconciliation_commit.rs b/src/reconciliation_commit.rs @@ -15,6 +15,7 @@ use crate::{ RhiReconciliationSourceCursorEvidence, RhiReconciliationSourceReplay, RhiStateHostMode, RhiTradeSourceCursor, reconciliation_job::{LeaseValidationError, validate_exact_lease}, + reconciliation_manifest::{RhiCommittedManifestMaterial, committed_manifest_material}, reconciliation_replay::{ RhiReconciliationReplayCommitFact, RhiReconciliationReplayCommitParts, committed_cursor_evidence, @@ -160,6 +161,7 @@ pub struct RhiReconciliationSourceCommitOutcome { checkpoint_advance_count: u32, dirty_generation_advanced: bool, committed_cursors: Box<[RhiReconciliationSourceCursorEvidence]>, + pub(crate) manifest_material: RhiCommittedManifestMaterial, } impl RhiReconciliationSourceCommitOutcome { @@ -203,6 +205,7 @@ impl fmt::Debug for RhiReconciliationSourceCommitOutcome { .field("checkpoint_advance_count", &self.checkpoint_advance_count) .field("dirty_generation_advanced", &self.dirty_generation_advanced) .field("committed_cursors", &self.committed_cursors.len()) + .field("manifest_material", &"[sealed]") .finish() } } @@ -254,6 +257,8 @@ impl RhiReconciliationAttemptRepository<'_> { }) { return Err(failure(RhiReconciliationCommitErrorKind::InvalidInput)); } + let manifest_material = committed_manifest_material(&plan, &parts) + .map_err(|()| failure(RhiReconciliationCommitErrorKind::InvalidInput))?; let raw = self .host() @@ -284,6 +289,7 @@ impl RhiReconciliationAttemptRepository<'_> { checkpoint_advance_count: raw.checkpoint_advance_count, dirty_generation_advanced: raw.dirty_generation_advanced, committed_cursors, + manifest_material, }) } } @@ -646,6 +652,12 @@ fn accepted_inventory_digest( accepted_inventory_digest_for_facts(&part.facts) } +pub(crate) fn committed_inventory_digest( + part: &RhiReconciliationReplayCommitParts, +) -> Option<[u8; 32]> { + accepted_inventory_digest(part).ok() +} + fn accepted_inventory_digest_for_facts( facts: &[RhiReconciliationReplayCommitFact], ) -> Result<[u8; 32], CommitOperationError> { diff --git a/src/reconciliation_manifest.rs b/src/reconciliation_manifest.rs @@ -0,0 +1,417 @@ +//! Immutable manifest materialization from one confirmed reconciliation commit. + +use core::{fmt, num::NonZeroU64}; +use std::error::Error; + +use radroots_event::id::{EventId, MutationId, TradeId}; +use radroots_service_host::UnixTimeSeconds; +use radroots_trade::evidence::{ + RadrootsTradeEvidenceManifestObservationV1, RadrootsTradeEvidenceManifestSourceResultV1, + RadrootsTradeEvidenceManifestV1, RadrootsTradeEvidencePolicyDigestV1, + RadrootsTradeEvidenceProvenanceDigestV1, RadrootsTradeEvidenceScopePrerequisitesV1, + RadrootsTradeEvidenceSourceCompletionV1, RadrootsTradeEvidenceSourceIdV1, + RadrootsTradeEvidenceSourceRequirementV1, RadrootsTradeEvidenceSourceResultDigestV1, + RadrootsTradeEvidenceSourceResultV1, RadrootsTradeSignedEventDigestV1, +}; +use sha2::{Digest, Sha256}; + +use crate::{ + RhiReconciliationAttemptPlan, RhiReconciliationSourceCommitOutcome, RhiTradeSourceCompletion, + reconciliation_commit::committed_inventory_digest, + reconciliation_replay::{ + RhiReconciliationReplayCommitFact, RhiReconciliationReplayCommitParts, + }, +}; + +/// Exact version of the RHI reconciliation-manifest materialization contract. +pub const RHI_RECONCILIATION_MANIFEST_CONTRACT_VERSION: u32 = 1; + +const SOURCE_RESULT_DIGEST_DOMAIN: &[u8] = + b"radroots.rhi.reconciliation_manifest_source_result.v1\0"; +const PROVENANCE_DIGEST_DOMAIN: &[u8] = b"radroots.rhi.evidence_provenance.v1\0"; +const SOURCE_SELECTOR: &[u8] = b"trade_mutation_lineage_v1"; + +/// Exact non-source prerequisite state bound into one manifest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiReconciliationScopePrerequisites { + Satisfied, + Unsatisfied, +} + +/// Stable source-free manifest materialization failure class. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiReconciliationManifestErrorKind { + InvalidObservationTime, + InvalidCommittedInventory, +} + +impl RhiReconciliationManifestErrorKind { + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidObservationTime => "reconciliation_manifest_observation_time_invalid", + Self::InvalidCommittedInventory => "reconciliation_manifest_inventory_invalid", + } + } +} + +/// Redacted source-free reconciliation-manifest failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiReconciliationManifestError { + kind: RhiReconciliationManifestErrorKind, +} + +impl RhiReconciliationManifestError { + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> RhiReconciliationManifestErrorKind { + 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 RhiReconciliationManifestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + RhiReconciliationManifestErrorKind::InvalidObservationTime => { + "RHI reconciliation manifest observation time is invalid" + } + RhiReconciliationManifestErrorKind::InvalidCommittedInventory => { + "RHI committed reconciliation inventory is invalid" + } + }) + } +} + +impl fmt::Debug for RhiReconciliationManifestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiReconciliationManifestError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for RhiReconciliationManifestError {} + +/// Sealed immutable evidence manifest derived from one confirmed Step190 commit. +/// +/// Callers cannot forge a manifest by constructing its representation. +/// +/// ```compile_fail +/// use rhi::RhiReconciliationManifest; +/// +/// let _forged = RhiReconciliationManifest { inner: todo!() }; +/// ``` +pub struct RhiReconciliationManifest { + inner: RadrootsTradeEvidenceManifestV1, +} + +impl RhiReconciliationManifest { + /// Returns the exact RHI manifest-materialization contract version. + #[must_use] + pub const fn contract_version(&self) -> u32 { + RHI_RECONCILIATION_MANIFEST_CONTRACT_VERSION + } + + /// Returns the exact shared manifest encoding contract ID. + #[must_use] + pub const fn shared_manifest_contract_id(&self) -> &'static str { + self.inner.contract_id() + } + + /// Returns the exact shared manifest encoding contract version. + #[must_use] + pub const fn shared_manifest_contract_version(&self) -> u16 { + self.inner.contract_version() + } + + /// Returns the exact trade selected by the committed attempt. + #[must_use] + pub const fn trade_id(&self) -> &TradeId { + self.inner.trade_id() + } + + /// Returns the exact nonzero dirty generation frozen by the manifest. + #[must_use] + pub const fn trade_generation(&self) -> u64 { + self.inner.trade_generation().get() + } + + /// Returns the explicit observation time in UTC seconds. + #[must_use] + pub const fn observed_at_unix_seconds(&self) -> u64 { + self.inner.observed_at_unix_s() + } + + /// Returns the exact canonical manifest bytes. + #[must_use] + pub fn canonical_bytes(&self) -> &[u8] { + self.inner.canonical_bytes() + } + + /// Returns the domain-separated shared manifest digest bytes. + #[must_use] + pub fn digest(&self) -> [u8; 32] { + *self.inner.digest().as_bytes() + } + + /// Returns the exact configured source count. + #[must_use] + pub fn source_count(&self) -> usize { + self.inner.sources().len() + } + + /// Returns the exact accepted source-observation count. + #[must_use] + pub fn observation_count(&self) -> usize { + self.inner.observations().len() + } +} + +impl fmt::Debug for RhiReconciliationManifest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiReconciliationManifest") + .field("source_count", &self.source_count()) + .field("observation_count", &self.observation_count()) + .finish_non_exhaustive() + } +} + +impl RhiReconciliationSourceCommitOutcome { + /// Freezes the exact committed inventory into the governed shared manifest. + /// + /// This consumes the sealed commit outcome so uncommitted replay material + /// cannot be relabelled as durable evidence. The observation time must be + /// at or after every committed source-result completion. + pub fn into_evidence_manifest( + self, + observed_at: UnixTimeSeconds, + prerequisites: RhiReconciliationScopePrerequisites, + ) -> Result<RhiReconciliationManifest, RhiReconciliationManifestError> { + freeze_manifest(self.manifest_material, observed_at, prerequisites) + } +} + +pub(crate) struct RhiCommittedManifestMaterial { + trade_id: TradeId, + generation: NonZeroU64, + policy_digest: RadrootsTradeEvidencePolicyDigestV1, + latest_finished_unix_ms: u64, + sources: Box<[RadrootsTradeEvidenceManifestSourceResultV1]>, + observations: Box<[RadrootsTradeEvidenceManifestObservationV1]>, +} + +pub(crate) fn committed_manifest_material( + plan: &RhiReconciliationAttemptPlan, + parts: &[RhiReconciliationReplayCommitParts], +) -> Result<RhiCommittedManifestMaterial, ()> { + let trade_id = parts.first().ok_or(())?.trade_id; + let generation = NonZeroU64::new(plan.input_generation()).ok_or(())?; + let policy_digest = + RadrootsTradeEvidencePolicyDigestV1::from_bytes(*plan.evidence_policy_digest().as_bytes()); + let mut latest_finished_unix_ms = 0_u64; + let mut sources = Vec::with_capacity(parts.len()); + let observation_capacity = parts.iter().try_fold(0_usize, |total, part| { + total.checked_add(part.facts.len()).ok_or(()) + })?; + let mut observations = Vec::with_capacity(observation_capacity); + + for (ordinal, part) in parts.iter().enumerate() { + if part.trade_id != trade_id + || part.policy_digest != *plan.evidence_policy_digest().as_bytes() + { + return Err(()); + } + latest_finished_unix_ms = latest_finished_unix_ms.max(part.result.finished_at().get()); + let source_id = + RadrootsTradeEvidenceSourceIdV1::parse(part.source_id.as_ref()).map_err(|_| ())?; + let result = RadrootsTradeEvidenceSourceResultV1::new( + if part.required { + RadrootsTradeEvidenceSourceRequirementV1::Required + } else { + RadrootsTradeEvidenceSourceRequirementV1::Optional + }, + map_completion(part.result.outcome()), + part.result.accepted_event_count(), + ) + .map_err(|_| ())?; + let inventory_digest = committed_inventory_digest(part).ok_or(())?; + let result_digest = source_result_digest(plan, ordinal, part, inventory_digest)?; + sources.push(RadrootsTradeEvidenceManifestSourceResultV1::new( + source_id.clone(), + result, + RadrootsTradeEvidenceSourceResultDigestV1::from_bytes(result_digest), + )); + for fact in &part.facts { + observations.push(manifest_observation(source_id.clone(), part, fact)?); + } + } + + Ok(RhiCommittedManifestMaterial { + trade_id, + generation, + policy_digest, + latest_finished_unix_ms, + sources: sources.into_boxed_slice(), + observations: observations.into_boxed_slice(), + }) +} + +fn freeze_manifest( + material: RhiCommittedManifestMaterial, + observed_at: UnixTimeSeconds, + prerequisites: RhiReconciliationScopePrerequisites, +) -> Result<RhiReconciliationManifest, RhiReconciliationManifestError> { + let earliest_observation = material.latest_finished_unix_ms.div_ceil(1_000); + if observed_at.get() < earliest_observation || i64::try_from(observed_at.get()).is_err() { + return Err(error( + RhiReconciliationManifestErrorKind::InvalidObservationTime, + )); + } + let inner = RadrootsTradeEvidenceManifestV1::new( + material.trade_id, + material.generation, + material.policy_digest, + observed_at.get(), + match prerequisites { + RhiReconciliationScopePrerequisites::Satisfied => { + RadrootsTradeEvidenceScopePrerequisitesV1::Satisfied + } + RhiReconciliationScopePrerequisites::Unsatisfied => { + RadrootsTradeEvidenceScopePrerequisitesV1::Unsatisfied + } + }, + material.sources.into_vec(), + material.observations.into_vec(), + ) + .map_err(|_| error(RhiReconciliationManifestErrorKind::InvalidCommittedInventory))?; + Ok(RhiReconciliationManifest { inner }) +} + +fn map_completion(value: RhiTradeSourceCompletion) -> RadrootsTradeEvidenceSourceCompletionV1 { + match value { + RhiTradeSourceCompletion::Complete => RadrootsTradeEvidenceSourceCompletionV1::Complete, + RhiTradeSourceCompletion::Unsupported => { + RadrootsTradeEvidenceSourceCompletionV1::Unsupported + } + RhiTradeSourceCompletion::IncompleteTimeout + | RhiTradeSourceCompletion::IncompleteUnavailable + | RhiTradeSourceCompletion::IncompleteResourceLimit + | RhiTradeSourceCompletion::IncompleteUnknown => { + RadrootsTradeEvidenceSourceCompletionV1::Incomplete + } + } +} + +fn source_result_digest( + plan: &RhiReconciliationAttemptPlan, + ordinal: usize, + part: &RhiReconciliationReplayCommitParts, + inventory_digest: [u8; 32], +) -> Result<[u8; 32], ()> { + let mut digest = Sha256::new(); + digest.update(SOURCE_RESULT_DIGEST_DOMAIN); + digest.update(plan.id().as_bytes()); + digest.update(u32::try_from(ordinal).map_err(|_| ())?.to_be_bytes()); + digest.update(part.request_id.as_bytes()); + update_framed(&mut digest, part.source_id.as_bytes())?; + digest.update(part.trade_id.as_bytes()); + digest.update([u8::from(part.required)]); + digest.update(part.policy_digest); + digest.update(part.selector_digest); + digest.update(part.replay_id.as_bytes()); + update_framed(&mut digest, part.result.outcome().code().as_bytes())?; + digest.update(part.result.started_at().get().to_be_bytes()); + digest.update(part.result.finished_at().get().to_be_bytes()); + digest.update(part.result.accepted_event_count().to_be_bytes()); + digest.update(part.result.accepted_event_bytes().to_be_bytes()); + digest.update(inventory_digest); + digest.update(part.duplicate_observations.to_be_bytes()); + update_optional_u64(&mut digest, part.first_observed_at.map(|value| value.get())); + update_optional_cursor(&mut digest, part.prior_cursor); + digest.update(part.overlap_seconds.to_be_bytes()); + digest.update(part.since_unix_seconds.to_be_bytes()); + update_optional_cursor(&mut digest, part.cursor_candidate); + digest.update([u8::from(part.eligible_cursor.is_some())]); + Ok(digest.finalize().into()) +} + +fn manifest_observation( + source_id: RadrootsTradeEvidenceSourceIdV1, + part: &RhiReconciliationReplayCommitParts, + fact: &RhiReconciliationReplayCommitFact, +) -> Result<RadrootsTradeEvidenceManifestObservationV1, ()> { + let record = &fact.record; + let mut provenance = Sha256::new(); + provenance.update(PROVENANCE_DIGEST_DOMAIN); + update_framed(&mut provenance, part.source_id.as_bytes())?; + update_framed(&mut provenance, SOURCE_SELECTOR)?; + provenance.update(part.policy_digest); + provenance.update(record.event_id); + provenance.update(record.event_signature); + provenance.update(fact.observed_at.get().to_be_bytes()); + Ok(RadrootsTradeEvidenceManifestObservationV1::new( + source_id, + MutationId::from_bytes(record.mutation_id), + EventId::from_bytes(record.event_id), + RadrootsTradeSignedEventDigestV1::sha256(&record.canonical_event_json), + RadrootsTradeEvidenceProvenanceDigestV1::from_bytes(provenance.finalize().into()), + )) +} + +fn update_framed(digest: &mut Sha256, bytes: &[u8]) -> Result<(), ()> { + digest.update(u64::try_from(bytes.len()).map_err(|_| ())?.to_be_bytes()); + digest.update(bytes); + Ok(()) +} + +fn update_optional_u64(digest: &mut Sha256, value: Option<u64>) { + match value { + Some(value) => { + digest.update([1]); + digest.update(value.to_be_bytes()); + } + None => digest.update([0]), + } +} + +fn update_optional_cursor(digest: &mut Sha256, value: Option<crate::RhiTradeSourceCursor>) { + match value { + Some(value) => { + digest.update([1]); + digest.update(value.created_at_unix_seconds().to_be_bytes()); + digest.update(value.event_id()); + } + None => digest.update([0]), + } +} + +const fn error(kind: RhiReconciliationManifestErrorKind) -> RhiReconciliationManifestError { + RhiReconciliationManifestError { kind } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn diagnostics_are_closed_redacted_and_source_free() { + for kind in [ + RhiReconciliationManifestErrorKind::InvalidObservationTime, + RhiReconciliationManifestErrorKind::InvalidCommittedInventory, + ] { + let error = super::error(kind); + assert_eq!(error.kind(), kind); + assert!(Error::source(&error).is_none()); + assert!(!format!("{error} {error:?}").contains("trade-primary")); + } + } +} 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_MANIFEST: &str = include_str!("../src/reconciliation_manifest.rs"); const RECONCILIATION_JOBS: &str = include_str!("../src/reconciliation_job.rs"); const RECONCILIATION_REPLAY: &str = include_str!("../src/reconciliation_replay.rs"); const RECONCILIATION_ATTEMPT_CONTRACT: &str = @@ -21,6 +22,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_MANIFEST_CONTRACT: &str = + include_str!("../contracts/services_hardening/reconciliation_manifest.v1.json"); const RUNTIME_FOUNDATION_CONTRACT: &str = include_str!("../contracts/services_hardening/runtime_foundation.v1.json"); const TRADE_INGEST_CONTRACT: &str = @@ -40,6 +43,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/reconciliation_attempt.rs"), include_str!("../src/reconciliation_commit.rs"), include_str!("../src/reconciliation_job.rs"), + include_str!("../src/reconciliation_manifest.rs"), include_str!("../src/reconciliation_replay.rs"), include_str!("../src/runtime_context.rs"), include_str!("../src/runtime_adapters.rs"), @@ -110,6 +114,7 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "reconciliation_attempt", "reconciliation_commit", "reconciliation_job", + "reconciliation_manifest", "reconciliation_replay", "runtime_context", "runtime_adapters", @@ -152,6 +157,9 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "RhiReconciliationAttemptResults", "RhiReconciliationSourceCommitOutcome", "RhiReconciliationCommitErrorKind", + "RhiReconciliationManifest", + "RhiReconciliationManifestErrorKind", + "RhiReconciliationScopePrerequisites", "RhiReconciliationSourceReplayPlan", "RhiReconciliationSourceReplay", "RhiReconciliationJobPolicy", @@ -237,6 +245,39 @@ fn reconciliation_commit_is_atomic_bounded_and_sealed() { } #[test] +fn reconciliation_manifest_is_canonical_sealed_and_effect_free() { + let contract: serde_json::Value = serde_json::from_str(RECONCILIATION_MANIFEST_CONTRACT) + .expect("reconciliation-manifest contract"); + assert_eq!(contract["schema"], "radroots.rhi.reconciliation-manifest"); + assert_eq!(contract["contract_version"], 1); + assert_eq!( + contract["construction_authority"], + "confirmed_step_190_commit_outcome_only" + ); + assert_eq!(contract["effects"]["sqlite"], false); + for required in [ + "RadrootsTradeEvidenceManifestV1::new(", + "SOURCE_RESULT_DIGEST_DOMAIN", + "PROVENANCE_DIGEST_DOMAIN", + "committed_inventory_digest(part)", + "pub fn into_evidence_manifest(", + ] { + assert!( + RECONCILIATION_MANIFEST.contains(required), + "reconciliation manifest is missing {required}" + ); + } + for forbidden in ["sqlx::", "std::fs", "std::net", "tokio::", "SystemTime"] { + assert!( + !RECONCILIATION_MANIFEST.contains(forbidden), + "reconciliation manifest gained forbidden authority {forbidden}" + ); + } + assert!(!ROOT.contains("pub mod reconciliation_manifest")); + assert!(!PUBLIC_API.contains("rhi::reconciliation_manifest::")); +} + +#[test] fn public_errors_are_crate_owned_redacted_and_source_free() { let production = SOURCES.join("\n"); assert!(!production.contains("fn source(")); @@ -268,7 +309,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, 20); + assert_eq!(public_error_count, 21); } #[test] @@ -686,6 +727,8 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() { "[`reconciliation_replay.v1.json`](contracts/services_hardening/reconciliation_replay.v1.json)", "## Atomic reconciliation result commit", "[`reconciliation_commit.v1.json`](contracts/services_hardening/reconciliation_commit.v1.json)", + "## Immutable reconciliation manifest", + "[`reconciliation_manifest.v1.json`](contracts/services_hardening/reconciliation_manifest.v1.json)", "configured queue capacity is enforced beneath a fixed 65,536-job", "from an unexpired claimed job lease", "can be omitted, duplicated, reordered, or appended beyond", diff --git a/tests/services_hardening_reconciliation_jobs.rs b/tests/services_hardening_reconciliation_jobs.rs @@ -11,10 +11,10 @@ use rhi::{ RhiReconciliationAttemptResults, RhiReconciliationCommitErrorKind, RhiReconciliationJobErrorKind, RhiReconciliationJobPolicy, RhiReconciliationJobState, RhiReconciliationLease, RhiReconciliationLeaseOwner, RhiReconciliationRetryDelayMilliseconds, - RhiReconciliationSourceReplayPlan, RhiReconciliationSourceResult, - RhiReconciliationUnixMilliseconds, RhiRuntimeContext, RhiStateMetadata, - RhiTradeMutationAdmissionLimits, RhiTradeMutationAuthoredTimePolicy, - RhiTradeMutationObservedAtUnixSeconds, RhiTradeSourceCompletion, TradeId, + 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, resolve_rhi_runtime_context, @@ -997,6 +997,7 @@ async fn source_replay_commit_is_atomic_idempotent_and_mints_durable_cursor_evid }; let first_replay = make_replay(); let retry_replay = make_replay(); + let early_replay = make_replay(); let resume_plan = plan.clone(); let attempts = host.repositories().reconciliation_attempts(); let committed = attempts @@ -1014,23 +1015,72 @@ async fn source_replay_commit_is_atomic_idempotent_and_mints_durable_cursor_evid .created_at_unix_seconds(), 1_784_347_200 ); + let committed_cursor = committed.committed_cursors()[0].clone(); let resumed = RhiReconciliationSourceReplayPlan::from_request( &resume_plan, &resume_plan.requests()[0], &configuration, - Some(committed.committed_cursors()[0].clone()), + Some(committed_cursor), ) .expect("committed cursor resumes exact scope"); assert_eq!(resumed.since_unix_seconds(), 1_784_346_900); + let manifest = committed + .into_evidence_manifest( + UnixTimeSeconds::new(1_784_347_203), + RhiReconciliationScopePrerequisites::Satisfied, + ) + .expect("manifest"); + assert_eq!(manifest.contract_version(), 1); + assert_eq!( + manifest.shared_manifest_contract_id(), + "radroots.trade.evidence-manifest.v1" + ); + assert_eq!(manifest.shared_manifest_contract_version(), 1); + assert_eq!(manifest.trade_id(), &TradeId::from_bytes([0x11; 16])); + assert_eq!(manifest.trade_generation(), 1); + assert_eq!(manifest.observed_at_unix_seconds(), 1_784_347_203); + assert_eq!( + (manifest.source_count(), manifest.observation_count()), + (1, 1) + ); + assert_eq!( + manifest.digest(), + [ + 0x0b, 0x19, 0x3e, 0xd2, 0x93, 0x56, 0xd6, 0x3d, 0x37, 0x31, 0x63, 0x4b, 0x37, 0xfe, + 0x1f, 0x5d, 0x53, 0x21, 0x48, 0x74, 0x79, 0x3d, 0xc2, 0x3f, 0xe4, 0xa3, 0xb9, 0x84, + 0xf8, 0xa4, 0x51, 0xc2, + ] + ); + let canonical_manifest = manifest.canonical_bytes().to_vec(); let reconciled = attempts - .commit_source_replays(lease, plan, [retry_replay]) + .commit_source_replays(lease, plan.clone(), [retry_replay]) .await .expect("idempotent reconcile"); assert!(!reconciled.created()); assert_eq!(reconciled.source_result_count(), 1); assert_eq!(reconciled.checkpoint_advance_count(), 1); assert!(!reconciled.dirty_generation_advanced()); + let reconciled_manifest = reconciled + .into_evidence_manifest( + UnixTimeSeconds::new(1_784_347_203), + RhiReconciliationScopePrerequisites::Satisfied, + ) + .expect("idempotent manifest"); + assert_eq!(reconciled_manifest.canonical_bytes(), canonical_manifest); + let too_early = attempts + .commit_source_replays(lease, plan, [early_replay]) + .await + .expect("second idempotent reconcile") + .into_evidence_manifest( + UnixTimeSeconds::new(1_784_347_201), + RhiReconciliationScopePrerequisites::Unsatisfied, + ) + .expect_err("observation precedes source completion"); + assert_eq!( + too_early.kind(), + rhi::RhiReconciliationManifestErrorKind::InvalidObservationTime + ); host.close().await.expect("close"); let options = SqliteConnectOptions::new() diff --git a/tests/services_hardening_reconciliation_manifest_contract.rs b/tests/services_hardening_reconciliation_manifest_contract.rs @@ -0,0 +1,133 @@ +#![forbid(unsafe_code)] + +use rhi::RHI_RECONCILIATION_MANIFEST_CONTRACT_VERSION; +use serde_json::json; + +const CONTRACT: &str = + include_str!("../contracts/services_hardening/reconciliation_manifest.v1.json"); +const ROOT: &str = include_str!("../src/lib.rs"); +const SOURCE: &str = include_str!("../src/reconciliation_manifest.rs"); +const COMMIT: &str = include_str!("../src/reconciliation_commit.rs"); +const README: &str = include_str!("../README"); + +#[test] +fn machine_contract_freezes_the_complete_step_191_boundary() { + let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract"); + assert_eq!(contract["schema"], "radroots.rhi.reconciliation-manifest"); + assert_eq!(contract["schema_version"], 1); + assert_eq!( + contract["contract_version"], + RHI_RECONCILIATION_MANIFEST_CONTRACT_VERSION + ); + assert_eq!( + contract["construction_authority"], + "confirmed_step_190_commit_outcome_only" + ); + assert_eq!( + contract["shared_manifest"]["contract_id"], + "radroots.trade.evidence-manifest.v1" + ); + assert_eq!(contract["shared_manifest"]["source_count_maximum"], 16); + assert_eq!( + contract["shared_manifest"]["observation_count_maximum"], + 65_536 + ); + assert_eq!( + contract["identity"]["source_selector_digest"], + "bound_inside_each_source_result_digest" + ); + assert_eq!( + contract["source_result_digest"]["ordered_fields"], + json!([ + "attempt_id", + "canonical_source_ordinal_u32_be", + "request_id", + "framed_source_id_utf8", + "trade_id", + "required_flag", + "evidence_policy_digest", + "source_selector_digest", + "replay_id", + "framed_detailed_completion_code_utf8", + "started_at_unix_ms_u64_be", + "finished_at_unix_ms_u64_be", + "accepted_event_count_u32_be", + "accepted_event_bytes_u64_be", + "step_190_accepted_inventory_digest", + "duplicate_observation_count_u32_be", + "optional_first_observed_at_unix_s", + "optional_prior_cursor", + "overlap_seconds_u64_be", + "inclusive_since_unix_s_u64_be", + "optional_cursor_candidate", + "checkpoint_eligible_flag" + ]) + ); + assert_eq!(contract["effects"]["sqlite"], false); + assert_eq!(contract["effects"]["ambient_clock"], false); + assert_eq!(contract["effects"]["ambient_entropy"], false); +} + +#[test] +fn manifest_boundary_is_sealed_canonical_redacted_and_effect_free() { + assert!(ROOT.contains("mod reconciliation_manifest;")); + assert!(!ROOT.contains("pub mod reconciliation_manifest;")); + for required in [ + "RhiReconciliationManifest", + "RhiReconciliationManifestError", + "RhiReconciliationManifestErrorKind", + "RhiReconciliationScopePrerequisites", + "RHI_RECONCILIATION_MANIFEST_CONTRACT_VERSION", + ] { + assert!(ROOT.contains(required), "root API is missing {required}"); + } + for required in [ + "RadrootsTradeEvidenceManifestV1::new(", + "SOURCE_RESULT_DIGEST_DOMAIN", + "PROVENANCE_DIGEST_DOMAIN", + "SOURCE_SELECTOR", + "committed_inventory_digest(part)", + "RadrootsTradeSignedEventDigestV1::sha256(&record.canonical_event_json)", + "self.manifest_material", + "i64::try_from(observed_at.get())", + "pub const fn shared_manifest_contract_id(&self)", + "pub const fn shared_manifest_contract_version(&self)", + ] { + assert!( + SOURCE.contains(required), + "manifest boundary is missing {required}" + ); + } + assert!(COMMIT.contains("let manifest_material = committed_manifest_material(&plan, &parts)")); + assert!(COMMIT.contains("manifest_material,")); + assert!( + COMMIT + .find("let manifest_material = committed_manifest_material(&plan, &parts)") + .expect("pure manifest derivation") + < COMMIT + .find(".transaction(move |transaction|") + .expect("transaction boundary") + ); + for forbidden in [ + "sqlx::", + "std::fs", + "std::net", + "tokio::", + "SystemTime", + "thread_rng", + "OsRng", + "from_canonical_bytes", + "pub fn new(", + "pub const fn new(", + "pub fn persist", + ] { + assert!( + !SOURCE.contains(forbidden), + "manifest boundary gained forbidden authority {forbidden}" + ); + } + assert!(README.contains("## Immutable reconciliation manifest")); + assert!(README.contains( + "[`reconciliation_manifest.v1.json`](contracts/services_hardening/reconciliation_manifest.v1.json)" + )); +}