rhi

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

commit 253c072f820c6a71f4fec4187a1537227196176a
parent f03279a8e7f7bd27f234bc0e0a97efeeb9e4bfe5
Author: triesap <tyson@radroots.org>
Date:   Mon, 24 Aug 2026 08:43:13 +0000

rhi: reduce sealed reconciliation manifests

- bind the shared reducer to confirmed manifest material
- retain bounded immutable mutation content without public exposure
- freeze projection digests and redacted failure classifications
- qualify reducer contracts, API surface, and persistence compatibility

Diffstat:
MAGENTS.md | 6++++++
MREADME | 17+++++++++++++++++
Mcontracts/api_baselines/rhi.txt | 29+++++++++++++++++++++++++++++
Mcontracts/services_hardening/reconciliation_manifest.v1.json | 12+++++++++++-
Acontracts/services_hardening/reconciliation_reducer.v1.json | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 6++++++
Msrc/reconciliation_manifest.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++--
Asrc/reconciliation_reducer.rs | 358+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/state_trade.rs | 6+++---
Mtests/package_boundary.rs | 51++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/services_hardening_reconciliation_jobs.rs | 29++++++++++++++++++++++++++++-
Mtests/services_hardening_reconciliation_manifest_contract.rs | 18++++++++++++++++++
Atests/services_hardening_reconciliation_reducer_contract.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 778 insertions(+), 8 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -143,6 +143,12 @@ - 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. +- Reduce only the sealed owned reconciliation manifest. Retain its bounded + canonical mutation material privately from the confirmed Step 190 commit, + map its already-governed evidence coverage into the shared reducer input, + and bind the canonical shared projection digest to the exact manifest and + evidence-policy digests. Do not accept caller mutation material or add + SQLite, filesystem, source, relay, task, clock, entropy, or network access. - 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 @@ -249,6 +249,23 @@ lease expiry/reclaim, queue and result bounds, stale leases/generations, and durable reopen behavior without adding runtime, scheduler, source, or network authority. +## Pure reconciliation reducer + +[`reconciliation_reducer.v1.json`](contracts/services_hardening/reconciliation_reducer.v1.json) +binds the promoted shared `radroots.trade.reducer.v1` to one sealed confirmed +reconciliation manifest. The manifest privately retains only the bounded +canonical mutation material derived from its Step 190 commit; callers cannot +inject or replace reducer inputs. Reduction consumes the owned manifest, +retains it inside a sealed projection, and binds the shared projection digest +to the manifest and evidence-policy digests with a separate domain-separated +RHI digest. The projection retains the canonical shared result privately for +later checkpoints while exposing only bounded identity, digest, and count +evidence. +Reduction performs no SQLite, filesystem, source, relay, network, task, clock, +or entropy operation. Final four-state coverage, three-state outcome, +generation-fenced persistence, reports, attestations, and publication retain +their later checkpoint owners. + ## 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 @@ -183,6 +183,11 @@ pub rhi::RhiReconciliationManifestErrorKind::InvalidCommittedInventory pub rhi::RhiReconciliationManifestErrorKind::InvalidObservationTime impl rhi::RhiReconciliationManifestErrorKind pub const fn rhi::RhiReconciliationManifestErrorKind::code(self) -> &'static str +pub enum rhi::RhiReconciliationReducerErrorKind +pub rhi::RhiReconciliationReducerErrorKind::InvalidManifest +pub rhi::RhiReconciliationReducerErrorKind::ProjectionUnavailable +impl rhi::RhiReconciliationReducerErrorKind +pub const fn rhi::RhiReconciliationReducerErrorKind::code(self) -> &'static str pub enum rhi::RhiReconciliationReplayErrorKind pub rhi::RhiReconciliationReplayErrorKind::InvalidConfiguration pub rhi::RhiReconciliationReplayErrorKind::InvalidInput @@ -742,6 +747,28 @@ 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::RhiReconciliationProjection +impl rhi::RhiReconciliationProjection +pub const fn rhi::RhiReconciliationProjection::contract_version(&self) -> u32 +pub const fn rhi::RhiReconciliationProjection::digest(&self) -> [u8; 32] +pub fn rhi::RhiReconciliationProjection::issue_count(&self) -> usize +pub const fn rhi::RhiReconciliationProjection::manifest(&self) -> &rhi::RhiReconciliationManifest +pub const fn rhi::RhiReconciliationProjection::root_mutation_id(&self) -> core::option::Option<&radroots_event::id::MutationId> +pub const fn rhi::RhiReconciliationProjection::shared_projection_digest(&self) -> [u8; 32] +pub const fn rhi::RhiReconciliationProjection::shared_reducer_contract_id(&self) -> &'static str +pub const fn rhi::RhiReconciliationProjection::shared_reducer_contract_version(&self) -> u16 +pub const fn rhi::RhiReconciliationProjection::trade_id(&self) -> &radroots_event::id::TradeId +impl core::fmt::Debug for rhi::RhiReconciliationProjection +pub fn rhi::RhiReconciliationProjection::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiReconciliationReducerError +impl rhi::RhiReconciliationReducerError +pub const fn rhi::RhiReconciliationReducerError::code(self) -> &'static str +pub const fn rhi::RhiReconciliationReducerError::kind(self) -> rhi::RhiReconciliationReducerErrorKind +impl core::error::Error for rhi::RhiReconciliationReducerError +impl core::fmt::Debug for rhi::RhiReconciliationReducerError +pub fn rhi::RhiReconciliationReducerError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for rhi::RhiReconciliationReducerError +pub fn rhi::RhiReconciliationReducerError::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 @@ -1225,6 +1252,7 @@ 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_REDUCER_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 @@ -1288,6 +1316,7 @@ pub async fn rhi::open_rhi_state_read_write_from_config(&rhi::RhiRuntimeContext, pub fn rhi::parse_rhi_cli_v1_from<I, T>(I) -> core::result::Result<rhi::RhiCliInvocationV1, rhi::RhiCliV1Error> where I: core::iter::traits::collect::IntoIterator<Item = T>, T: core::convert::Into<std::ffi::os_str::OsString> + core::clone::Clone pub fn rhi::parse_rhi_config_v1(&[u8], rhi::RhiConfigProfile) -> core::result::Result<rhi::RhiConfigDocumentV1, rhi::RhiConfigV1Error> pub fn rhi::provision_rhi_encrypted_identity(&rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential, rhi::RhiEncryptedIdentityProvisioningMaterial) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError> +pub fn rhi::reduce_rhi_reconciliation_manifest(rhi::RhiReconciliationManifest) -> core::result::Result<rhi::RhiReconciliationProjection, rhi::RhiReconciliationReducerError> pub fn rhi::resolve_rhi_runtime_context(&radroots_runtime_paths::roots::RadrootsPathResolver, &rhi::RhiCliInvocationV1) -> core::result::Result<rhi::RhiRuntimeContext, rhi::RhiRuntimeContextError> pub fn rhi::resolve_rhi_wrapping_credential(&rhi::RhiRuntimeContext, &rhi::RhiIdentityEnvelopeBinding) -> core::result::Result<rhi::RhiWrappingCredential, rhi::RhiCredentialResolutionError> pub fn rhi::rhi_migration_catalog() -> core::result::Result<radroots_service_sqlite::migration::MigrationCatalog, rhi::RhiStateCatalogError> diff --git a/contracts/services_hardening/reconciliation_manifest.v1.json b/contracts/services_hardening/reconciliation_manifest.v1.json @@ -74,6 +74,17 @@ "debug": "counts_only_redacted", "errors": "crate_owned_source_free_redacted" }, + "private_reducer_material": { + "construction": "deduplicated_from_confirmed_step_190_canonical_mutation_content", + "ordering": "mutation_id_ascending", + "representative_event": "smallest_event_id_for_mutation", + "maximum_mutations": 65536, + "maximum_canonical_content_bytes": 134217728, + "storage": "immutable_reference_counted_bytes", + "canonical_manifest_wire_changed": false, + "public_raw_access": false, + "reducer_owner": "reconciliation_reducer.v1.json" + }, "effects": { "sqlite": false, "filesystem": false, @@ -94,7 +105,6 @@ ], "deferred": [ "wave_qualification", - "lineage_reducer", "coverage_and_outcome_projection", "manifest_persistence", "attestation", diff --git a/contracts/services_hardening/reconciliation_reducer.v1.json b/contracts/services_hardening/reconciliation_reducer.v1.json @@ -0,0 +1,81 @@ +{ + "schema": "radroots.rhi.reconciliation-reducer", + "schema_version": 1, + "contract_version": 1, + "input": { + "authority": "sealed_confirmed_reconciliation_manifest_only", + "consumption": "owned_manifest_retained_inside_projection", + "mutation_material": "private_canonical_content_bound_to_manifest_observations", + "mutation_order": "mutation_id_ascending", + "representative_event": "smallest_event_id_for_mutation", + "maximum_mutations": 65536, + "maximum_canonical_content_bytes": 134217728, + "private_terms": "none_without_explicit_manifest_evidence", + "prior_attestations": "none_without_explicit_manifest_evidence", + "observed_time": "exact_manifest_observed_at_unix_seconds", + "evidence_state": { + "missing": "missing", + "partial": "query_partial", + "scope_satisfied": "complete", + "unsupported": "unsupported_version" + } + }, + "shared_reducer": { + "contract_id": "radroots.trade.reducer.v1", + "contract_version": 1, + "canonical_projection": "radroots_trade_projection_v1", + "projection_digest": "shared_domain_separated_lowercase_hex_sha256" + }, + "projection_digest": { + "algorithm": "sha256", + "domain_utf8_then_nul": "radroots.rhi.reconciliation_projection.v1", + "ordered_fields": [ + "rhi_reducer_contract_version_u32_be", + "shared_reducer_contract_id_length_u64_be", + "shared_reducer_contract_id_utf8", + "shared_reducer_contract_version_u16_be", + "evidence_manifest_digest", + "evidence_policy_digest", + "shared_projection_digest" + ] + }, + "result": { + "sealed": true, + "caller_forgeable": false, + "retains_manifest": true, + "shared_projection_public_access": false, + "shared_projection_retained_for_later_steps": true, + "shared_projection_digest_available": true, + "rhi_projection_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": [ + "raw_manifest_constructor", + "caller_supplied_mutation_material", + "database_lookup", + "source_or_relay_lookup", + "arrival_order_selected_truth", + "unbound_projection_digest", + "final_coverage_or_outcome_authority", + "projection_persistence", + "report_or_attestation_construction" + ], + "deferred": [ + "final_coverage_and_outcome", + "generation_fenced_commit", + "report_and_attestation", + "manifest_and_projection_persistence", + "publication", + "job_finalization" + ] +} diff --git a/src/lib.rs b/src/lib.rs @@ -12,6 +12,7 @@ mod reconciliation_attempt; mod reconciliation_commit; mod reconciliation_job; mod reconciliation_manifest; +mod reconciliation_reducer; mod reconciliation_replay; mod runtime_adapters; mod runtime_context; @@ -94,6 +95,11 @@ pub use reconciliation_manifest::{ RhiReconciliationManifestError, RhiReconciliationManifestErrorKind, RhiReconciliationScopePrerequisites, }; +pub use reconciliation_reducer::{ + RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION, RhiReconciliationProjection, + RhiReconciliationReducerError, RhiReconciliationReducerErrorKind, + reduce_rhi_reconciliation_manifest, +}; pub use reconciliation_replay::{ RHI_RECONCILIATION_REPLAY_CONTRACT_VERSION, RhiReconciliationReplayError, RhiReconciliationReplayErrorKind, RhiReconciliationSourceCursorEvidence, diff --git a/src/reconciliation_manifest.rs b/src/reconciliation_manifest.rs @@ -1,7 +1,7 @@ //! Immutable manifest materialization from one confirmed reconciliation commit. use core::{fmt, num::NonZeroU64}; -use std::error::Error; +use std::{collections::BTreeMap, error::Error, sync::Arc}; use radroots_event::id::{EventId, MutationId, TradeId}; use radroots_service_host::UnixTimeSeconds; @@ -30,6 +30,8 @@ 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"; +pub(crate) const RHI_REDUCER_MAXIMUM_MUTATIONS: usize = 65_536; +pub(crate) const RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES: usize = 134_217_728; /// Exact non-source prerequisite state bound into one manifest. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -111,6 +113,7 @@ impl Error for RhiReconciliationManifestError {} /// ``` pub struct RhiReconciliationManifest { inner: RadrootsTradeEvidenceManifestV1, + reducer_mutations: Box<[RhiReducerMutationMaterial]>, } impl RhiReconciliationManifest { @@ -173,6 +176,14 @@ impl RhiReconciliationManifest { pub fn observation_count(&self) -> usize { self.inner.observations().len() } + + pub(crate) const fn inner(&self) -> &RadrootsTradeEvidenceManifestV1 { + &self.inner + } + + pub(crate) fn reducer_mutations(&self) -> &[RhiReducerMutationMaterial] { + &self.reducer_mutations + } } impl fmt::Debug for RhiReconciliationManifest { @@ -207,6 +218,13 @@ pub(crate) struct RhiCommittedManifestMaterial { latest_finished_unix_ms: u64, sources: Box<[RadrootsTradeEvidenceManifestSourceResultV1]>, observations: Box<[RadrootsTradeEvidenceManifestObservationV1]>, + reducer_mutations: Box<[RhiReducerMutationMaterial]>, +} + +pub(crate) struct RhiReducerMutationMaterial { + pub(crate) mutation_id: MutationId, + pub(crate) event_id: EventId, + pub(crate) canonical_content: Arc<[u8]>, } pub(crate) fn committed_manifest_material( @@ -223,6 +241,7 @@ pub(crate) fn committed_manifest_material( total.checked_add(part.facts.len()).ok_or(()) })?; let mut observations = Vec::with_capacity(observation_capacity); + let mut reducer_mutations = BTreeMap::<[u8; 32], RhiReducerMutationMaterial>::new(); for (ordinal, part) in parts.iter().enumerate() { if part.trade_id != trade_id @@ -252,8 +271,31 @@ pub(crate) fn committed_manifest_material( )); for fact in &part.facts { observations.push(manifest_observation(source_id.clone(), part, fact)?); + reducer_mutations + .entry(fact.record.mutation_id) + .and_modify(|current| { + if fact.record.event_id < *current.event_id.as_bytes() { + current.event_id = EventId::from_bytes(fact.record.event_id); + current.canonical_content = fact.record.canonical_content.clone(); + } + }) + .or_insert_with(|| RhiReducerMutationMaterial { + mutation_id: MutationId::from_bytes(fact.record.mutation_id), + event_id: EventId::from_bytes(fact.record.event_id), + canonical_content: fact.record.canonical_content.clone(), + }); } } + if reducer_mutations.len() > RHI_REDUCER_MAXIMUM_MUTATIONS + || reducer_mutations + .values() + .try_fold(0_usize, |total, material| { + total.checked_add(material.canonical_content.len()) + }) + .is_none_or(|total| total > RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES) + { + return Err(()); + } Ok(RhiCommittedManifestMaterial { trade_id, @@ -262,6 +304,10 @@ pub(crate) fn committed_manifest_material( latest_finished_unix_ms, sources: sources.into_boxed_slice(), observations: observations.into_boxed_slice(), + reducer_mutations: reducer_mutations + .into_values() + .collect::<Vec<_>>() + .into_boxed_slice(), }) } @@ -293,7 +339,10 @@ fn freeze_manifest( material.observations.into_vec(), ) .map_err(|_| error(RhiReconciliationManifestErrorKind::InvalidCommittedInventory))?; - Ok(RhiReconciliationManifest { inner }) + Ok(RhiReconciliationManifest { + inner, + reducer_mutations: material.reducer_mutations, + }) } fn map_completion(value: RhiTradeSourceCompletion) -> RadrootsTradeEvidenceSourceCompletionV1 { diff --git a/src/reconciliation_reducer.rs b/src/reconciliation_reducer.rs @@ -0,0 +1,358 @@ +//! Pure deterministic reduction of one sealed reconciliation manifest. + +use core::fmt; +use std::{collections::BTreeSet, error::Error}; + +use radroots_event::{ + id::{MutationId, TradeId}, + trade::trade_mutation_from_canonical_content, +}; +use radroots_trade::{ + evidence::{ + RadrootsTradeEvidenceCoverageV1, RadrootsTradeEvidenceStateV1, + RadrootsTradeMutationRecordV1, + }, + model::RadrootsTradeProjectionV1, + reducer::{ + RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION, + RadrootsTradeReductionInputV1, reduce_trade_records, + }, +}; +use sha2::{Digest, Sha256}; + +use crate::{ + RhiReconciliationManifest, + reconciliation_manifest::{ + RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES, RHI_REDUCER_MAXIMUM_MUTATIONS, + RhiReducerMutationMaterial, + }, +}; + +/// Exact version of the RHI reconciliation-reducer binding. +pub const RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION: u32 = 1; + +const PROJECTION_DIGEST_DOMAIN: &[u8] = b"radroots.rhi.reconciliation_projection.v1\0"; + +/// Stable source-free reconciliation-reducer failure class. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiReconciliationReducerErrorKind { + InvalidManifest, + ProjectionUnavailable, +} + +impl RhiReconciliationReducerErrorKind { + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidManifest => "reconciliation_reducer_manifest_invalid", + Self::ProjectionUnavailable => "reconciliation_reducer_projection_unavailable", + } + } +} + +/// Redacted source-free reconciliation-reducer failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiReconciliationReducerError { + kind: RhiReconciliationReducerErrorKind, +} + +impl RhiReconciliationReducerError { + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> RhiReconciliationReducerErrorKind { + 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 RhiReconciliationReducerError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + RhiReconciliationReducerErrorKind::InvalidManifest => { + "RHI reconciliation manifest cannot be reduced" + } + RhiReconciliationReducerErrorKind::ProjectionUnavailable => { + "RHI reconciliation projection is unavailable" + } + }) + } +} + +impl fmt::Debug for RhiReconciliationReducerError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiReconciliationReducerError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for RhiReconciliationReducerError {} + +/// Sealed deterministic projection retaining its exact manifest capability. +/// +/// Callers cannot construct or relabel a projection. +/// +/// ```compile_fail +/// use rhi::RhiReconciliationProjection; +/// +/// let _forged = RhiReconciliationProjection {}; +/// ``` +pub struct RhiReconciliationProjection { + manifest: RhiReconciliationManifest, + shared: RadrootsTradeProjectionV1, + shared_projection_digest: [u8; 32], + digest: [u8; 32], +} + +impl RhiReconciliationProjection { + /// Returns the exact RHI reducer-binding contract version. + #[must_use] + pub const fn contract_version(&self) -> u32 { + RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION + } + + /// Returns the exact shared reducer contract ID. + #[must_use] + pub const fn shared_reducer_contract_id(&self) -> &'static str { + RADROOTS_TRADE_REDUCER_CONTRACT_ID + } + + /// Returns the exact shared reducer contract version. + #[must_use] + pub const fn shared_reducer_contract_version(&self) -> u16 { + RADROOTS_TRADE_REDUCER_VERSION + } + + /// Returns the sealed manifest consumed by this projection. + #[must_use] + pub const fn manifest(&self) -> &RhiReconciliationManifest { + &self.manifest + } + + /// Returns the exact trade selected by the immutable manifest. + #[must_use] + pub const fn trade_id(&self) -> &TradeId { + self.manifest.trade_id() + } + + /// Returns the exact shared projection digest decoded from lowercase hex. + #[must_use] + pub const fn shared_projection_digest(&self) -> [u8; 32] { + self.shared_projection_digest + } + + /// Returns the domain-separated RHI projection digest. + #[must_use] + pub const fn digest(&self) -> [u8; 32] { + self.digest + } + + /// Returns the number of canonical shared reducer issues. + #[must_use] + pub fn issue_count(&self) -> usize { + self.shared.issues().len() + } + + /// Returns the selected root mutation when the manifest establishes one. + #[must_use] + pub const fn root_mutation_id(&self) -> Option<&MutationId> { + self.shared.root_mutation_id() + } +} + +impl fmt::Debug for RhiReconciliationProjection { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiReconciliationProjection") + .field("source_count", &self.manifest.source_count()) + .field("observation_count", &self.manifest.observation_count()) + .field("issue_count", &self.issue_count()) + .finish_non_exhaustive() + } +} + +/// Reduces one sealed immutable reconciliation manifest without external I/O. +pub fn reduce_rhi_reconciliation_manifest( + manifest: RhiReconciliationManifest, +) -> Result<RhiReconciliationProjection, RhiReconciliationReducerError> { + let inner = manifest.inner(); + if !mutation_material_within_bounds(manifest.reducer_mutations()) { + return Err(failure(RhiReconciliationReducerErrorKind::InvalidManifest)); + } + let observation_inventory = inner + .observations() + .iter() + .map(|observation| (*observation.mutation_id(), *observation.event_id())) + .collect::<BTreeSet<_>>(); + if inner.observations().iter().any(|observation| { + manifest + .reducer_mutations() + .binary_search_by_key(observation.mutation_id(), |material| material.mutation_id) + .is_err() + }) { + return Err(failure(RhiReconciliationReducerErrorKind::InvalidManifest)); + } + + let mut mutations = Vec::with_capacity(manifest.reducer_mutations().len()); + for material in manifest.reducer_mutations() { + if !observation_inventory.contains(&(material.mutation_id, material.event_id)) { + return Err(failure(RhiReconciliationReducerErrorKind::InvalidManifest)); + } + let content = core::str::from_utf8(&material.canonical_content) + .map_err(|_| failure(RhiReconciliationReducerErrorKind::InvalidManifest))?; + let mutation = trade_mutation_from_canonical_content(content) + .map_err(|_| failure(RhiReconciliationReducerErrorKind::InvalidManifest))?; + if mutation.mutation_id != Some(material.mutation_id) + || mutation.trade_id != *inner.trade_id() + { + return Err(failure(RhiReconciliationReducerErrorKind::InvalidManifest)); + } + mutations.push(RadrootsTradeMutationRecordV1::new( + Some(material.event_id), + mutation, + )); + } + + let input = RadrootsTradeReductionInputV1::new(*inner.trade_id()) + .with_mutations(mutations) + .with_evidence_state(evidence_state(inner.coverage())) + .with_observed_at_unix_s(Some(inner.observed_at_unix_s())); + let shared = reduce_trade_records(input); + if shared.reducer_contract_id() != RADROOTS_TRADE_REDUCER_CONTRACT_ID + || shared.reducer_version() != RADROOTS_TRADE_REDUCER_VERSION + || shared.trade_id() != inner.trade_id() + { + return Err(failure( + RhiReconciliationReducerErrorKind::ProjectionUnavailable, + )); + } + let shared_projection_digest = decode_lower_hex_32(shared.projection_digest()) + .ok_or_else(|| failure(RhiReconciliationReducerErrorKind::ProjectionUnavailable))?; + let digest = projection_digest(&manifest, shared_projection_digest) + .ok_or_else(|| failure(RhiReconciliationReducerErrorKind::ProjectionUnavailable))?; + Ok(RhiReconciliationProjection { + manifest, + shared, + shared_projection_digest, + digest, + }) +} + +fn mutation_material_within_bounds(materials: &[RhiReducerMutationMaterial]) -> bool { + material_lengths_within_bounds( + materials.len(), + materials + .iter() + .map(|material| material.canonical_content.len()), + ) +} + +fn material_lengths_within_bounds<I>(count: usize, lengths: I) -> bool +where + I: IntoIterator<Item = usize>, +{ + count <= RHI_REDUCER_MAXIMUM_MUTATIONS + && lengths + .into_iter() + .try_fold(0_usize, usize::checked_add) + .is_some_and(|total| total <= RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES) +} + +fn evidence_state(coverage: RadrootsTradeEvidenceCoverageV1) -> RadrootsTradeEvidenceStateV1 { + match coverage { + RadrootsTradeEvidenceCoverageV1::Missing => RadrootsTradeEvidenceStateV1::Missing, + RadrootsTradeEvidenceCoverageV1::Partial => RadrootsTradeEvidenceStateV1::QueryPartial, + RadrootsTradeEvidenceCoverageV1::ScopeSatisfied => RadrootsTradeEvidenceStateV1::Complete, + RadrootsTradeEvidenceCoverageV1::Unsupported => { + RadrootsTradeEvidenceStateV1::UnsupportedVersion + } + } +} + +fn projection_digest(manifest: &RhiReconciliationManifest, shared: [u8; 32]) -> Option<[u8; 32]> { + let inner = manifest.inner(); + let mut digest = Sha256::new(); + digest.update(PROJECTION_DIGEST_DOMAIN); + digest.update(RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION.to_be_bytes()); + digest.update( + u64::try_from(RADROOTS_TRADE_REDUCER_CONTRACT_ID.len()) + .ok()? + .to_be_bytes(), + ); + digest.update(RADROOTS_TRADE_REDUCER_CONTRACT_ID.as_bytes()); + digest.update(RADROOTS_TRADE_REDUCER_VERSION.to_be_bytes()); + digest.update(manifest.digest()); + digest.update(inner.evidence_policy_digest().as_bytes()); + digest.update(shared); + Some(digest.finalize().into()) +} + +fn decode_lower_hex_32(value: &str) -> Option<[u8; 32]> { + if value.len() != 64 { + return None; + } + let mut output = [0_u8; 32]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + output[index] = decode_lower_hex(pair[0])? + .checked_mul(16)? + .checked_add(decode_lower_hex(pair[1])?)?; + } + Some(output) +} + +const fn decode_lower_hex(value: u8) -> Option<u8> { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + _ => None, + } +} + +const fn failure(kind: RhiReconciliationReducerErrorKind) -> RhiReconciliationReducerError { + RhiReconciliationReducerError { kind } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn diagnostics_and_digest_decoder_are_closed() { + for kind in [ + RhiReconciliationReducerErrorKind::InvalidManifest, + RhiReconciliationReducerErrorKind::ProjectionUnavailable, + ] { + let error = failure(kind); + assert_eq!(error.kind(), kind); + assert!(Error::source(&error).is_none()); + assert!(!format!("{error} {error:?}").contains("trade-primary")); + } + assert_eq!(decode_lower_hex_32(&"ab".repeat(32)), Some([0xab; 32])); + assert_eq!(decode_lower_hex_32(&"AB".repeat(32)), None); + assert_eq!(decode_lower_hex_32("00"), None); + } + + #[test] + fn mutation_material_length_and_count_bounds_are_exact() { + assert!(material_lengths_within_bounds( + RHI_REDUCER_MAXIMUM_MUTATIONS, + [RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES] + )); + assert!(!material_lengths_within_bounds( + RHI_REDUCER_MAXIMUM_MUTATIONS, + [RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES, 1] + )); + assert!(!material_lengths_within_bounds( + RHI_REDUCER_MAXIMUM_MUTATIONS + 1, + core::iter::empty() + )); + assert!(!material_lengths_within_bounds(1, [usize::MAX, 1])); + } +} diff --git a/src/state_trade.rs b/src/state_trade.rs @@ -1,7 +1,7 @@ //! Atomic immutable persistence for admitted trade-event evidence. use core::fmt; -use std::error::Error; +use std::{error::Error, sync::Arc}; use radroots_service_sqlite::{ ServiceSqliteTransaction, ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind, @@ -316,7 +316,7 @@ pub(crate) struct PersistenceRecord { pub(crate) author_pubkey: [u8; 32], pub(crate) event_kind: u32, pub(crate) authored_at_unix_s: u64, - pub(crate) canonical_content: Box<[u8]>, + pub(crate) canonical_content: Arc<[u8]>, pub(crate) canonical_event_json: Box<[u8]>, } @@ -345,7 +345,7 @@ impl PersistenceRecord { author_pubkey: *event.author().as_bytes(), event_kind: event.kind_u32(), authored_at_unix_s: event.created_at_u64(), - canonical_content: canonical_content.into_boxed_slice(), + canonical_content: canonical_content.into(), canonical_event_json: canonical_event_json.into_boxed_slice(), }) } diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -14,6 +14,7 @@ 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_REDUCER: &str = include_str!("../src/reconciliation_reducer.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 = @@ -24,6 +25,8 @@ 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 RECONCILIATION_REDUCER_CONTRACT: &str = + include_str!("../contracts/services_hardening/reconciliation_reducer.v1.json"); const RUNTIME_FOUNDATION_CONTRACT: &str = include_str!("../contracts/services_hardening/runtime_foundation.v1.json"); const TRADE_INGEST_CONTRACT: &str = @@ -44,6 +47,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/reconciliation_commit.rs"), include_str!("../src/reconciliation_job.rs"), include_str!("../src/reconciliation_manifest.rs"), + include_str!("../src/reconciliation_reducer.rs"), include_str!("../src/reconciliation_replay.rs"), include_str!("../src/runtime_context.rs"), include_str!("../src/runtime_adapters.rs"), @@ -115,6 +119,7 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "reconciliation_commit", "reconciliation_job", "reconciliation_manifest", + "reconciliation_reducer", "reconciliation_replay", "runtime_context", "runtime_adapters", @@ -160,6 +165,9 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "RhiReconciliationManifest", "RhiReconciliationManifestErrorKind", "RhiReconciliationScopePrerequisites", + "RhiReconciliationProjection", + "RhiReconciliationReducerErrorKind", + "reduce_rhi_reconciliation_manifest", "RhiReconciliationSourceReplayPlan", "RhiReconciliationSourceReplay", "RhiReconciliationJobPolicy", @@ -278,6 +286,43 @@ fn reconciliation_manifest_is_canonical_sealed_and_effect_free() { } #[test] +fn reconciliation_reducer_is_manifest_bound_sealed_and_effect_free() { + let contract: serde_json::Value = serde_json::from_str(RECONCILIATION_REDUCER_CONTRACT) + .expect("reconciliation-reducer contract"); + assert_eq!(contract["schema"], "radroots.rhi.reconciliation-reducer"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["input"]["maximum_mutations"], 65_536); + assert_eq!( + contract["input"]["maximum_canonical_content_bytes"], + 134_217_728 + ); + assert_eq!(contract["effects"]["sqlite"], false); + for required in [ + "trade_mutation_from_canonical_content", + "reduce_trade_records(input)", + "PROJECTION_DIGEST_DOMAIN", + "manifest.digest()", + "inner.evidence_policy_digest()", + "pub fn reduce_rhi_reconciliation_manifest(", + "RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES", + ] { + assert!( + RECONCILIATION_REDUCER.contains(required), + "reconciliation reducer is missing {required}" + ); + } + for forbidden in ["sqlx::", "std::fs", "std::net", "tokio::", "SystemTime"] { + assert!( + !RECONCILIATION_REDUCER.contains(forbidden), + "reconciliation reducer gained forbidden authority {forbidden}" + ); + } + assert!(!ROOT.contains("pub mod reconciliation_reducer")); + assert!(!PUBLIC_API.contains("rhi::reconciliation_reducer::")); + assert!(!PUBLIC_API.contains("RhiReconciliationProjection::shared_projection(&self)")); +} + +#[test] fn public_errors_are_crate_owned_redacted_and_source_free() { let production = SOURCES.join("\n"); assert!(!production.contains("fn source(")); @@ -309,7 +354,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, 21); + assert_eq!(public_error_count, 22); } #[test] @@ -729,6 +774,10 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() { "[`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)", + "## Pure reconciliation reducer", + "[`reconciliation_reducer.v1.json`](contracts/services_hardening/reconciliation_reducer.v1.json)", + "binds the promoted shared `radroots.trade.reducer.v1`", + "Final four-state coverage, three-state outcome", "The Step 192 integration-wave qualification proves that concurrent exact", "lost-success retry converges after close/reopen", "inventory terminates at configured source count plus one before mutation", diff --git a/tests/services_hardening_reconciliation_jobs.rs b/tests/services_hardening_reconciliation_jobs.rs @@ -17,7 +17,7 @@ use rhi::{ 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, + reduce_rhi_reconciliation_manifest, resolve_rhi_runtime_context, }; use sqlx::{Connection, SqliteConnection, sqlite::SqliteConnectOptions}; @@ -1052,6 +1052,33 @@ async fn source_replay_commit_is_atomic_idempotent_and_mints_durable_cursor_evid ] ); let canonical_manifest = manifest.canonical_bytes().to_vec(); + let projection = reduce_rhi_reconciliation_manifest(manifest).expect("pure projection"); + assert_eq!(projection.contract_version(), 1); + assert_eq!( + projection.shared_reducer_contract_id(), + "radroots.trade.reducer.v1" + ); + assert_eq!(projection.shared_reducer_contract_version(), 1); + assert_eq!(projection.trade_id(), &TradeId::from_bytes([0x11; 16])); + assert_eq!(projection.manifest().canonical_bytes(), canonical_manifest); + assert!(projection.root_mutation_id().is_some()); + assert_eq!(projection.issue_count(), 0); + assert_eq!( + projection.shared_projection_digest(), + [ + 0x21, 0xd5, 0xd5, 0xe6, 0x06, 0x7a, 0x13, 0x68, 0xd0, 0xd5, 0x25, 0xa3, 0xec, 0xd1, + 0xb5, 0xcc, 0x99, 0xcb, 0x03, 0xd7, 0xf8, 0x06, 0xe6, 0xba, 0x47, 0xd3, 0xb9, 0x29, + 0x99, 0xa7, 0xe9, 0x61, + ] + ); + assert_eq!( + projection.digest(), + [ + 0xd1, 0x33, 0xa7, 0x72, 0xd2, 0x87, 0xa2, 0x56, 0x4a, 0xb3, 0xb3, 0xb2, 0xca, 0xb6, + 0xdc, 0xa6, 0xe5, 0xc5, 0xa0, 0x7f, 0x30, 0x8f, 0x67, 0xc5, 0xee, 0x77, 0x38, 0x06, + 0x40, 0x7a, 0x03, 0x71, + ] + ); host.close() .await .expect("close before lost-success replay"); diff --git a/tests/services_hardening_reconciliation_manifest_contract.rs b/tests/services_hardening_reconciliation_manifest_contract.rs @@ -66,6 +66,24 @@ fn machine_contract_freezes_the_complete_step_191_boundary() { assert_eq!(contract["effects"]["sqlite"], false); assert_eq!(contract["effects"]["ambient_clock"], false); assert_eq!(contract["effects"]["ambient_entropy"], false); + assert_eq!( + contract["private_reducer_material"]["construction"], + "deduplicated_from_confirmed_step_190_canonical_mutation_content" + ); + assert_eq!( + contract["private_reducer_material"]["canonical_manifest_wire_changed"], + false + ); + assert_eq!( + contract["private_reducer_material"]["maximum_canonical_content_bytes"], + 134_217_728 + ); + assert!( + !contract["deferred"] + .as_array() + .expect("deferred inventory") + .contains(&json!("lineage_reducer")) + ); } #[test] diff --git a/tests/services_hardening_reconciliation_reducer_contract.rs b/tests/services_hardening_reconciliation_reducer_contract.rs @@ -0,0 +1,120 @@ +#![forbid(unsafe_code)] + +use rhi::RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION; +use serde_json::json; + +const CONTRACT: &str = + include_str!("../contracts/services_hardening/reconciliation_reducer.v1.json"); +const ROOT: &str = include_str!("../src/lib.rs"); +const SOURCE: &str = include_str!("../src/reconciliation_reducer.rs"); +const MANIFEST: &str = include_str!("../src/reconciliation_manifest.rs"); +const README: &str = include_str!("../README"); + +#[test] +fn machine_contract_freezes_the_complete_step_193_boundary() { + let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract"); + assert_eq!(contract["schema"], "radroots.rhi.reconciliation-reducer"); + assert_eq!(contract["schema_version"], 1); + assert_eq!( + contract["contract_version"], + RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION + ); + assert_eq!( + contract["input"]["authority"], + "sealed_confirmed_reconciliation_manifest_only" + ); + assert_eq!(contract["input"]["maximum_mutations"], 65_536); + assert_eq!( + contract["input"]["maximum_canonical_content_bytes"], + 134_217_728 + ); + assert_eq!( + contract["input"]["evidence_state"], + json!({ + "missing": "missing", + "partial": "query_partial", + "scope_satisfied": "complete", + "unsupported": "unsupported_version" + }) + ); + assert_eq!( + contract["shared_reducer"]["contract_id"], + "radroots.trade.reducer.v1" + ); + assert_eq!( + contract["projection_digest"]["ordered_fields"], + json!([ + "rhi_reducer_contract_version_u32_be", + "shared_reducer_contract_id_length_u64_be", + "shared_reducer_contract_id_utf8", + "shared_reducer_contract_version_u16_be", + "evidence_manifest_digest", + "evidence_policy_digest", + "shared_projection_digest" + ]) + ); + assert_eq!(contract["effects"]["sqlite"], false); + assert_eq!(contract["effects"]["source_or_relay"], false); + assert_eq!(contract["effects"]["ambient_clock"], false); + assert_eq!(contract["effects"]["ambient_entropy"], false); +} + +#[test] +fn reducer_boundary_is_sealed_redacted_and_effect_free() { + assert!(ROOT.contains("mod reconciliation_reducer;")); + assert!(!ROOT.contains("pub mod reconciliation_reducer;")); + for required in [ + "RhiReconciliationProjection", + "RhiReconciliationReducerError", + "RhiReconciliationReducerErrorKind", + "RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION", + "reduce_rhi_reconciliation_manifest", + ] { + assert!(ROOT.contains(required), "root API is missing {required}"); + } + for required in [ + "trade_mutation_from_canonical_content", + "reduce_trade_records(input)", + "PROJECTION_DIGEST_DOMAIN", + "manifest.digest()", + "inner.evidence_policy_digest()", + "decode_lower_hex_32(shared.projection_digest())", + "RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES", + ] { + assert!(SOURCE.contains(required), "reducer is missing {required}"); + } + assert!(!SOURCE.contains("fn shared_projection(&self)")); + for required in [ + "reducer_mutations: Box<[RhiReducerMutationMaterial]>", + "BTreeMap::<[u8; 32], RhiReducerMutationMaterial>::new()", + "RHI_REDUCER_MAXIMUM_MUTATION_MATERIAL_BYTES: usize = 134_217_728", + "canonical_content: Arc<[u8]>", + "canonical_content: fact.record.canonical_content.clone()", + ] { + assert!( + MANIFEST.contains(required), + "manifest reducer material is missing {required}" + ); + } + for forbidden in [ + "sqlx::", + "std::fs", + "std::net", + "tokio::", + "SystemTime", + "thread_rng", + "OsRng", + "pub fn new(", + "pub const fn new(", + "pub fn persist", + ] { + assert!( + !SOURCE.contains(forbidden), + "reducer gained forbidden authority {forbidden}" + ); + } + assert!(README.contains("## Pure reconciliation reducer")); + assert!(README.contains( + "[`reconciliation_reducer.v1.json`](contracts/services_hardening/reconciliation_reducer.v1.json)" + )); +}