commit 678cc851ab64930404fab50d1ff72e58c20580d8 parent 253c072f820c6a71f4fec4187a1537227196176a Author: triesap <tyson@radroots.org> Date: Mon, 24 Aug 2026 09:04:58 +0000 refactor(rhi): define evidence outcomes - derive claim outcomes from sealed reconciliation projections - preserve indeterminate results for incomplete evidence - freeze stable reason codes and machine contract - qualify the public boundary and refreshed API baseline Diffstat:
13 files changed, 824 insertions(+), 32 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md @@ -141,14 +141,19 @@ 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. + owns durable manifest persistence; reducers, 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. +- Derive claim-specific coverage and outcome only from that sealed projection. + Missing, partial, unsupported, unavailable, ambiguous, unresolved, or absent + evidence is `Indeterminate`; evidence absence never becomes `Invalid`. + Permit `Valid` only for one clean active agreement claim and `Invalid` only + for one clean cancelled claim. Emit only the fixed stable reason vocabulary. - 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 @@ -262,9 +262,19 @@ 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. +or entropy operation. Generation-fenced persistence, reports, attestations, +and publication retain their later checkpoint owners. + +[`reconciliation_outcome.v1.json`](contracts/services_hardening/reconciliation_outcome.v1.json) +derives one claim-specific evaluation from that sealed projection. +Coverage is exactly `Missing`, `Partial`, `ScopeSatisfied`, or `Unsupported`; +outcome is +exactly `Valid`, `Invalid`, or `Indeterminate`. Missing, partial, unsupported, +digest-unavailable, unresolved, ambiguous, or absent-claim evidence is always +indeterminate. Only a clean active agreement claim is valid, and only a clean +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. ## Existing-state runtime foundation diff --git a/contracts/api_baselines/rhi.txt b/contracts/api_baselines/rhi.txt @@ -183,6 +183,19 @@ pub rhi::RhiReconciliationManifestErrorKind::InvalidCommittedInventory pub rhi::RhiReconciliationManifestErrorKind::InvalidObservationTime impl rhi::RhiReconciliationManifestErrorKind pub const fn rhi::RhiReconciliationManifestErrorKind::code(self) -> &'static str +pub enum rhi::RhiReconciliationReasonCode +pub rhi::RhiReconciliationReasonCode::AgreementClaimCancelled +pub rhi::RhiReconciliationReasonCode::AgreementClaimMissing +pub rhi::RhiReconciliationReasonCode::AgreementClaimUnresolved +pub rhi::RhiReconciliationReasonCode::GoverningSchemaUnsupported +pub rhi::RhiReconciliationReasonCode::ProjectionDigestUnavailable +pub rhi::RhiReconciliationReasonCode::ReducerIssueUnresolved +pub rhi::RhiReconciliationReasonCode::RequiredEvidenceMissing +pub rhi::RhiReconciliationReasonCode::RequiredSourceIncomplete +pub rhi::RhiReconciliationReasonCode::RequiredSourceUnsupported +pub rhi::RhiReconciliationReasonCode::ScopeSatisfied +impl rhi::RhiReconciliationReasonCode +pub const fn rhi::RhiReconciliationReasonCode::code(self) -> &'static str pub enum rhi::RhiReconciliationReducerErrorKind pub rhi::RhiReconciliationReducerErrorKind::InvalidManifest pub rhi::RhiReconciliationReducerErrorKind::ProjectionUnavailable @@ -661,6 +674,16 @@ impl core::fmt::Debug for rhi::RhiReconciliationCommitError pub fn rhi::RhiReconciliationCommitError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::fmt::Display for rhi::RhiReconciliationCommitError pub fn rhi::RhiReconciliationCommitError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiReconciliationEvaluation +impl rhi::RhiReconciliationEvaluation +pub const fn rhi::RhiReconciliationEvaluation::claim_mutation_id(&self) -> &radroots_event::id::MutationId +pub const fn rhi::RhiReconciliationEvaluation::contract_version(&self) -> u32 +pub const fn rhi::RhiReconciliationEvaluation::coverage(&self) -> rhi::RhiReconciliationCoverage +pub const fn rhi::RhiReconciliationEvaluation::outcome(&self) -> rhi::RhiReconciliationOutcome +pub const fn rhi::RhiReconciliationEvaluation::projection(&self) -> &rhi::RhiReconciliationProjection +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::RhiReconciliationJob impl rhi::RhiReconciliationJob pub const fn rhi::RhiReconciliationJob::attempt_count(self) -> u16 @@ -750,11 +773,11 @@ pub fn rhi::RhiReconciliationManifestError::fmt(&self, &mut core::fmt::Formatter 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 const fn rhi::RhiReconciliationProjection::digest(&self) -> core::option::Option<[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_projection_digest(&self) -> core::option::Option<[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 @@ -1252,6 +1275,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_OUTCOME_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_REPLAY_CONTRACT_VERSION: u32 pub const rhi::RHI_RUNTIME_ADAPTER_CONTRACT_VERSION: u32 @@ -1305,6 +1329,7 @@ pub fn rhi::CanonicalRhiIdentityAccess::open_existing(&self, &rhi::RhiIdentityEn pub fn rhi::admit_rhi_trade_mutation_event(rhi::RhiTradeMutationAdmissionLimits, &[u8], rhi::RhiTradeMutationObservedAtUnixSeconds, rhi::RhiTradeMutationAuthoredTimePolicy) -> core::result::Result<rhi::RhiAdmittedTradeMutationEvent, rhi::RhiTradeMutationAdmissionError> pub async fn rhi::apply_rhi_configuration(&rhi::RhiRuntimeContext, &rhi::RhiConfigDocumentV1, &rhi::RhiConfigDocumentV1, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiConfigApplyOutcome, rhi::RhiConfigApplyError> pub fn rhi::attest_projection_claim(&radroots_trade::trade_contract_v1::RadrootsTradeProjectionV1, &radroots_event::id::MutationId, &rhi::TradeAgreementAttestationPolicy) -> core::result::Result<rhi::TradeAgreementAttestationReportV1, rhi::TradeAgreementAttestationError> +pub fn rhi::evaluate_rhi_reconciliation_claim(rhi::RhiReconciliationProjection, radroots_event::id::MutationId) -> rhi::RhiReconciliationEvaluation pub async fn rhi::finalize_rhi_state_restore(rhi::RhiStagedStateRestore) -> core::result::Result<(), rhi::RhiStateMaintenanceError> pub async fn rhi::ingest_rhi_trade_source(&rhi::RhiStateRepositories<'_>, &rhi::RhiTransportAdapters, &rhi::RhiConfigDocumentV1, &str, radroots_event::id::TradeId, rhi::RhiTradeSourceAttempt) -> core::result::Result<rhi::RhiTradeSourceIngestOutcome, rhi::RhiTradeSourceIngestError> pub async fn rhi::initialize_rhi_state(&rhi::RhiRuntimeContext, &rhi::RhiStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<(), rhi::RhiStateHostError> @@ -1326,3 +1351,5 @@ pub async fn rhi::stage_rhi_state_restore(&rhi::RhiRuntimeContext, &rhi::RhiStat pub fn rhi::trade_mutation_subscription_kinds() -> alloc::vec::Vec<u32> pub fn rhi::validate_rhi_state_catalogs(&radroots_service_sqlite::migration::MigrationCatalog, &radroots_service_sqlite::integrity::catalog::SchemaCatalog) -> core::result::Result<(), rhi::RhiStateCatalogError> pub fn rhi::verify_rhi_state_backup(&[u8], radroots_service_sqlite::backup::manifest::BackupManifestSha256, &std::path::Path, &rhi::RhiStateMetadata, core::num::nonzero::NonZeroU64) -> core::result::Result<rhi::RhiVerifiedStateBackup, rhi::RhiStateMaintenanceError> +pub type rhi::RhiReconciliationCoverage = radroots_trade::evidence::RadrootsTradeEvidenceCoverageV1 +pub type rhi::RhiReconciliationOutcome = radroots_trade::evidence::RadrootsTradeEvidenceOutcomeV1 diff --git a/contracts/services_hardening/reconciliation_manifest.v1.json b/contracts/services_hardening/reconciliation_manifest.v1.json @@ -83,7 +83,8 @@ "storage": "immutable_reference_counted_bytes", "canonical_manifest_wire_changed": false, "public_raw_access": false, - "reducer_owner": "reconciliation_reducer.v1.json" + "reducer_owner": "reconciliation_reducer.v1.json", + "outcome_owner": "reconciliation_outcome.v1.json" }, "effects": { "sqlite": false, @@ -101,11 +102,10 @@ "arrival_order_selected_truth", "event_authored_time_relabelled_as_observation_time", "manifest_persistence", - "reducer_or_final_outcome_authority" + "manifest_direct_reducer_or_outcome_authority" ], "deferred": [ "wave_qualification", - "coverage_and_outcome_projection", "manifest_persistence", "attestation", "publication", diff --git a/contracts/services_hardening/reconciliation_outcome.v1.json b/contracts/services_hardening/reconciliation_outcome.v1.json @@ -0,0 +1,100 @@ +{ + "schema": "radroots.rhi.reconciliation-outcome", + "schema_version": 1, + "contract_version": 1, + "input": { + "projection_authority": "sealed_step_193_projection_only", + "claim_authority": "exact_typed_mutation_id", + "ownership": "projection_consumed_and_retained_inside_evaluation", + "coverage_source": "exact_owned_manifest_coverage", + "shared_projection_source": "private_step_193_projection" + }, + "coverage": [ + "missing", + "partial", + "scope_satisfied", + "unsupported" + ], + "outcome": [ + "valid", + "invalid", + "indeterminate" + ], + "precedence": [ + "manifest_coverage", + "projection_digest_availability", + "shared_evidence_state", + "reducer_issue_or_claim_ambiguity", + "claim_presence", + "clean_active_claim", + "clean_cancelled_claim", + "unresolved_claim" + ], + "decision": { + "missing": ["indeterminate", "required_evidence_missing"], + "partial": ["indeterminate", "required_source_incomplete"], + "unsupported": ["indeterminate", "required_source_unsupported"], + "projection_digest_unavailable": ["indeterminate", "projection_digest_unavailable"], + "shared_evidence_missing": ["indeterminate", "required_evidence_missing"], + "shared_evidence_partial": ["indeterminate", "required_source_incomplete"], + "shared_schema_unsupported": ["indeterminate", "governing_schema_unsupported"], + "reducer_issue_or_claim_ambiguity": ["indeterminate", "reducer_issue_unresolved"], + "claim_missing": ["indeterminate", "agreement_claim_missing"], + "clean_active_claim": ["valid", "scope_satisfied"], + "clean_cancelled_claim": ["invalid", "agreement_claim_cancelled"], + "claim_unresolved": ["indeterminate", "agreement_claim_unresolved"] + }, + "reason_inventory": { + "cardinality": 1, + "closed": true, + "ordering": "single_primary_reason", + "codes": [ + "required_evidence_missing", + "required_source_incomplete", + "required_source_unsupported", + "projection_digest_unavailable", + "governing_schema_unsupported", + "reducer_issue_unresolved", + "agreement_claim_missing", + "agreement_claim_unresolved", + "scope_satisfied", + "agreement_claim_cancelled" + ], + "raw_source_errors": false + }, + "result": { + "sealed": true, + "caller_forgeable": false, + "retains_projection": true, + "retains_claim": true, + "debug": "coverage_outcome_reason_only", + "error": "none_total_over_sealed_projection" + }, + "effects": { + "sqlite": false, + "filesystem": false, + "source_or_relay": false, + "network": false, + "task_spawn": false, + "ambient_clock": false, + "ambient_entropy": false + }, + "forbidden": [ + "caller_supplied_coverage", + "caller_supplied_outcome", + "caller_supplied_reason", + "evidence_absence_as_invalid", + "valid_or_invalid_without_scope_satisfied", + "raw_source_error_reason", + "report_or_attestation_construction", + "persistence_or_job_finalization" + ], + "deferred": [ + "generation_fenced_commit", + "report_and_attestation", + "manifest_projection_and_report_persistence", + "publication", + "job_finalization", + "integration_wave_qualification" + ] +} diff --git a/contracts/services_hardening/reconciliation_reducer.v1.json b/contracts/services_hardening/reconciliation_reducer.v1.json @@ -45,8 +45,9 @@ "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, + "shared_projection_digest_available": "optional_fail_closed_for_outcome", + "rhi_projection_digest_available": "optional_fail_closed_for_outcome", + "outcome_owner": "reconciliation_outcome.v1.json", "debug": "counts_only_redacted", "errors": "crate_owned_source_free_redacted" }, @@ -66,12 +67,11 @@ "source_or_relay_lookup", "arrival_order_selected_truth", "unbound_projection_digest", - "final_coverage_or_outcome_authority", + "caller_supplied_coverage_or_outcome", "projection_persistence", "report_or_attestation_construction" ], "deferred": [ - "final_coverage_and_outcome", "generation_fenced_commit", "report_and_attestation", "manifest_and_projection_persistence", diff --git a/src/lib.rs b/src/lib.rs @@ -96,8 +96,10 @@ pub use reconciliation_manifest::{ RhiReconciliationScopePrerequisites, }; pub use reconciliation_reducer::{ - RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION, RhiReconciliationProjection, - RhiReconciliationReducerError, RhiReconciliationReducerErrorKind, + RHI_RECONCILIATION_OUTCOME_CONTRACT_VERSION, RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION, + RhiReconciliationCoverage, RhiReconciliationEvaluation, RhiReconciliationOutcome, + RhiReconciliationProjection, RhiReconciliationReasonCode, RhiReconciliationReducerError, + RhiReconciliationReducerErrorKind, evaluate_rhi_reconciliation_claim, reduce_rhi_reconciliation_manifest, }; pub use reconciliation_replay::{ diff --git a/src/reconciliation_reducer.rs b/src/reconciliation_reducer.rs @@ -9,10 +9,10 @@ use radroots_event::{ }; use radroots_trade::{ evidence::{ - RadrootsTradeEvidenceCoverageV1, RadrootsTradeEvidenceStateV1, - RadrootsTradeMutationRecordV1, + RadrootsTradeEvidenceCoverageV1, RadrootsTradeEvidenceOutcomeV1, + RadrootsTradeEvidenceStateV1, RadrootsTradeMutationRecordV1, }, - model::RadrootsTradeProjectionV1, + model::{RadrootsTradeAgreementStateV1, RadrootsTradeProjectionV1}, reducer::{ RADROOTS_TRADE_REDUCER_CONTRACT_ID, RADROOTS_TRADE_REDUCER_VERSION, RadrootsTradeReductionInputV1, reduce_trade_records, @@ -30,6 +30,13 @@ use crate::{ /// Exact version of the RHI reconciliation-reducer binding. pub const RHI_RECONCILIATION_REDUCER_CONTRACT_VERSION: u32 = 1; +/// Exact version of the RHI reconciliation coverage/outcome binding. +pub const RHI_RECONCILIATION_OUTCOME_CONTRACT_VERSION: u32 = 1; + +/// Exact shared four-state reconciliation coverage vocabulary. +pub type RhiReconciliationCoverage = RadrootsTradeEvidenceCoverageV1; +/// Exact shared three-state reconciliation outcome vocabulary. +pub type RhiReconciliationOutcome = RadrootsTradeEvidenceOutcomeV1; const PROJECTION_DIGEST_DOMAIN: &[u8] = b"radroots.rhi.reconciliation_projection.v1\0"; @@ -107,8 +114,8 @@ impl Error for RhiReconciliationReducerError {} pub struct RhiReconciliationProjection { manifest: RhiReconciliationManifest, shared: RadrootsTradeProjectionV1, - shared_projection_digest: [u8; 32], - digest: [u8; 32], + shared_projection_digest: Option<[u8; 32]>, + digest: Option<[u8; 32]>, } impl RhiReconciliationProjection { @@ -144,13 +151,13 @@ impl RhiReconciliationProjection { /// Returns the exact shared projection digest decoded from lowercase hex. #[must_use] - pub const fn shared_projection_digest(&self) -> [u8; 32] { + pub const fn shared_projection_digest(&self) -> Option<[u8; 32]> { self.shared_projection_digest } /// Returns the domain-separated RHI projection digest. #[must_use] - pub const fn digest(&self) -> [u8; 32] { + pub const fn digest(&self) -> Option<[u8; 32]> { self.digest } @@ -167,6 +174,106 @@ impl RhiReconciliationProjection { } } +/// Stable closed reason for one claim-specific reconciliation outcome. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum RhiReconciliationReasonCode { + RequiredEvidenceMissing, + RequiredSourceIncomplete, + RequiredSourceUnsupported, + ProjectionDigestUnavailable, + GoverningSchemaUnsupported, + ReducerIssueUnresolved, + AgreementClaimMissing, + AgreementClaimUnresolved, + ScopeSatisfied, + AgreementClaimCancelled, +} + +impl RhiReconciliationReasonCode { + /// Returns the exact stable lowercase report reason code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::RequiredEvidenceMissing => "required_evidence_missing", + Self::RequiredSourceIncomplete => "required_source_incomplete", + Self::RequiredSourceUnsupported => "required_source_unsupported", + Self::ProjectionDigestUnavailable => "projection_digest_unavailable", + Self::GoverningSchemaUnsupported => "governing_schema_unsupported", + Self::ReducerIssueUnresolved => "reducer_issue_unresolved", + Self::AgreementClaimMissing => "agreement_claim_missing", + Self::AgreementClaimUnresolved => "agreement_claim_unresolved", + Self::ScopeSatisfied => "scope_satisfied", + Self::AgreementClaimCancelled => "agreement_claim_cancelled", + } + } +} + +/// Sealed claim-specific coverage and outcome derived from one projection. +/// +/// Callers cannot construct or relabel an evaluation. +/// +/// ```compile_fail +/// use rhi::RhiReconciliationEvaluation; +/// +/// let _forged = RhiReconciliationEvaluation {}; +/// ``` +pub struct RhiReconciliationEvaluation { + projection: RhiReconciliationProjection, + claim_mutation_id: MutationId, + coverage: RhiReconciliationCoverage, + outcome: RhiReconciliationOutcome, + reason_codes: [RhiReconciliationReasonCode; 1], +} + +impl RhiReconciliationEvaluation { + /// Returns the exact coverage/outcome contract version. + #[must_use] + pub const fn contract_version(&self) -> u32 { + RHI_RECONCILIATION_OUTCOME_CONTRACT_VERSION + } + + /// Returns the sealed projection evaluated for this claim. + #[must_use] + pub const fn projection(&self) -> &RhiReconciliationProjection { + &self.projection + } + + /// Returns the exact typed claim selected by the evaluation. + #[must_use] + pub const fn claim_mutation_id(&self) -> &MutationId { + &self.claim_mutation_id + } + + /// Returns the exact four-state evidence coverage. + #[must_use] + pub const fn coverage(&self) -> RhiReconciliationCoverage { + self.coverage + } + + /// Returns the exact three-state claim outcome. + #[must_use] + pub const fn outcome(&self) -> RhiReconciliationOutcome { + self.outcome + } + + /// Returns the exact bounded stable reason-code inventory. + #[must_use] + pub const fn reason_codes(&self) -> &[RhiReconciliationReasonCode] { + &self.reason_codes + } +} + +impl fmt::Debug for RhiReconciliationEvaluation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiReconciliationEvaluation") + .field("coverage", &self.coverage) + .field("outcome", &self.outcome) + .field("reason_codes", &self.reason_codes) + .finish_non_exhaustive() + } +} + impl fmt::Debug for RhiReconciliationProjection { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter @@ -233,10 +340,10 @@ pub fn reduce_rhi_reconciliation_manifest( 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))?; + let shared_projection_digest = decode_lower_hex_32(shared.projection_digest()); + let digest = shared_projection_digest.and_then(|shared_projection_digest| { + projection_digest(&manifest, shared_projection_digest) + }); Ok(RhiReconciliationProjection { manifest, shared, @@ -245,6 +352,103 @@ pub fn reduce_rhi_reconciliation_manifest( }) } +/// Evaluates one exact typed agreement claim against a sealed projection. +pub fn evaluate_rhi_reconciliation_claim( + projection: RhiReconciliationProjection, + claim_mutation_id: MutationId, +) -> RhiReconciliationEvaluation { + let shared = &projection.shared; + let facts = EvaluationFacts { + coverage: projection.manifest.inner().coverage(), + shared_evidence: shared.evidence_state(), + projection_digest_available: projection.digest.is_some(), + reducer_issue_present: !shared.issues().is_empty(), + claim_present: shared + .agreement_claims() + .iter() + .any(|claim| claim.claim_mutation_id() == &claim_mutation_id), + claim_active: shared + .active_agreement_claim_ids() + .contains(&claim_mutation_id), + claim_contested: shared.contested_claim_ids().contains(&claim_mutation_id), + claim_cancelled: shared.cancelled_claim_ids().contains(&claim_mutation_id), + agreement_agreed: shared.agreement_state() == RadrootsTradeAgreementStateV1::Agreed, + }; + let (outcome, reason) = classify_evaluation(facts); + RhiReconciliationEvaluation { + projection, + claim_mutation_id, + coverage: facts.coverage, + outcome, + reason_codes: [reason], + } +} + +#[derive(Clone, Copy)] +struct EvaluationFacts { + coverage: RhiReconciliationCoverage, + shared_evidence: RadrootsTradeEvidenceStateV1, + projection_digest_available: bool, + reducer_issue_present: bool, + claim_present: bool, + claim_active: bool, + claim_contested: bool, + claim_cancelled: bool, + agreement_agreed: bool, +} + +fn classify_evaluation( + facts: EvaluationFacts, +) -> (RhiReconciliationOutcome, RhiReconciliationReasonCode) { + use RadrootsTradeEvidenceOutcomeV1::{Indeterminate, Invalid, Valid}; + use RhiReconciliationReasonCode::{ + AgreementClaimCancelled, AgreementClaimMissing, AgreementClaimUnresolved, + GoverningSchemaUnsupported, ProjectionDigestUnavailable, ReducerIssueUnresolved, + RequiredEvidenceMissing, RequiredSourceIncomplete, RequiredSourceUnsupported, + ScopeSatisfied, + }; + + match facts.coverage { + RhiReconciliationCoverage::Missing => return (Indeterminate, RequiredEvidenceMissing), + RhiReconciliationCoverage::Partial => return (Indeterminate, RequiredSourceIncomplete), + RhiReconciliationCoverage::Unsupported => { + return (Indeterminate, RequiredSourceUnsupported); + } + RhiReconciliationCoverage::ScopeSatisfied => {} + } + if !facts.projection_digest_available { + return (Indeterminate, ProjectionDigestUnavailable); + } + match facts.shared_evidence { + RadrootsTradeEvidenceStateV1::Missing => { + return (Indeterminate, RequiredEvidenceMissing); + } + RadrootsTradeEvidenceStateV1::QueryPartial => { + return (Indeterminate, RequiredSourceIncomplete); + } + RadrootsTradeEvidenceStateV1::UnsupportedVersion => { + return (Indeterminate, GoverningSchemaUnsupported); + } + RadrootsTradeEvidenceStateV1::Complete => {} + } + if facts.reducer_issue_present + || facts.claim_contested + || (facts.claim_active && facts.claim_cancelled) + { + return (Indeterminate, ReducerIssueUnresolved); + } + if !facts.claim_present { + return (Indeterminate, AgreementClaimMissing); + } + if facts.claim_active && facts.agreement_agreed { + return (Valid, ScopeSatisfied); + } + if facts.claim_cancelled && !facts.claim_active { + return (Invalid, AgreementClaimCancelled); + } + (Indeterminate, AgreementClaimUnresolved) +} + fn mutation_material_within_bounds(materials: &[RhiReducerMutationMaterial]) -> bool { material_lengths_within_bounds( materials.len(), @@ -323,6 +527,20 @@ const fn failure(kind: RhiReconciliationReducerErrorKind) -> RhiReconciliationRe mod tests { use super::*; + fn scope_satisfied_facts() -> EvaluationFacts { + EvaluationFacts { + coverage: RhiReconciliationCoverage::ScopeSatisfied, + shared_evidence: RadrootsTradeEvidenceStateV1::Complete, + projection_digest_available: true, + reducer_issue_present: false, + claim_present: true, + claim_active: true, + claim_contested: false, + claim_cancelled: false, + agreement_agreed: true, + } + } + #[test] fn diagnostics_and_digest_decoder_are_closed() { for kind in [ @@ -355,4 +573,192 @@ mod tests { )); assert!(!material_lengths_within_bounds(1, [usize::MAX, 1])); } + + #[test] + fn coverage_and_claim_state_matrix_is_total_and_fail_closed() { + use RadrootsTradeEvidenceCoverageV1::{Missing, Partial, ScopeSatisfied, Unsupported}; + use RadrootsTradeEvidenceOutcomeV1::{Indeterminate, Invalid, Valid}; + use RhiReconciliationReasonCode::{ + AgreementClaimCancelled, AgreementClaimMissing, RequiredEvidenceMissing, + RequiredSourceIncomplete, RequiredSourceUnsupported, ScopeSatisfied as ScopeReason, + }; + + for (coverage, incomplete_reason) in [ + (Missing, RequiredEvidenceMissing), + (Partial, RequiredSourceIncomplete), + (Unsupported, RequiredSourceUnsupported), + ] { + for (present, active, cancelled) in [ + (false, false, false), + (true, true, false), + (true, false, true), + ] { + let actual = classify_evaluation(EvaluationFacts { + coverage, + claim_present: present, + claim_active: active, + claim_cancelled: cancelled, + ..scope_satisfied_facts() + }); + assert_eq!(actual, (Indeterminate, incomplete_reason)); + assert!(coverage.permits(actual.0)); + } + } + + for (present, active, cancelled, expected) in [ + (false, false, false, (Indeterminate, AgreementClaimMissing)), + (true, true, false, (Valid, ScopeReason)), + (true, false, true, (Invalid, AgreementClaimCancelled)), + ] { + let actual = classify_evaluation(EvaluationFacts { + coverage: ScopeSatisfied, + claim_present: present, + claim_active: active, + claim_cancelled: cancelled, + ..scope_satisfied_facts() + }); + assert_eq!(actual, expected); + assert!(ScopeSatisfied.permits(actual.0)); + } + } + + #[test] + fn fail_closed_precedence_covers_every_unavailable_or_ambiguous_fact() { + use RadrootsTradeEvidenceOutcomeV1::Indeterminate; + use RhiReconciliationReasonCode::{ + AgreementClaimUnresolved, GoverningSchemaUnsupported, ProjectionDigestUnavailable, + ReducerIssueUnresolved, RequiredEvidenceMissing, RequiredSourceIncomplete, + }; + + let cases = [ + ( + EvaluationFacts { + projection_digest_available: false, + ..scope_satisfied_facts() + }, + ProjectionDigestUnavailable, + ), + ( + EvaluationFacts { + shared_evidence: RadrootsTradeEvidenceStateV1::Missing, + ..scope_satisfied_facts() + }, + RequiredEvidenceMissing, + ), + ( + EvaluationFacts { + shared_evidence: RadrootsTradeEvidenceStateV1::QueryPartial, + ..scope_satisfied_facts() + }, + RequiredSourceIncomplete, + ), + ( + EvaluationFacts { + shared_evidence: RadrootsTradeEvidenceStateV1::UnsupportedVersion, + ..scope_satisfied_facts() + }, + GoverningSchemaUnsupported, + ), + ( + EvaluationFacts { + reducer_issue_present: true, + ..scope_satisfied_facts() + }, + ReducerIssueUnresolved, + ), + ( + EvaluationFacts { + claim_contested: true, + ..scope_satisfied_facts() + }, + ReducerIssueUnresolved, + ), + ( + EvaluationFacts { + claim_cancelled: true, + ..scope_satisfied_facts() + }, + ReducerIssueUnresolved, + ), + ( + EvaluationFacts { + agreement_agreed: false, + ..scope_satisfied_facts() + }, + AgreementClaimUnresolved, + ), + ( + EvaluationFacts { + claim_active: false, + claim_cancelled: false, + ..scope_satisfied_facts() + }, + AgreementClaimUnresolved, + ), + ]; + for (facts, reason) in cases { + assert_eq!(classify_evaluation(facts), (Indeterminate, reason)); + } + + let precedence = classify_evaluation(EvaluationFacts { + coverage: RhiReconciliationCoverage::Missing, + projection_digest_available: false, + shared_evidence: RadrootsTradeEvidenceStateV1::UnsupportedVersion, + reducer_issue_present: true, + claim_present: false, + ..scope_satisfied_facts() + }); + assert_eq!(precedence, (Indeterminate, RequiredEvidenceMissing)); + } + + #[test] + fn reason_codes_are_closed_stable_and_source_free() { + let inventory = [ + ( + RhiReconciliationReasonCode::RequiredEvidenceMissing, + "required_evidence_missing", + ), + ( + RhiReconciliationReasonCode::RequiredSourceIncomplete, + "required_source_incomplete", + ), + ( + RhiReconciliationReasonCode::RequiredSourceUnsupported, + "required_source_unsupported", + ), + ( + RhiReconciliationReasonCode::ProjectionDigestUnavailable, + "projection_digest_unavailable", + ), + ( + RhiReconciliationReasonCode::GoverningSchemaUnsupported, + "governing_schema_unsupported", + ), + ( + RhiReconciliationReasonCode::ReducerIssueUnresolved, + "reducer_issue_unresolved", + ), + ( + RhiReconciliationReasonCode::AgreementClaimMissing, + "agreement_claim_missing", + ), + ( + RhiReconciliationReasonCode::AgreementClaimUnresolved, + "agreement_claim_unresolved", + ), + ( + RhiReconciliationReasonCode::ScopeSatisfied, + "scope_satisfied", + ), + ( + RhiReconciliationReasonCode::AgreementClaimCancelled, + "agreement_claim_cancelled", + ), + ]; + assert_eq!(inventory.len(), 10); + for (reason, code) in inventory { + assert_eq!(reason.code(), code); + assert!(!code.contains("source://")); + } + } } diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -27,6 +27,8 @@ 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 RECONCILIATION_OUTCOME_CONTRACT: &str = + include_str!("../contracts/services_hardening/reconciliation_outcome.v1.json"); const RUNTIME_FOUNDATION_CONTRACT: &str = include_str!("../contracts/services_hardening/runtime_foundation.v1.json"); const TRADE_INGEST_CONTRACT: &str = @@ -166,7 +168,13 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "RhiReconciliationManifestErrorKind", "RhiReconciliationScopePrerequisites", "RhiReconciliationProjection", + "RhiReconciliationEvaluation", + "RhiReconciliationCoverage", + "RhiReconciliationOutcome", + "RhiReconciliationReasonCode", + "RHI_RECONCILIATION_OUTCOME_CONTRACT_VERSION", "RhiReconciliationReducerErrorKind", + "evaluate_rhi_reconciliation_claim", "reduce_rhi_reconciliation_manifest", "RhiReconciliationSourceReplayPlan", "RhiReconciliationSourceReplay", @@ -323,6 +331,46 @@ fn reconciliation_reducer_is_manifest_bound_sealed_and_effect_free() { } #[test] +fn reconciliation_outcome_is_projection_bound_total_and_effect_free() { + let contract: serde_json::Value = serde_json::from_str(RECONCILIATION_OUTCOME_CONTRACT) + .expect("reconciliation-outcome contract"); + assert_eq!(contract["schema"], "radroots.rhi.reconciliation-outcome"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["coverage"].as_array().expect("coverage").len(), 4); + assert_eq!(contract["outcome"].as_array().expect("outcome").len(), 3); + assert_eq!(contract["reason_inventory"]["cardinality"], 1); + assert_eq!(contract["effects"]["sqlite"], false); + for required in [ + "pub fn evaluate_rhi_reconciliation_claim(", + "RhiReconciliationEvaluation", + "RhiReconciliationReasonCode", + "classify_evaluation(facts)", + "projection: RhiReconciliationProjection", + "claim_mutation_id: MutationId", + ] { + assert!( + RECONCILIATION_REDUCER.contains(required), + "reconciliation outcome is missing {required}" + ); + } + for forbidden in [ + "sqlx::", + "std::fs", + "std::net", + "tokio::", + "SystemTime", + "caller_supplied_outcome", + ] { + assert!( + !RECONCILIATION_REDUCER.contains(forbidden), + "reconciliation outcome gained forbidden authority {forbidden}" + ); + } + assert!(!PUBLIC_API.contains("rhi::reconciliation_reducer::")); + assert!(!PUBLIC_API.contains("RhiReconciliationEvaluation {")); +} + +#[test] fn public_errors_are_crate_owned_redacted_and_source_free() { let production = SOURCES.join("\n"); assert!(!production.contains("fn source(")); @@ -777,7 +825,9 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() { "## 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", + "[`reconciliation_outcome.v1.json`](contracts/services_hardening/reconciliation_outcome.v1.json)", + "Coverage is exactly `Missing`, `Partial`, `ScopeSatisfied`, or `Unsupported`", + "Missing, partial, unsupported,", "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 @@ -1065,20 +1065,43 @@ async fn source_replay_commit_is_atomic_idempotent_and_mints_durable_cursor_evid assert_eq!(projection.issue_count(), 0); assert_eq!( projection.shared_projection_digest(), - [ + Some([ 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(), - [ + Some([ 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, - ] + ]) + ); + let claim = *projection.root_mutation_id().expect("root proposal"); + let evaluation = rhi::evaluate_rhi_reconciliation_claim(projection, claim); + assert_eq!(evaluation.contract_version(), 1); + assert_eq!( + evaluation.coverage(), + rhi::RhiReconciliationCoverage::ScopeSatisfied + ); + assert_eq!( + evaluation.outcome(), + rhi::RhiReconciliationOutcome::Indeterminate + ); + assert_eq!( + evaluation.reason_codes(), + [rhi::RhiReconciliationReasonCode::AgreementClaimMissing] + ); + assert_eq!(evaluation.claim_mutation_id(), &claim); + assert_eq!( + evaluation.projection().trade_id(), + &TradeId::from_bytes([0x11; 16]) ); + let evaluation_debug = format!("{evaluation:?}"); + assert!(!evaluation_debug.contains(&format!("{claim:?}"))); + assert!(!evaluation_debug.contains("d133a772")); 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 @@ -78,12 +78,22 @@ fn machine_contract_freezes_the_complete_step_191_boundary() { contract["private_reducer_material"]["maximum_canonical_content_bytes"], 134_217_728 ); + assert_eq!( + contract["private_reducer_material"]["outcome_owner"], + "reconciliation_outcome.v1.json" + ); assert!( !contract["deferred"] .as_array() .expect("deferred inventory") .contains(&json!("lineage_reducer")) ); + assert!( + !contract["deferred"] + .as_array() + .expect("deferred inventory") + .contains(&json!("coverage_and_outcome_projection")) + ); } #[test] diff --git a/tests/services_hardening_reconciliation_outcome_contract.rs b/tests/services_hardening_reconciliation_outcome_contract.rs @@ -0,0 +1,147 @@ +#![forbid(unsafe_code)] + +use rhi::RHI_RECONCILIATION_OUTCOME_CONTRACT_VERSION; +use serde_json::json; + +const CONTRACT: &str = + include_str!("../contracts/services_hardening/reconciliation_outcome.v1.json"); +const ROOT: &str = include_str!("../src/lib.rs"); +const SOURCE: &str = include_str!("../src/reconciliation_reducer.rs"); +const README: &str = include_str!("../README"); + +#[test] +fn machine_contract_freezes_the_complete_step_194_boundary() { + let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract"); + assert_eq!(contract["schema"], "radroots.rhi.reconciliation-outcome"); + assert_eq!(contract["schema_version"], 1); + assert_eq!( + contract["contract_version"], + RHI_RECONCILIATION_OUTCOME_CONTRACT_VERSION + ); + assert_eq!( + contract["coverage"], + json!(["missing", "partial", "scope_satisfied", "unsupported"]) + ); + assert_eq!( + contract["outcome"], + json!(["valid", "invalid", "indeterminate"]) + ); + assert_eq!( + contract["precedence"], + json!([ + "manifest_coverage", + "projection_digest_availability", + "shared_evidence_state", + "reducer_issue_or_claim_ambiguity", + "claim_presence", + "clean_active_claim", + "clean_cancelled_claim", + "unresolved_claim" + ]) + ); + assert_eq!( + contract["decision"]["clean_active_claim"], + json!(["valid", "scope_satisfied"]) + ); + assert_eq!( + contract["decision"]["clean_cancelled_claim"], + json!(["invalid", "agreement_claim_cancelled"]) + ); + for branch in [ + "missing", + "partial", + "unsupported", + "projection_digest_unavailable", + "shared_evidence_missing", + "shared_evidence_partial", + "shared_schema_unsupported", + "reducer_issue_or_claim_ambiguity", + "claim_missing", + "claim_unresolved", + ] { + assert_eq!( + contract["decision"][branch][0], "indeterminate", + "{branch} must remain fail closed" + ); + } + assert_eq!(contract["reason_inventory"]["cardinality"], 1); + assert_eq!(contract["reason_inventory"]["closed"], true); + assert_eq!( + contract["reason_inventory"]["codes"], + json!([ + "required_evidence_missing", + "required_source_incomplete", + "required_source_unsupported", + "projection_digest_unavailable", + "governing_schema_unsupported", + "reducer_issue_unresolved", + "agreement_claim_missing", + "agreement_claim_unresolved", + "scope_satisfied", + "agreement_claim_cancelled" + ]) + ); + assert_eq!(contract["result"]["caller_forgeable"], false); + assert_eq!(contract["result"]["retains_projection"], true); + for effect in [ + "sqlite", + "filesystem", + "source_or_relay", + "network", + "task_spawn", + "ambient_clock", + "ambient_entropy", + ] { + assert_eq!(contract["effects"][effect], false, "effect {effect}"); + } +} + +#[test] +fn outcome_boundary_is_sealed_typed_redacted_and_total() { + for required in [ + "RhiReconciliationEvaluation", + "RhiReconciliationCoverage", + "RhiReconciliationOutcome", + "RhiReconciliationReasonCode", + "RHI_RECONCILIATION_OUTCOME_CONTRACT_VERSION", + "evaluate_rhi_reconciliation_claim", + ] { + assert!(ROOT.contains(required), "root API is missing {required}"); + } + for required in [ + "projection: RhiReconciliationProjection", + "claim_mutation_id: MutationId", + "reason_codes: [RhiReconciliationReasonCode; 1]", + "fn classify_evaluation(", + "RadrootsTradeEvidenceStateV1::UnsupportedVersion", + "RhiReconciliationCoverage::ScopeSatisfied", + "(Invalid, AgreementClaimCancelled)", + "(Indeterminate, AgreementClaimUnresolved)", + ] { + assert!( + SOURCE.contains(required), + "outcome boundary is missing {required}" + ); + } + for forbidden in [ + "sqlx::", + "std::fs", + "std::net", + "tokio::", + "SystemTime", + "thread_rng", + "OsRng", + "pub fn new(", + "pub const fn new(", + "Result<RhiReconciliationEvaluation", + ] { + assert!( + !SOURCE.contains(forbidden), + "outcome boundary gained forbidden authority {forbidden}" + ); + } + assert!(README.contains("## Pure reconciliation reducer")); + assert!(README.contains( + "[`reconciliation_outcome.v1.json`](contracts/services_hardening/reconciliation_outcome.v1.json)" + )); +} diff --git a/tests/services_hardening_reconciliation_reducer_contract.rs b/tests/services_hardening_reconciliation_reducer_contract.rs @@ -57,6 +57,18 @@ fn machine_contract_freezes_the_complete_step_193_boundary() { assert_eq!(contract["effects"]["source_or_relay"], false); assert_eq!(contract["effects"]["ambient_clock"], false); assert_eq!(contract["effects"]["ambient_entropy"], false); + assert_eq!( + contract["result"]["shared_projection_digest_available"], + "optional_fail_closed_for_outcome" + ); + assert_eq!( + contract["result"]["rhi_projection_digest_available"], + "optional_fail_closed_for_outcome" + ); + assert_eq!( + contract["result"]["outcome_owner"], + "reconciliation_outcome.v1.json" + ); } #[test]