rhi

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

commit ba67c988ee14f2cfd45057b54f4d9c88175090c0
parent 678cc851ab64930404fab50d1ff72e58c20580d8
Author: triesap <tyson@radroots.org>
Date:   Mon, 24 Aug 2026 09:32:53 +0000

refactor(rhi): fence reconciliation finalization

- bind sealed manifests to exact attempt and job identities
- validate live leases, dirty generations, policies, and commits
- require same-transaction revalidation before final writes
- qualify contracts, redaction, and the refreshed API surface

Diffstat:
MAGENTS.md | 4++++
MREADME | 19+++++++++++++++++++
Mcontracts/api_baselines/rhi.txt | 29+++++++++++++++++++++++++++++
Acontracts/services_hardening/reconciliation_finalization.v1.json | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/services_hardening/reconciliation_manifest.v1.json | 7+++++++
Msrc/lib.rs | 5+++++
Msrc/reconciliation_attempt.rs | 5++++-
Asrc/reconciliation_finalization.rs | 382+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/reconciliation_manifest.rs | 19++++++++++++++++++-
Mtests/package_boundary.rs | 57++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Atests/services_hardening_reconciliation_finalization_contract.rs | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_reconciliation_jobs.rs | 303++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/services_hardening_reconciliation_manifest_contract.rs | 14++++++++++++++
13 files changed, 1055 insertions(+), 5 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -163,6 +163,10 @@ - Fence final work by dirty generation and policy digest. A stale worker cannot overwrite newer evidence, and CAS loss leaves no partial report, outbox, source completion, checkpoint, or job finalization. +- Carry the exact committed attempt and job identities privately through the + sealed manifest, projection, and evaluation chain. A finalization preflight + is not commit authority: rerun its exact lease, generation, policy, and + attempt validator inside the Step 199 atomic transaction before any write. ## 6. Report, attestation, and publication invariants diff --git a/README b/README @@ -276,6 +276,25 @@ cancelled agreement claim is decisively invalid. Every evaluation retains its projection and exposes exactly one closed stable primary reason code without adding I/O or ambient authority. +## Generation-fenced finalization preflight + +`RhiReconciliationAttemptRepository::prepare_finalization` consumes one sealed +evaluation plus the exact live claimed lease. The manifest privately retains +its Step 190 attempt and job identities without changing canonical manifest +bytes or its digest, so an older attempt cannot be relabelled with a reclaimed +worker's distinct attempt authority. One bounded read-only SQLite transaction +checks the unexpired exact lease, current dirty generation, evidence-policy +digest, and immutable committed-attempt row before returning a sealed +`RhiReconciliationFinalizationFence`. + +The fence is a preflight capability, not durable commit proof: state may change +after it is returned. Step 199 must rerun the same validator inside the final +atomic transaction before its first write. This checkpoint writes no manifest, +projection, report, signed event, outbox, checkpoint, or job state and performs +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). + ## 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 @@ -159,6 +159,17 @@ pub rhi::RhiReconciliationCommitErrorKind::LeaseLost pub rhi::RhiReconciliationCommitErrorKind::Storage impl rhi::RhiReconciliationCommitErrorKind pub const fn rhi::RhiReconciliationCommitErrorKind::code(self) -> &'static str +pub enum rhi::RhiReconciliationFinalizationErrorKind +pub rhi::RhiReconciliationFinalizationErrorKind::AttemptUnavailable +pub rhi::RhiReconciliationFinalizationErrorKind::CommitOutcomeUnknown +pub rhi::RhiReconciliationFinalizationErrorKind::GenerationConflict +pub rhi::RhiReconciliationFinalizationErrorKind::InvalidInput +pub rhi::RhiReconciliationFinalizationErrorKind::InvalidMode +pub rhi::RhiReconciliationFinalizationErrorKind::LeaseLost +pub rhi::RhiReconciliationFinalizationErrorKind::ProjectionUnavailable +pub rhi::RhiReconciliationFinalizationErrorKind::Storage +impl rhi::RhiReconciliationFinalizationErrorKind +pub const fn rhi::RhiReconciliationFinalizationErrorKind::code(self) -> &'static str pub enum rhi::RhiReconciliationJobErrorKind pub rhi::RhiReconciliationJobErrorKind::CommitOutcomeUnknown pub rhi::RhiReconciliationJobErrorKind::DirtyGenerationConflict @@ -656,6 +667,8 @@ pub async fn rhi::RhiReconciliationAttemptRepository<'_>::commit_source_replays< impl rhi::RhiReconciliationAttemptRepository<'_> pub const fn rhi::RhiReconciliationAttemptRepository<'_>::descriptor(&self) -> rhi::RhiStateRepositoryDescriptor pub const fn rhi::RhiReconciliationAttemptRepository<'_>::kind(&self) -> rhi::RhiStateRepositoryKind +impl rhi::RhiReconciliationAttemptRepository<'_> +pub async fn rhi::RhiReconciliationAttemptRepository<'_>::prepare_finalization(&self, rhi::RhiReconciliationLease, rhi::RhiReconciliationEvaluation, rhi::RhiReconciliationUnixMilliseconds) -> core::result::Result<rhi::RhiReconciliationFinalizationFence, rhi::RhiReconciliationFinalizationError> impl core::fmt::Debug for rhi::RhiReconciliationAttemptRepository<'_> pub fn rhi::RhiReconciliationAttemptRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiReconciliationAttemptResults @@ -684,6 +697,21 @@ pub const fn rhi::RhiReconciliationEvaluation::projection(&self) -> &rhi::RhiRec pub const fn rhi::RhiReconciliationEvaluation::reason_codes(&self) -> &[rhi::RhiReconciliationReasonCode] impl core::fmt::Debug for rhi::RhiReconciliationEvaluation pub fn rhi::RhiReconciliationEvaluation::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiReconciliationFinalizationError +impl rhi::RhiReconciliationFinalizationError +pub const fn rhi::RhiReconciliationFinalizationError::code(self) -> &'static str +pub const fn rhi::RhiReconciliationFinalizationError::kind(self) -> rhi::RhiReconciliationFinalizationErrorKind +impl core::error::Error for rhi::RhiReconciliationFinalizationError +impl core::fmt::Debug for rhi::RhiReconciliationFinalizationError +pub fn rhi::RhiReconciliationFinalizationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for rhi::RhiReconciliationFinalizationError +pub fn rhi::RhiReconciliationFinalizationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiReconciliationFinalizationFence +impl rhi::RhiReconciliationFinalizationFence +pub const fn rhi::RhiReconciliationFinalizationFence::contract_version(&self) -> u32 +pub const fn rhi::RhiReconciliationFinalizationFence::evaluation(&self) -> &rhi::RhiReconciliationEvaluation +impl core::fmt::Debug for rhi::RhiReconciliationFinalizationFence +pub fn rhi::RhiReconciliationFinalizationFence::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiReconciliationJob impl rhi::RhiReconciliationJob pub const fn rhi::RhiReconciliationJob::attempt_count(self) -> u16 @@ -1272,6 +1300,7 @@ 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_COMMIT_CONTRACT_VERSION: u32 +pub const rhi::RHI_RECONCILIATION_FINALIZATION_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 diff --git a/contracts/services_hardening/reconciliation_finalization.v1.json b/contracts/services_hardening/reconciliation_finalization.v1.json @@ -0,0 +1,86 @@ +{ + "schema": "radroots.rhi.reconciliation-finalization", + "schema_version": 1, + "contract_version": 1, + "purpose": "read_only_preflight_for_the_later_generation_fenced_atomic_finalization", + "input": { + "evaluation_authority": "sealed_step_194_evaluation_only", + "lease_authority": "exact_live_claimed_reconciliation_lease", + "time_authority": "caller_injected_integer_utc_milliseconds", + "ownership": "evaluation_consumed_and_retained_inside_fence" + }, + "private_identity_chain": { + "manifest_retains": [ + "step_190_attempt_id", + "step_190_job_id" + ], + "canonical_manifest_wire_changed": false, + "required_equalities": [ + "manifest_job_id_equals_lease_job_id", + "manifest_attempt_id_equals_domain_separated_current_lease_attempt_id", + "manifest_trade_equals_lease_job_trade", + "manifest_generation_equals_lease_job_input_generation", + "manifest_policy_digest_equals_lease_job_policy_digest" + ], + "projection_digest": "required_available_before_fence_creation", + "public_identity_access": false + }, + "preflight_order": [ + "writable_host", + "sealed_identity_chain", + "unexpired_input_lease", + "exact_current_lease", + "exact_current_dirty_generation_and_policy_digest", + "exact_committed_attempt_row" + ], + "durable_validation": { + "transaction": "one_bounded_read_only_service_sqlite_transaction", + "attempt_table": "evidence_reconciliations", + "attempt_match_cardinality": 1, + "mutation": false, + "step_199_requirement": "rerun_same_validator_inside_atomic_finalization_transaction_before_any_write", + "preflight_is_commit_authority": false, + "state_may_change_after_preflight": true + }, + "result": { + "type": "RhiReconciliationFinalizationFence", + "sealed": true, + "clone": false, + "serialize": false, + "caller_forgeable": false, + "retains_evaluation": true, + "debug": "coverage_and_outcome_only", + "errors": "crate_owned_source_free_redacted" + }, + "effects": { + "sqlite_read": true, + "sqlite_write": false, + "filesystem": false, + "source_or_relay": false, + "network": false, + "task_spawn": false, + "ambient_clock": false, + "ambient_entropy": false, + "report_or_attestation": false, + "publication": false, + "job_finalization": false + }, + "forbidden": [ + "old_attempt_relabelled_with_new_lease", + "stale_generation_fence", + "policy_digest_substitution", + "projection_digest_absence_as_finalizable", + "preflight_used_as_durable_commit_proof", + "report_or_outbox_write", + "checkpoint_advance", + "job_state_transition" + ], + "deferred": [ + "canonical_report_statement_supersession_and_signed_attestation", + "manifest_projection_report_and_signed_event_schema", + "atomic_finalization_transaction", + "publication", + "job_finalization", + "integration_wave_qualification" + ] +} diff --git a/contracts/services_hardening/reconciliation_manifest.v1.json b/contracts/services_hardening/reconciliation_manifest.v1.json @@ -86,6 +86,13 @@ "reducer_owner": "reconciliation_reducer.v1.json", "outcome_owner": "reconciliation_outcome.v1.json" }, + "private_finalization_identity": { + "attempt_id": "exact_step_190_committed_attempt_id", + "job_id": "exact_step_190_claimed_job_id", + "canonical_manifest_wire_changed": false, + "public_access": false, + "owner": "reconciliation_finalization.v1.json" + }, "effects": { "sqlite": false, "filesystem": false, diff --git a/src/lib.rs b/src/lib.rs @@ -10,6 +10,7 @@ mod identity_credential; mod identity_envelope; mod reconciliation_attempt; mod reconciliation_commit; +mod reconciliation_finalization; mod reconciliation_job; mod reconciliation_manifest; mod reconciliation_reducer; @@ -83,6 +84,10 @@ pub use reconciliation_commit::{ RHI_RECONCILIATION_COMMIT_CONTRACT_VERSION, RhiReconciliationCommitError, RhiReconciliationCommitErrorKind, RhiReconciliationSourceCommitOutcome, }; +pub use reconciliation_finalization::{ + RHI_RECONCILIATION_FINALIZATION_CONTRACT_VERSION, RhiReconciliationFinalizationError, + RhiReconciliationFinalizationErrorKind, RhiReconciliationFinalizationFence, +}; pub use reconciliation_job::{ RHI_RECONCILIATION_JOB_CONTRACT_VERSION, RHI_RECONCILIATION_JOB_MAX_ACTIVE, RhiReconciliationJob, RhiReconciliationJobError, RhiReconciliationJobErrorKind, diff --git a/src/reconciliation_attempt.rs b/src/reconciliation_attempt.rs @@ -631,7 +631,10 @@ fn absolute_deadline( .map_err(|_| error(RhiReconciliationAttemptErrorKind::InvalidInput)) } -fn attempt_id(job_id: RhiReconciliationJobId, attempt_count: u16) -> RhiReconciliationAttemptId { +pub(crate) fn attempt_id( + job_id: RhiReconciliationJobId, + attempt_count: u16, +) -> RhiReconciliationAttemptId { let mut hasher = Sha256::new(); hasher.update(ATTEMPT_ID_DOMAIN); hasher.update(job_id.as_bytes()); diff --git a/src/reconciliation_finalization.rs b/src/reconciliation_finalization.rs @@ -0,0 +1,382 @@ +//! Generation-fenced reconciliation-finalization preflight. + +use core::fmt; +use std::error::Error; + +use radroots_event::id::TradeId; +use radroots_service_sqlite::{ + ServiceSqliteTransaction, ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind, +}; + +use crate::{ + RhiEvidencePolicyDigest, RhiReconciliationAttemptId, RhiReconciliationAttemptRepository, + RhiReconciliationEvaluation, RhiReconciliationJobId, RhiReconciliationJobState, + RhiReconciliationLease, RhiReconciliationUnixMilliseconds, RhiStateHostMode, + reconciliation_attempt::attempt_id, + reconciliation_job::{LeaseValidationError, validate_exact_lease}, + source_ingest::{SourceOperationError, read_dirty}, +}; + +/// Exact version of the reconciliation-finalization fence contract. +pub const RHI_RECONCILIATION_FINALIZATION_CONTRACT_VERSION: u32 = 1; + +const MATCH_COMMITTED_ATTEMPT_SQL: &str = r#"SELECT COUNT(*) +FROM evidence_reconciliations +WHERE attempt_id = ? AND job_id = ? AND trade_id = ? AND input_generation = ? + AND evidence_policy_sha256 = ?"#; + +/// Stable source-free failure class for finalization preflight. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiReconciliationFinalizationErrorKind { + InvalidMode, + InvalidInput, + LeaseLost, + GenerationConflict, + AttemptUnavailable, + ProjectionUnavailable, + Storage, + CommitOutcomeUnknown, +} + +impl RhiReconciliationFinalizationErrorKind { + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidMode => "reconciliation_finalization_mode_invalid", + Self::InvalidInput => "reconciliation_finalization_input_invalid", + Self::LeaseLost => "reconciliation_finalization_lease_lost", + Self::GenerationConflict => "reconciliation_finalization_generation_conflict", + Self::AttemptUnavailable => "reconciliation_finalization_attempt_unavailable", + Self::ProjectionUnavailable => "reconciliation_finalization_projection_unavailable", + Self::Storage => "reconciliation_finalization_storage_failed", + Self::CommitOutcomeUnknown => "reconciliation_finalization_outcome_unknown", + } + } +} + +/// Redacted source-free finalization-preflight failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiReconciliationFinalizationError { + kind: RhiReconciliationFinalizationErrorKind, +} + +impl RhiReconciliationFinalizationError { + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> RhiReconciliationFinalizationErrorKind { + 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 RhiReconciliationFinalizationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + RhiReconciliationFinalizationErrorKind::InvalidMode => { + "RHI reconciliation finalization requires writable state" + } + RhiReconciliationFinalizationErrorKind::InvalidInput => { + "RHI reconciliation finalization input is invalid" + } + RhiReconciliationFinalizationErrorKind::LeaseLost => { + "RHI reconciliation finalization lease is no longer authoritative" + } + RhiReconciliationFinalizationErrorKind::GenerationConflict => { + "RHI reconciliation finalization generation changed" + } + RhiReconciliationFinalizationErrorKind::AttemptUnavailable => { + "RHI reconciliation finalization attempt is unavailable" + } + RhiReconciliationFinalizationErrorKind::ProjectionUnavailable => { + "RHI reconciliation finalization projection is unavailable" + } + RhiReconciliationFinalizationErrorKind::Storage => { + "RHI reconciliation finalization preflight failed" + } + RhiReconciliationFinalizationErrorKind::CommitOutcomeUnknown => { + "RHI reconciliation finalization preflight outcome is unknown" + } + }) + } +} + +impl fmt::Debug for RhiReconciliationFinalizationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiReconciliationFinalizationError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for RhiReconciliationFinalizationError {} + +/// Sealed preflight capability for one exact evaluated reconciliation attempt. +/// +/// This value proves only that its lease, generation, policy, and committed +/// attempt matched during one bounded read-only preflight transaction. The +/// eventual Step199 writer must rerun the same validator inside its atomic +/// transaction before any mutation. +/// +/// ```compile_fail +/// use rhi::RhiReconciliationFinalizationFence; +/// +/// let _forged = RhiReconciliationFinalizationFence { evaluation: todo!() }; +/// ``` +pub struct RhiReconciliationFinalizationFence { + lease: RhiReconciliationLease, + identity: FinalizationIdentity, + evaluation: RhiReconciliationEvaluation, +} + +impl RhiReconciliationFinalizationFence { + /// Returns the exact finalization-fence contract version. + #[must_use] + pub const fn contract_version(&self) -> u32 { + RHI_RECONCILIATION_FINALIZATION_CONTRACT_VERSION + } + + /// Returns the sealed Step194 evaluation retained by this preflight. + #[must_use] + pub const fn evaluation(&self) -> &RhiReconciliationEvaluation { + &self.evaluation + } +} + +impl fmt::Debug for RhiReconciliationFinalizationFence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiReconciliationFinalizationFence") + .field("coverage", &self.evaluation.coverage()) + .field("outcome", &self.evaluation.outcome()) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Copy)] +struct FinalizationIdentity { + attempt_id: RhiReconciliationAttemptId, + job_id: RhiReconciliationJobId, + trade_id: TradeId, + generation: u64, + policy_digest: RhiEvidencePolicyDigest, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FinalizationOperationError { + LeaseLost, + GenerationConflict, + AttemptUnavailable, + Storage, +} + +impl RhiReconciliationAttemptRepository<'_> { + /// Performs one bounded read-only preflight for later atomic finalization. + /// + /// The returned capability is not commit authority. Durable finalization + /// must rerun the same exact checks inside its Step199 write transaction. + pub async fn prepare_finalization( + &self, + lease: RhiReconciliationLease, + evaluation: RhiReconciliationEvaluation, + now: RhiReconciliationUnixMilliseconds, + ) -> Result<RhiReconciliationFinalizationFence, RhiReconciliationFinalizationError> { + if self.host().mode() != RhiStateHostMode::ReadWriteExisting { + return Err(failure(RhiReconciliationFinalizationErrorKind::InvalidMode)); + } + let identity = finalization_identity(lease, &evaluation, now)?; + let fence = RhiReconciliationFinalizationFence { + lease, + identity, + evaluation, + }; + self.host() + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { + validate_finalization_fence(transaction, &fence, now).await?; + Ok(fence) + }) + }) + .await + .map_err(map_transaction_error) + } +} + +pub(crate) async fn validate_finalization_fence( + transaction: &mut ServiceSqliteTransaction<'_>, + fence: &RhiReconciliationFinalizationFence, + now: RhiReconciliationUnixMilliseconds, +) -> Result<(), FinalizationOperationError> { + validate_finalization_identity(transaction, fence.lease, fence.identity, now).await +} + +fn finalization_identity( + lease: RhiReconciliationLease, + evaluation: &RhiReconciliationEvaluation, + now: RhiReconciliationUnixMilliseconds, +) -> Result<FinalizationIdentity, RhiReconciliationFinalizationError> { + let job = lease.job(); + let manifest = evaluation.projection().manifest(); + if job.state() != RhiReconciliationJobState::Leased + || job.attempt_count() == 0 + || manifest.job_id() != job.id() + || manifest.attempt_id() != attempt_id(job.id(), job.attempt_count()) + || manifest.trade_id() != &job.trade_id() + || manifest.trade_generation() != job.input_generation() + || manifest.inner().evidence_policy_digest().as_bytes() + != job.evidence_policy_digest().as_bytes() + { + return Err(failure( + RhiReconciliationFinalizationErrorKind::InvalidInput, + )); + } + if now >= lease.lease_expires() { + return Err(failure(RhiReconciliationFinalizationErrorKind::LeaseLost)); + } + if evaluation.projection().digest().is_none() { + return Err(failure( + RhiReconciliationFinalizationErrorKind::ProjectionUnavailable, + )); + } + Ok(FinalizationIdentity { + attempt_id: manifest.attempt_id(), + job_id: manifest.job_id(), + trade_id: job.trade_id(), + generation: job.input_generation(), + policy_digest: job.evidence_policy_digest(), + }) +} + +async fn validate_finalization_identity( + transaction: &mut ServiceSqliteTransaction<'_>, + lease: RhiReconciliationLease, + identity: FinalizationIdentity, + now: RhiReconciliationUnixMilliseconds, +) -> Result<(), FinalizationOperationError> { + if now >= lease.lease_expires() { + return Err(FinalizationOperationError::LeaseLost); + } + validate_exact_lease(transaction, lease) + .await + .map_err(|error| match error { + LeaseValidationError::LeaseLost => FinalizationOperationError::LeaseLost, + LeaseValidationError::Storage => FinalizationOperationError::Storage, + })?; + read_dirty(transaction, identity.trade_id) + .await + .map_err(|error| match error { + SourceOperationError::GenerationConflict => { + FinalizationOperationError::GenerationConflict + } + SourceOperationError::Persistence(_) | SourceOperationError::Storage => { + FinalizationOperationError::Storage + } + })? + .filter(|dirty| { + dirty.generation.get() == identity.generation && dirty.policy == identity.policy_digest + }) + .ok_or(FinalizationOperationError::GenerationConflict)?; + let generation = + i64::try_from(identity.generation).map_err(|_| FinalizationOperationError::Storage)?; + let count = sqlx::query_scalar::<_, i64>(MATCH_COMMITTED_ATTEMPT_SQL) + .bind(identity.attempt_id.as_bytes().as_slice()) + .bind(identity.job_id.as_bytes().as_slice()) + .bind(identity.trade_id.as_bytes().as_slice()) + .bind(generation) + .bind(identity.policy_digest.as_bytes().as_slice()) + .fetch_one(&mut *transaction) + .await + .map_err(|_| FinalizationOperationError::Storage)?; + if count == 1 { + Ok(()) + } else { + Err(FinalizationOperationError::AttemptUnavailable) + } +} + +fn map_transaction_error( + error: ServiceSqliteTransactionError<FinalizationOperationError>, +) -> RhiReconciliationFinalizationError { + if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown { + return failure(RhiReconciliationFinalizationErrorKind::CommitOutcomeUnknown); + } + failure(match error.operation_error().copied() { + Some(FinalizationOperationError::LeaseLost) => { + RhiReconciliationFinalizationErrorKind::LeaseLost + } + Some(FinalizationOperationError::GenerationConflict) => { + RhiReconciliationFinalizationErrorKind::GenerationConflict + } + Some(FinalizationOperationError::AttemptUnavailable) => { + RhiReconciliationFinalizationErrorKind::AttemptUnavailable + } + Some(FinalizationOperationError::Storage) | None => { + RhiReconciliationFinalizationErrorKind::Storage + } + }) +} + +const fn failure( + kind: RhiReconciliationFinalizationErrorKind, +) -> RhiReconciliationFinalizationError { + RhiReconciliationFinalizationError { kind } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_inventory_is_exact_source_free_and_redacted() { + let cases = [ + ( + RhiReconciliationFinalizationErrorKind::InvalidMode, + "reconciliation_finalization_mode_invalid", + ), + ( + RhiReconciliationFinalizationErrorKind::InvalidInput, + "reconciliation_finalization_input_invalid", + ), + ( + RhiReconciliationFinalizationErrorKind::LeaseLost, + "reconciliation_finalization_lease_lost", + ), + ( + RhiReconciliationFinalizationErrorKind::GenerationConflict, + "reconciliation_finalization_generation_conflict", + ), + ( + RhiReconciliationFinalizationErrorKind::AttemptUnavailable, + "reconciliation_finalization_attempt_unavailable", + ), + ( + RhiReconciliationFinalizationErrorKind::ProjectionUnavailable, + "reconciliation_finalization_projection_unavailable", + ), + ( + RhiReconciliationFinalizationErrorKind::Storage, + "reconciliation_finalization_storage_failed", + ), + ( + RhiReconciliationFinalizationErrorKind::CommitOutcomeUnknown, + "reconciliation_finalization_outcome_unknown", + ), + ]; + for (kind, code) in cases { + let error = failure(kind); + assert_eq!(error.kind(), kind); + assert_eq!(error.code(), code); + assert!(Error::source(&error).is_none()); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains("11111111")); + assert!(!rendered.contains("trade-primary")); + } + } +} diff --git a/src/reconciliation_manifest.rs b/src/reconciliation_manifest.rs @@ -16,7 +16,8 @@ use radroots_trade::evidence::{ use sha2::{Digest, Sha256}; use crate::{ - RhiReconciliationAttemptPlan, RhiReconciliationSourceCommitOutcome, RhiTradeSourceCompletion, + RhiReconciliationAttemptId, RhiReconciliationAttemptPlan, RhiReconciliationJobId, + RhiReconciliationSourceCommitOutcome, RhiTradeSourceCompletion, reconciliation_commit::committed_inventory_digest, reconciliation_replay::{ RhiReconciliationReplayCommitFact, RhiReconciliationReplayCommitParts, @@ -113,6 +114,8 @@ impl Error for RhiReconciliationManifestError {} /// ``` pub struct RhiReconciliationManifest { inner: RadrootsTradeEvidenceManifestV1, + attempt_id: RhiReconciliationAttemptId, + job_id: RhiReconciliationJobId, reducer_mutations: Box<[RhiReducerMutationMaterial]>, } @@ -181,6 +184,14 @@ impl RhiReconciliationManifest { &self.inner } + pub(crate) const fn attempt_id(&self) -> RhiReconciliationAttemptId { + self.attempt_id + } + + pub(crate) const fn job_id(&self) -> RhiReconciliationJobId { + self.job_id + } + pub(crate) fn reducer_mutations(&self) -> &[RhiReducerMutationMaterial] { &self.reducer_mutations } @@ -212,6 +223,8 @@ impl RhiReconciliationSourceCommitOutcome { } pub(crate) struct RhiCommittedManifestMaterial { + attempt_id: RhiReconciliationAttemptId, + job_id: RhiReconciliationJobId, trade_id: TradeId, generation: NonZeroU64, policy_digest: RadrootsTradeEvidencePolicyDigestV1, @@ -298,6 +311,8 @@ pub(crate) fn committed_manifest_material( } Ok(RhiCommittedManifestMaterial { + attempt_id: plan.id(), + job_id: plan.job_id(), trade_id, generation, policy_digest, @@ -341,6 +356,8 @@ fn freeze_manifest( .map_err(|_| error(RhiReconciliationManifestErrorKind::InvalidCommittedInventory))?; Ok(RhiReconciliationManifest { inner, + attempt_id: material.attempt_id, + job_id: material.job_id, reducer_mutations: material.reducer_mutations, }) } 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_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"); const RECONCILIATION_JOBS: &str = include_str!("../src/reconciliation_job.rs"); @@ -23,6 +24,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_FINALIZATION_CONTRACT: &str = + include_str!("../contracts/services_hardening/reconciliation_finalization.v1.json"); const RECONCILIATION_MANIFEST_CONTRACT: &str = include_str!("../contracts/services_hardening/reconciliation_manifest.v1.json"); const RECONCILIATION_REDUCER_CONTRACT: &str = @@ -47,6 +50,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/identity_envelope.rs"), include_str!("../src/reconciliation_attempt.rs"), include_str!("../src/reconciliation_commit.rs"), + include_str!("../src/reconciliation_finalization.rs"), include_str!("../src/reconciliation_job.rs"), include_str!("../src/reconciliation_manifest.rs"), include_str!("../src/reconciliation_reducer.rs"), @@ -119,6 +123,7 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "identity_envelope", "reconciliation_attempt", "reconciliation_commit", + "reconciliation_finalization", "reconciliation_job", "reconciliation_manifest", "reconciliation_reducer", @@ -164,6 +169,9 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "RhiReconciliationAttemptResults", "RhiReconciliationSourceCommitOutcome", "RhiReconciliationCommitErrorKind", + "RhiReconciliationFinalizationFence", + "RhiReconciliationFinalizationErrorKind", + "RHI_RECONCILIATION_FINALIZATION_CONTRACT_VERSION", "RhiReconciliationManifest", "RhiReconciliationManifestErrorKind", "RhiReconciliationScopePrerequisites", @@ -261,6 +269,50 @@ fn reconciliation_commit_is_atomic_bounded_and_sealed() { } #[test] +fn reconciliation_finalization_is_attempt_bound_nonmutating_and_revalidated() { + let contract: serde_json::Value = serde_json::from_str(RECONCILIATION_FINALIZATION_CONTRACT) + .expect("reconciliation-finalization contract"); + assert_eq!( + contract["schema"], + "radroots.rhi.reconciliation-finalization" + ); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["effects"]["sqlite_read"], true); + assert_eq!(contract["effects"]["sqlite_write"], false); + assert_eq!( + contract["durable_validation"]["preflight_is_commit_authority"], + false + ); + for required in [ + "pub async fn prepare_finalization(", + "validate_finalization_fence(", + "validate_exact_lease(transaction, lease)", + "read_dirty(transaction, identity.trade_id)", + "MATCH_COMMITTED_ATTEMPT_SQL", + "attempt_id(job.id(), job.attempt_count())", + ] { + assert!( + RECONCILIATION_FINALIZATION.contains(required), + "reconciliation finalization is missing {required}" + ); + } + for forbidden in [ + "INSERT ", + "UPDATE ", + "DELETE ", + "tokio::spawn", + "SystemTime", + ] { + assert!( + !RECONCILIATION_FINALIZATION.contains(forbidden), + "reconciliation finalization gained forbidden authority {forbidden}" + ); + } + assert!(!ROOT.contains("pub mod reconciliation_finalization")); + assert!(!PUBLIC_API.contains("rhi::reconciliation_finalization::")); +} + +#[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"); @@ -402,7 +454,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, 22); + assert_eq!(public_error_count, 23); } #[test] @@ -826,6 +878,9 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() { "[`reconciliation_reducer.v1.json`](contracts/services_hardening/reconciliation_reducer.v1.json)", "binds the promoted shared `radroots.trade.reducer.v1`", "[`reconciliation_outcome.v1.json`](contracts/services_hardening/reconciliation_outcome.v1.json)", + "## 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", "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_finalization_contract.rs b/tests/services_hardening_reconciliation_finalization_contract.rs @@ -0,0 +1,130 @@ +#![forbid(unsafe_code)] + +use rhi::RHI_RECONCILIATION_FINALIZATION_CONTRACT_VERSION; +use serde_json::json; + +const CONTRACT: &str = + include_str!("../contracts/services_hardening/reconciliation_finalization.v1.json"); +const ROOT: &str = include_str!("../src/lib.rs"); +const SOURCE: &str = include_str!("../src/reconciliation_finalization.rs"); +const MANIFEST: &str = include_str!("../src/reconciliation_manifest.rs"); +const README: &str = include_str!("../README"); + +#[test] +fn machine_contract_freezes_the_complete_step_195_boundary() { + let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract"); + assert_eq!( + contract["schema"], + "radroots.rhi.reconciliation-finalization" + ); + assert_eq!(contract["schema_version"], 1); + assert_eq!( + contract["contract_version"], + RHI_RECONCILIATION_FINALIZATION_CONTRACT_VERSION + ); + assert_eq!( + contract["private_identity_chain"]["manifest_retains"], + json!(["step_190_attempt_id", "step_190_job_id"]) + ); + assert_eq!( + contract["private_identity_chain"]["canonical_manifest_wire_changed"], + false + ); + assert_eq!( + contract["preflight_order"], + json!([ + "writable_host", + "sealed_identity_chain", + "unexpired_input_lease", + "exact_current_lease", + "exact_current_dirty_generation_and_policy_digest", + "exact_committed_attempt_row" + ]) + ); + assert_eq!(contract["durable_validation"]["mutation"], false); + assert_eq!( + contract["durable_validation"]["step_199_requirement"], + "rerun_same_validator_inside_atomic_finalization_transaction_before_any_write" + ); + assert_eq!( + contract["durable_validation"]["preflight_is_commit_authority"], + false + ); + assert_eq!(contract["effects"]["sqlite_read"], true); + assert_eq!(contract["effects"]["sqlite_write"], false); + for effect in [ + "filesystem", + "source_or_relay", + "network", + "task_spawn", + "ambient_clock", + "ambient_entropy", + "report_or_attestation", + "publication", + "job_finalization", + ] { + assert_eq!(contract["effects"][effect], false, "effect {effect}"); + } +} + +#[test] +fn finalization_boundary_is_sealed_identity_bound_and_nonmutating() { + assert!(ROOT.contains("mod reconciliation_finalization;")); + assert!(!ROOT.contains("pub mod reconciliation_finalization;")); + for required in [ + "RhiReconciliationFinalizationFence", + "RhiReconciliationFinalizationError", + "RhiReconciliationFinalizationErrorKind", + "RHI_RECONCILIATION_FINALIZATION_CONTRACT_VERSION", + ] { + assert!(ROOT.contains(required), "root API is missing {required}"); + } + for required in [ + "pub async fn prepare_finalization(", + "validate_finalization_fence(", + "validate_exact_lease(transaction, lease)", + "read_dirty(transaction, identity.trade_id)", + "MATCH_COMMITTED_ATTEMPT_SQL", + "manifest.attempt_id() != attempt_id(job.id(), job.attempt_count())", + "now >= lease.lease_expires()", + "evaluation.projection().digest().is_none()", + ] { + assert!( + SOURCE.contains(required), + "finalization boundary is missing {required}" + ); + } + for required in [ + "attempt_id: RhiReconciliationAttemptId", + "job_id: RhiReconciliationJobId", + "attempt_id: plan.id()", + "job_id: plan.job_id()", + ] { + assert!( + MANIFEST.contains(required), + "manifest is missing {required}" + ); + } + for forbidden in [ + "INSERT ", + "UPDATE ", + "DELETE ", + "std::fs", + "std::net", + "tokio::spawn", + "SystemTime", + "thread_rng", + "OsRng", + "pub fn new(", + "pub const fn new(", + ] { + assert!( + !SOURCE.contains(forbidden), + "finalization boundary gained forbidden authority {forbidden}" + ); + } + assert!(README.contains("## Generation-fenced finalization preflight")); + assert!(README.contains( + "[`reconciliation_finalization.v1.json`](contracts/services_hardening/reconciliation_finalization.v1.json)" + )); +} diff --git a/tests/services_hardening_reconciliation_jobs.rs b/tests/services_hardening_reconciliation_jobs.rs @@ -9,8 +9,9 @@ use rhi::{ RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiReconciliationAttemptErrorKind, RhiReconciliationAttemptPlan, RhiReconciliationAttemptResults, RhiReconciliationCommitErrorKind, - RhiReconciliationJobErrorKind, RhiReconciliationJobPolicy, RhiReconciliationJobState, - RhiReconciliationLease, RhiReconciliationLeaseOwner, RhiReconciliationRetryDelayMilliseconds, + RhiReconciliationFinalizationErrorKind, RhiReconciliationJobErrorKind, + RhiReconciliationJobPolicy, RhiReconciliationJobState, RhiReconciliationLease, + RhiReconciliationLeaseOwner, RhiReconciliationRetryDelayMilliseconds, RhiReconciliationScopePrerequisites, RhiReconciliationSourceReplayPlan, RhiReconciliationSourceResult, RhiReconciliationUnixMilliseconds, RhiRuntimeContext, RhiStateMetadata, RhiTradeMutationAdmissionLimits, RhiTradeMutationAuthoredTimePolicy, @@ -1412,6 +1413,176 @@ async fn incomplete_results_never_advance_and_stale_leases_fail_closed() { drop((configuration, metadata, runtime, root)); } +#[tokio::test] +async fn finalization_preflight_is_exact_nonmutating_and_redacted() { + let (root, runtime, metadata, configuration, host, lease, evaluation) = + finalization_fixture("finalization-exact").await; + 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 preflight"); + assert_eq!(fence.contract_version(), 1); + assert_eq!( + fence + .evaluation() + .projection() + .manifest() + .trade_generation(), + 2 + ); + assert!(fence.evaluation().projection().digest().is_some()); + let rendered = format!("{fence:?}"); + assert!(!rendered.contains("11111111")); + assert!(!rendered.contains("reconciliation_attempt")); + assert_eq!(finalization_snapshot(&runtime).await, before); + host.close().await.expect("close"); + drop((fence, configuration, metadata, runtime, root)); +} + +#[tokio::test] +async fn finalization_rejects_stale_generation_policy_and_expired_or_lost_leases() { + let (root, runtime, metadata, configuration, host, lease, evaluation) = + finalization_fixture("finalization-generation").await; + write_dirty( + &runtime, + lease.job().trade_id(), + 3, + *lease.job().evidence_policy_digest().as_bytes(), + 1_784_347_207, + ) + .await; + let error = host + .repositories() + .reconciliation_attempts() + .prepare_finalization(lease, evaluation, now(1_784_347_207_000)) + .await + .expect_err("stale generation"); + assert_eq!( + error.kind(), + RhiReconciliationFinalizationErrorKind::GenerationConflict + ); + assert!(Error::source(&error).is_none()); + assert!(!format!("{error} {error:?}").contains("11111111")); + host.close().await.expect("close"); + drop((configuration, metadata, runtime, root)); + + let (root, runtime, metadata, configuration, host, lease, evaluation) = + finalization_fixture("finalization-policy").await; + write_dirty( + &runtime, + lease.job().trade_id(), + 3, + [0xa5; 32], + 1_784_347_207, + ) + .await; + let error = host + .repositories() + .reconciliation_attempts() + .prepare_finalization(lease, evaluation, now(1_784_347_207_000)) + .await + .expect_err("stale policy"); + assert_eq!( + error.kind(), + RhiReconciliationFinalizationErrorKind::GenerationConflict + ); + host.close().await.expect("close"); + drop((configuration, metadata, runtime, root)); + + let (root, runtime, metadata, configuration, host, lease, evaluation) = + finalization_fixture("finalization-expired").await; + let error = host + .repositories() + .reconciliation_attempts() + .prepare_finalization(lease, evaluation, lease.lease_expires()) + .await + .expect_err("expired lease"); + assert_eq!( + error.kind(), + RhiReconciliationFinalizationErrorKind::LeaseLost + ); + host.close().await.expect("close"); + drop((configuration, metadata, runtime, root)); + + let (root, runtime, metadata, configuration, host, lease, evaluation) = + finalization_fixture("finalization-lost").await; + host.repositories() + .reconciliation_jobs() + .record_failure( + lease, + now(1_784_347_206_000), + RhiReconciliationRetryDelayMilliseconds::new(1).expect("delay"), + ) + .await + .expect("release lease"); + let error = host + .repositories() + .reconciliation_attempts() + .prepare_finalization(lease, evaluation, now(1_784_347_206_001)) + .await + .expect_err("lost lease"); + assert_eq!( + error.kind(), + RhiReconciliationFinalizationErrorKind::LeaseLost + ); + host.close().await.expect("close"); + drop((configuration, metadata, runtime, root)); +} + +#[tokio::test] +async fn finalization_rejects_cross_attempt_relabelling_and_read_only_hosts() { + let (root, runtime, metadata, configuration, host, lease, evaluation) = + finalization_fixture("finalization-attempt").await; + let jobs = host.repositories().reconciliation_jobs(); + jobs.record_failure( + lease, + now(1_784_347_206_000), + RhiReconciliationRetryDelayMilliseconds::new(1).expect("delay"), + ) + .await + .expect("retry schedule"); + let next_lease = jobs + .claim_next(owner(0x93), now(1_784_347_206_001)) + .await + .expect("next claim") + .expect("reclaimed job"); + assert_eq!(next_lease.job().attempt_count(), 2); + let error = host + .repositories() + .reconciliation_attempts() + .prepare_finalization(next_lease, evaluation, now(1_784_347_206_002)) + .await + .expect_err("old evaluation cannot be relabelled"); + assert_eq!( + error.kind(), + RhiReconciliationFinalizationErrorKind::InvalidInput + ); + host.close().await.expect("close"); + drop((configuration, metadata, runtime, root)); + + let (root, runtime, metadata, configuration, host, lease, evaluation) = + finalization_fixture("finalization-inspection").await; + host.close().await.expect("close writer"); + let inspection = open_rhi_state_inspection(&runtime, &metadata) + .await + .expect("inspection"); + let error = inspection + .repositories() + .reconciliation_attempts() + .prepare_finalization(lease, evaluation, now(1_784_347_206_000)) + .await + .expect_err("inspection cannot prepare finalization"); + assert_eq!( + error.kind(), + RhiReconciliationFinalizationErrorKind::InvalidMode + ); + inspection.close().await.expect("inspection close"); + drop((configuration, metadata, runtime, root)); +} + async fn fixture_connection(runtime: &RhiRuntimeContext) -> SqliteConnection { let options = SqliteConnectOptions::new() .filename(runtime.artifacts().state_database()) @@ -1422,6 +1593,134 @@ async fn fixture_connection(runtime: &RhiRuntimeContext) -> SqliteConnection { .expect("offline fixture connection") } +async fn finalization_snapshot( + runtime: &RhiRuntimeContext, +) -> (i64, i64, i64, i64, i64, i64, i64, i64) { + let mut connection = fixture_connection(runtime).await; + let row: (i64, i64, i64, i64, i64, i64, i64, i64) = sqlx::query_as( + r#"SELECT + (SELECT COUNT(*) FROM reconciliation_jobs), + (SELECT COUNT(*) FROM reconciliation_jobs WHERE state = 'leased'), + (SELECT SUM(revision) FROM reconciliation_jobs), + (SELECT COUNT(*) FROM evidence_reconciliations), + (SELECT COUNT(*) FROM evidence_reconciliation_sources), + (SELECT COUNT(*) FROM relay_checkpoints), + (SELECT generation FROM trade_dirty_generations LIMIT 1), + (SELECT COUNT(*) FROM sqlite_schema WHERE name IN ( + 'evidence_manifests', 'trade_projections', 'attestation_reports', + 'signed_attestation_events', 'publication_outbox' + ))"#, + ) + .fetch_one(&mut connection) + .await + .expect("finalization snapshot"); + connection.close().await.expect("snapshot close"); + row +} + +async fn finalization_fixture( + instance: &str, +) -> ( + tempfile::TempDir, + RhiRuntimeContext, + RhiStateMetadata, + rhi::RhiConfigDocumentV1, + rhi::RhiStateHost, + RhiReconciliationLease, + rhi::RhiReconciliationEvaluation, +) { + 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; + let wire = replay_wire(); + let first_request = &first_plan.requests()[0]; + let first_replay = RhiReconciliationSourceReplayPlan::from_request( + &first_plan, + first_request, + &configuration, + None, + ) + .expect("first replay plan") + .finish( + first_request, + RhiTradeSourceCompletion::Complete, + now(started_ms), + now(started_ms + 2_000), + [admitted_replay(&configuration, &wire, 1_784_347_200)], + ) + .expect("first replay"); + let first = host + .repositories() + .reconciliation_attempts() + .commit_source_replays(first_lease, first_plan, [first_replay]) + .await + .expect("first commit"); + assert!(first.dirty_generation_advanced()); + let cursor = first.committed_cursors()[0].clone(); + drop(first); + + let jobs = host.repositories().reconciliation_jobs(); + let scheduled = jobs + .schedule_trade( + first_lease.job().trade_id(), + configured_policy(&configuration), + now(started_ms + 3_000), + ) + .await + .expect("schedule final generation"); + assert_eq!(scheduled.job().input_generation(), 2); + let lease = jobs + .claim_next(owner(0x92), now(started_ms + 3_000)) + .await + .expect("claim final generation") + .expect("final job"); + let plan = + RhiReconciliationAttemptPlan::from_claim(lease, &configuration, now(started_ms + 3_000)) + .expect("final plan"); + let request = &plan.requests()[0]; + let replay = RhiReconciliationSourceReplayPlan::from_request( + &plan, + request, + &configuration, + Some(cursor), + ) + .expect("final replay plan") + .finish( + request, + RhiTradeSourceCompletion::Complete, + now(started_ms + 3_000), + now(started_ms + 5_000), + [admitted_replay(&configuration, &wire, 1_784_347_203)], + ) + .expect("final replay"); + let committed = host + .repositories() + .reconciliation_attempts() + .commit_source_replays(lease, plan, [replay]) + .await + .expect("final commit"); + assert!(!committed.dirty_generation_advanced()); + let manifest = committed + .into_evidence_manifest( + UnixTimeSeconds::new(1_784_347_206), + RhiReconciliationScopePrerequisites::Satisfied, + ) + .expect("final manifest"); + assert_eq!(manifest.trade_generation(), 2); + 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); + ( + root, + runtime, + metadata, + configuration, + host, + lease, + evaluation, + ) +} + async fn attempt_fixture( instance: &str, ) -> ( diff --git a/tests/services_hardening_reconciliation_manifest_contract.rs b/tests/services_hardening_reconciliation_manifest_contract.rs @@ -82,6 +82,18 @@ fn machine_contract_freezes_the_complete_step_191_boundary() { contract["private_reducer_material"]["outcome_owner"], "reconciliation_outcome.v1.json" ); + assert_eq!( + contract["private_finalization_identity"]["attempt_id"], + "exact_step_190_committed_attempt_id" + ); + assert_eq!( + contract["private_finalization_identity"]["job_id"], + "exact_step_190_claimed_job_id" + ); + assert_eq!( + contract["private_finalization_identity"]["canonical_manifest_wire_changed"], + false + ); assert!( !contract["deferred"] .as_array() @@ -120,6 +132,8 @@ fn manifest_boundary_is_sealed_canonical_redacted_and_effect_free() { "i64::try_from(observed_at.get())", "pub const fn shared_manifest_contract_id(&self)", "pub const fn shared_manifest_contract_version(&self)", + "attempt_id: plan.id()", + "job_id: plan.job_id()", ] { assert!( SOURCE.contains(required),