rhi

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

commit 19f9fc20b3033fa302bf40197a14a43e003f6bc8
parent f7288bd533d9e381947a24567f02e61a8fc33290
Author: triesap <tyson@radroots.org>
Date:   Mon, 24 Aug 2026 12:24:38 +0000

refactor(rhi): define publication attempt evidence

Diffstat:
MAGENTS.md | 8++++++++
MREADME | 25++++++++++++++++++++++---
Mcontracts/api_baselines/rhi.txt | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/services_hardening/publication_attempt_evidence.v1.json | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 8++++++++
Asrc/publication_attempt.rs | 540+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/publication_submission.rs | 15+++++++++++++++
Mtests/package_boundary.rs | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Atests/services_hardening_publication_attempt_evidence_contract.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 923 insertions(+), 4 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -292,6 +292,14 @@ the bounded stored-byte digest on every read, including after reopen. The capability is not claim authority; later relay execution must borrow its exact byte slice without parsing or reconstruction. +- Model publication target and attempt evidence only with the closed pending, + submitted, accepted, rejected, rate-limited, auth-required, failed, and + unknown vocabulary. Attempt evidence must derive its identity from the + sealed committed outbox and exact event digest plus bounded target ordinal + and attempt number; never accept caller-supplied identities, arbitrary result + codes, raw relay diagnostics, reversed timestamps, or implicit clock reads. + The model is not claim, transition, or relay authority. The durable executor + must revalidate every live target, lease, revision, attempt, and byte binding. - Commit one exact reconciliation-attempt replay inventory only through the typed attempt repository. Revalidate the exact live lease, dirty generation, evidence policy, and every scoped prior checkpoint before mutation; persist diff --git a/README b/README @@ -377,11 +377,30 @@ return the same committed byte string or fail closed. The capability is not a relay claim or lease. Its only payload accessor returns the stored bytes unchanged; this path contains no JSON or event parsing, rebuilding, reserialization, signing, relay/network/filesystem work, task -spawn, or ambient clock/entropy access. Target states, attempt evidence, relay -I/O, retry scheduling, and lease recovery remain with Steps 201-202. The exact -machine contract is +spawn, or ambient clock/entropy access. The closed target/attempt evidence model +is defined below; durable claims and transitions, relay I/O, retry scheduling, +and lease recovery remain with Step 202. The exact machine contract is [`publication_submission.v1.json`](contracts/services_hardening/publication_submission.v1.json). +## Bounded publication attempt evidence + +The publication target state is one closed value: pending, submitted, accepted, +rejected, rate-limited, auth-required, failed, or unknown. Pending is not an +attempt outcome, Accepted is the sole terminal target state, submission alone +does not prove relay delivery, and Unknown can be refined only by independent +evidence. Attempt outcomes use the same closed vocabulary except Pending and +expose no arbitrary result string or upstream diagnostic. + +`RhiPublicationAttemptEvidence` binds the sealed committed outbox identity and +exact event digest to a zero-based target ordinal no greater than 31, a +one-based attempt number no greater than 100, ordered injected integer UTC +milliseconds, and one closed outcome under a domain-separated SHA-256 identity. +It is pure bounded evidence rather than claim, transition, or relay authority. +Step 202 must revalidate live target, lease, revision, attempt, and exact-byte +bindings when it persists Submitted before I/O and later commits an observed +outcome. The exact machine contract is +[`publication_attempt_evidence.v1.json`](contracts/services_hardening/publication_attempt_evidence.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 @@ -133,6 +133,23 @@ pub enum rhi::RhiPresenceCommandV1 pub rhi::RhiPresenceCommandV1::Desired pub rhi::RhiPresenceCommandV1::Refresh pub rhi::RhiPresenceCommandV1::Render +pub enum rhi::RhiPublicationAttemptEvidenceErrorKind +pub rhi::RhiPublicationAttemptEvidenceErrorKind::InvalidAttemptNumber +pub rhi::RhiPublicationAttemptEvidenceErrorKind::InvalidTargetOrdinal +pub rhi::RhiPublicationAttemptEvidenceErrorKind::InvalidTime +impl rhi::RhiPublicationAttemptEvidenceErrorKind +pub const fn rhi::RhiPublicationAttemptEvidenceErrorKind::code(self) -> &'static str +pub enum rhi::RhiPublicationAttemptOutcome +pub rhi::RhiPublicationAttemptOutcome::Accepted +pub rhi::RhiPublicationAttemptOutcome::AuthRequired +pub rhi::RhiPublicationAttemptOutcome::Failed +pub rhi::RhiPublicationAttemptOutcome::RateLimited +pub rhi::RhiPublicationAttemptOutcome::Rejected +pub rhi::RhiPublicationAttemptOutcome::Submitted +pub rhi::RhiPublicationAttemptOutcome::Unknown +impl rhi::RhiPublicationAttemptOutcome +pub const fn rhi::RhiPublicationAttemptOutcome::code(self) -> &'static str +pub const fn rhi::RhiPublicationAttemptOutcome::target_state(self) -> rhi::RhiPublicationTargetState pub enum rhi::RhiPublicationCommandV1 pub rhi::RhiPublicationCommandV1::Backlog pub rhi::RhiPublicationCommandV1::Retry @@ -153,6 +170,18 @@ pub rhi::RhiPublicationSubmissionErrorKind::NotFound pub rhi::RhiPublicationSubmissionErrorKind::Storage impl rhi::RhiPublicationSubmissionErrorKind pub const fn rhi::RhiPublicationSubmissionErrorKind::code(self) -> &'static str +pub enum rhi::RhiPublicationTargetState +pub rhi::RhiPublicationTargetState::Accepted +pub rhi::RhiPublicationTargetState::AuthRequired +pub rhi::RhiPublicationTargetState::Failed +pub rhi::RhiPublicationTargetState::Pending +pub rhi::RhiPublicationTargetState::RateLimited +pub rhi::RhiPublicationTargetState::Rejected +pub rhi::RhiPublicationTargetState::Submitted +pub rhi::RhiPublicationTargetState::Unknown +impl rhi::RhiPublicationTargetState +pub const fn rhi::RhiPublicationTargetState::code(self) -> &'static str +pub const fn rhi::RhiPublicationTargetState::is_accepted_terminal(self) -> bool pub enum rhi::RhiReconciliationAttemptErrorKind pub rhi::RhiReconciliationAttemptErrorKind::InvalidConfiguration pub rhi::RhiReconciliationAttemptErrorKind::InvalidInput @@ -669,6 +698,34 @@ pub const fn rhi::RhiProvenanceRepository<'_>::descriptor(&self) -> rhi::RhiStat pub const fn rhi::RhiProvenanceRepository<'_>::kind(&self) -> rhi::RhiStateRepositoryKind impl core::fmt::Debug for rhi::RhiProvenanceRepository<'_> pub fn rhi::RhiProvenanceRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPublicationAttemptEvidence +impl rhi::RhiPublicationAttemptEvidence +pub const fn rhi::RhiPublicationAttemptEvidence::attempt_number(&self) -> u16 +pub const fn rhi::RhiPublicationAttemptEvidence::event_sha256(&self) -> &[u8; 32] +pub const fn rhi::RhiPublicationAttemptEvidence::finished_at(&self) -> rhi::RhiPublicationUnixMilliseconds +pub const fn rhi::RhiPublicationAttemptEvidence::id(&self) -> rhi::RhiPublicationAttemptId +pub fn rhi::RhiPublicationAttemptEvidence::new(&rhi::RhiCommittedPublication, u32, u16, rhi::RhiPublicationUnixMilliseconds, rhi::RhiPublicationUnixMilliseconds, rhi::RhiPublicationAttemptOutcome) -> core::result::Result<Self, rhi::RhiPublicationAttemptEvidenceError> +pub const fn rhi::RhiPublicationAttemptEvidence::outbox_id(&self) -> rhi::RhiPublicationOutboxId +pub const fn rhi::RhiPublicationAttemptEvidence::outcome(&self) -> rhi::RhiPublicationAttemptOutcome +pub const fn rhi::RhiPublicationAttemptEvidence::result_code(&self) -> &'static str +pub const fn rhi::RhiPublicationAttemptEvidence::started_at(&self) -> rhi::RhiPublicationUnixMilliseconds +pub const fn rhi::RhiPublicationAttemptEvidence::target_ordinal(&self) -> u8 +impl core::fmt::Debug for rhi::RhiPublicationAttemptEvidence +pub fn rhi::RhiPublicationAttemptEvidence::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPublicationAttemptEvidenceError +impl rhi::RhiPublicationAttemptEvidenceError +pub const fn rhi::RhiPublicationAttemptEvidenceError::code(self) -> &'static str +pub const fn rhi::RhiPublicationAttemptEvidenceError::kind(self) -> rhi::RhiPublicationAttemptEvidenceErrorKind +impl core::error::Error for rhi::RhiPublicationAttemptEvidenceError +impl core::fmt::Debug for rhi::RhiPublicationAttemptEvidenceError +pub fn rhi::RhiPublicationAttemptEvidenceError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for rhi::RhiPublicationAttemptEvidenceError +pub fn rhi::RhiPublicationAttemptEvidenceError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPublicationAttemptId(_) +impl rhi::RhiPublicationAttemptId +pub const fn rhi::RhiPublicationAttemptId::as_bytes(&self) -> &[u8; 32] +impl core::fmt::Debug for rhi::RhiPublicationAttemptId +pub fn rhi::RhiPublicationAttemptId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiPublicationAttemptRepository<'host> impl rhi::RhiPublicationAttemptRepository<'_> pub const fn rhi::RhiPublicationAttemptRepository<'_>::descriptor(&self) -> rhi::RhiStateRepositoryDescriptor @@ -739,6 +796,10 @@ pub const fn rhi::RhiPublicationTargetRepository<'_>::descriptor(&self) -> rhi:: pub const fn rhi::RhiPublicationTargetRepository<'_>::kind(&self) -> rhi::RhiStateRepositoryKind impl core::fmt::Debug for rhi::RhiPublicationTargetRepository<'_> pub fn rhi::RhiPublicationTargetRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPublicationUnixMilliseconds(_) +impl rhi::RhiPublicationUnixMilliseconds +pub const fn rhi::RhiPublicationUnixMilliseconds::get(self) -> u64 +pub fn rhi::RhiPublicationUnixMilliseconds::new(u64) -> core::result::Result<Self, rhi::RhiPublicationAttemptEvidenceError> pub struct rhi::RhiReconciliationAttemptError impl rhi::RhiReconciliationAttemptError pub const fn rhi::RhiReconciliationAttemptError::code(self) -> &'static str @@ -1443,10 +1504,13 @@ pub const rhi::RHI_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION: u32 pub const rhi::RHI_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES: usize pub const rhi::RHI_MIGRATION_CATALOG_SHA256: [u8; 32] pub const rhi::RHI_PROVIDER_CONTRACT_VERSION: u32 +pub const rhi::RHI_PUBLICATION_ATTEMPT_EVIDENCE_CONTRACT_VERSION: u32 +pub const rhi::RHI_PUBLICATION_ATTEMPT_NUMBER_MAXIMUM: u16 pub const rhi::RHI_PUBLICATION_CONTRACT_VERSION: u32 pub const rhi::RHI_PUBLICATION_MAX_ATTEMPTS: u16 pub const rhi::RHI_PUBLICATION_MAX_TARGETS: usize pub const rhi::RHI_PUBLICATION_SUBMISSION_CONTRACT_VERSION: u32 +pub const rhi::RHI_PUBLICATION_TARGET_ORDINAL_MAXIMUM: u32 pub const rhi::RHI_RECONCILIATION_ATTEMPT_CONTRACT_VERSION: u32 pub const rhi::RHI_RECONCILIATION_ATTEMPT_MAX_SOURCES: usize pub const rhi::RHI_RECONCILIATION_ATTESTATION_CONTRACT_VERSION: u32 diff --git a/contracts/services_hardening/publication_attempt_evidence.v1.json b/contracts/services_hardening/publication_attempt_evidence.v1.json @@ -0,0 +1,99 @@ +{ + "schema": "radroots.rhi.publication-attempt-evidence", + "schema_version": 1, + "contract_version": 1, + "target_states": [ + "pending", + "submitted", + "accepted", + "rejected", + "rate_limited", + "auth_required", + "failed", + "unknown" + ], + "attempt_outcomes": [ + "submitted", + "accepted", + "rejected", + "rate_limited", + "auth_required", + "failed", + "unknown" + ], + "bounds": { + "target_ordinal": { "minimum": 0, "maximum": 31 }, + "attempt_number": { "minimum": 1, "maximum": 100 }, + "unix_milliseconds": { "minimum": 0, "maximum": 9223372036854775807 }, + "finished_at": "greater_than_or_equal_to_started_at" + }, + "attempt_identity": { + "algorithm": "sha256", + "domain": "radroots.rhi.publication_attempt.v1\u0000", + "framing": [ + "outbox_id_32_bytes", + "exact_committed_event_sha256_32_bytes", + "target_ordinal_u32_be", + "attempt_number_u32_be" + ] + }, + "evidence": { + "sealed_type": "RhiPublicationAttemptEvidence", + "forgeable": false, + "serializable": false, + "debug": "redacted", + "bound_fields": [ + "attempt_id", + "outbox_id", + "exact_committed_event_sha256", + "target_ordinal", + "attempt_number", + "started_at_unix_ms", + "finished_at_unix_ms", + "outcome" + ], + "result_code": "exact_closed_outcome_code", + "raw_relay_result_or_error": false + }, + "semantics": { + "pending_is_attempt_outcome": false, + "accepted_target_is_terminal": true, + "submission_alone_proves_delivery": false, + "unknown_may_be_refined_only_by_independent_evidence": true, + "constructor_is_claim_or_transition_authority": false + }, + "effects": { + "sqlite_read_or_mutation": false, + "relay_or_network": false, + "event_parse_rebuild_serialize_or_sign": false, + "task_spawn": false, + "ambient_clock": false, + "ambient_entropy": false + }, + "deferred_to_step_202": [ + "publication_claim_and_lease", + "durable_submitted_before_io", + "relay_submission", + "compare_and_swap_outcome_commit", + "cancellation_and_lost_acknowledgement", + "backoff_and_retry_schedule", + "lease_and_restart_recovery", + "unknown_outcome_reconciliation" + ], + "forbidden": [ + "arbitrary_target_state", + "arbitrary_attempt_outcome", + "arbitrary_result_code", + "raw_relay_error_text", + "unbounded_target_ordinal", + "unbounded_attempt_number", + "unbounded_or_reversed_time", + "caller_supplied_outbox_or_event_digest", + "sqlite_authority", + "relay_io", + "event_deserialization", + "event_rebuild", + "event_reserialization", + "event_resigning" + ] +} diff --git a/src/lib.rs b/src/lib.rs @@ -9,6 +9,7 @@ mod features; mod identity_credential; mod identity_envelope; mod publication; +mod publication_attempt; mod publication_submission; mod reconciliation_attempt; mod reconciliation_attestation; @@ -72,6 +73,13 @@ pub use publication::{ RhiPublicationAuthority, RhiPublicationError, RhiPublicationErrorKind, RhiPublicationMode, RhiPublicationRetryPolicy, RhiPublicationTarget, }; +pub use publication_attempt::{ + RHI_PUBLICATION_ATTEMPT_EVIDENCE_CONTRACT_VERSION, RHI_PUBLICATION_ATTEMPT_NUMBER_MAXIMUM, + RHI_PUBLICATION_TARGET_ORDINAL_MAXIMUM, RhiPublicationAttemptEvidence, + RhiPublicationAttemptEvidenceError, RhiPublicationAttemptEvidenceErrorKind, + RhiPublicationAttemptId, RhiPublicationAttemptOutcome, RhiPublicationTargetState, + RhiPublicationUnixMilliseconds, +}; pub use publication_submission::{ RHI_PUBLICATION_SUBMISSION_CONTRACT_VERSION, RhiCommittedPublication, RhiPublicationOutboxId, RhiPublicationSubmissionError, RhiPublicationSubmissionErrorKind, diff --git a/src/publication_attempt.rs b/src/publication_attempt.rs @@ -0,0 +1,540 @@ +//! Closed publication target states and bounded attempt evidence. + +use core::fmt; +use std::error::Error; + +use sha2::{Digest as _, Sha256}; + +use crate::RhiCommittedPublication; + +/// Exact version of the publication-attempt evidence contract. +pub const RHI_PUBLICATION_ATTEMPT_EVIDENCE_CONTRACT_VERSION: u32 = 1; + +/// Absolute target ordinal ceiling inherited from the 32-target publication bound. +pub const RHI_PUBLICATION_TARGET_ORDINAL_MAXIMUM: u32 = 31; + +/// Absolute durable attempt ceiling inherited from the publication contract. +pub const RHI_PUBLICATION_ATTEMPT_NUMBER_MAXIMUM: u16 = 100; + +const ATTEMPT_ID_DOMAIN: &[u8] = b"radroots.rhi.publication_attempt.v1\0"; +const MAX_UNIX_MILLISECONDS: u64 = i64::MAX as u64; + +/// Stable closed target state retained by the publication workflow. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RhiPublicationTargetState { + Pending, + Submitted, + Accepted, + Rejected, + RateLimited, + AuthRequired, + Failed, + Unknown, +} + +impl RhiPublicationTargetState { + /// Returns the exact machine-contract spelling. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Submitted => "submitted", + Self::Accepted => "accepted", + Self::Rejected => "rejected", + Self::RateLimited => "rate_limited", + Self::AuthRequired => "auth_required", + Self::Failed => "failed", + Self::Unknown => "unknown", + } + } + + /// Reports whether the state is the sole terminal target state. + #[must_use] + pub const fn is_accepted_terminal(self) -> bool { + matches!(self, Self::Accepted) + } +} + +/// Stable closed outcome for one bounded publication attempt. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RhiPublicationAttemptOutcome { + Submitted, + Accepted, + Rejected, + RateLimited, + AuthRequired, + Failed, + Unknown, +} + +impl RhiPublicationAttemptOutcome { + /// Returns the exact machine-contract spelling and safe persisted result code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::Submitted => "submitted", + Self::Accepted => "accepted", + Self::Rejected => "rejected", + Self::RateLimited => "rate_limited", + Self::AuthRequired => "auth_required", + Self::Failed => "failed", + Self::Unknown => "unknown", + } + } + + /// Returns the target state represented by this exact observation. + #[must_use] + pub const fn target_state(self) -> RhiPublicationTargetState { + match self { + Self::Submitted => RhiPublicationTargetState::Submitted, + Self::Accepted => RhiPublicationTargetState::Accepted, + Self::Rejected => RhiPublicationTargetState::Rejected, + Self::RateLimited => RhiPublicationTargetState::RateLimited, + Self::AuthRequired => RhiPublicationTargetState::AuthRequired, + Self::Failed => RhiPublicationTargetState::Failed, + Self::Unknown => RhiPublicationTargetState::Unknown, + } + } +} + +/// Bounded injected wall-clock value in integer UTC milliseconds. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RhiPublicationUnixMilliseconds(u64); + +impl RhiPublicationUnixMilliseconds { + /// Validates an injected timestamp against SQLite's signed representation. + pub fn new(value: u64) -> Result<Self, RhiPublicationAttemptEvidenceError> { + if value > MAX_UNIX_MILLISECONDS { + return Err(failure(RhiPublicationAttemptEvidenceErrorKind::InvalidTime)); + } + Ok(Self(value)) + } + + /// Returns the exact integer UTC millisecond value. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Domain-separated identity of one exact outbox-target attempt. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct RhiPublicationAttemptId([u8; 32]); + +impl RhiPublicationAttemptId { + /// Returns the exact identity bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for RhiPublicationAttemptId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RhiPublicationAttemptId([redacted])") + } +} + +/// Sealed bounded evidence for one exact publication attempt. +/// +/// Construction binds the immutable committed outbox and event digest to a +/// bounded target ordinal and attempt number. It does not claim the target, +/// persist a Submitted transition, perform relay I/O, or authorize a later +/// durable outcome transition; Step 202 must revalidate those live facts. +/// +/// ```compile_fail +/// use rhi::RhiPublicationAttemptEvidence; +/// +/// let _forged = RhiPublicationAttemptEvidence { attempt_number: 1 }; +/// ``` +#[must_use = "publication attempt evidence must be durably reconciled or deliberately discarded"] +pub struct RhiPublicationAttemptEvidence { + id: RhiPublicationAttemptId, + outbox_id: crate::RhiPublicationOutboxId, + event_sha256: [u8; 32], + target_ordinal: u8, + attempt_number: u16, + started_at: RhiPublicationUnixMilliseconds, + finished_at: RhiPublicationUnixMilliseconds, + outcome: RhiPublicationAttemptOutcome, +} + +impl RhiPublicationAttemptEvidence { + /// Constructs one bounded observation from the exact committed publication. + pub fn new( + publication: &RhiCommittedPublication, + target_ordinal: u32, + attempt_number: u16, + started_at: RhiPublicationUnixMilliseconds, + finished_at: RhiPublicationUnixMilliseconds, + outcome: RhiPublicationAttemptOutcome, + ) -> Result<Self, RhiPublicationAttemptEvidenceError> { + let target_ordinal = u8::try_from(target_ordinal) + .ok() + .filter(|ordinal| u32::from(*ordinal) <= RHI_PUBLICATION_TARGET_ORDINAL_MAXIMUM) + .ok_or_else(|| failure(RhiPublicationAttemptEvidenceErrorKind::InvalidTargetOrdinal))?; + if !(1..=RHI_PUBLICATION_ATTEMPT_NUMBER_MAXIMUM).contains(&attempt_number) { + return Err(failure( + RhiPublicationAttemptEvidenceErrorKind::InvalidAttemptNumber, + )); + } + if finished_at < started_at { + return Err(failure(RhiPublicationAttemptEvidenceErrorKind::InvalidTime)); + } + let outbox_id = publication.outbox_id(); + let event_sha256 = *publication.event_sha256(); + let mut digest = Sha256::new(); + digest.update(ATTEMPT_ID_DOMAIN); + digest.update(outbox_id.as_bytes()); + digest.update(event_sha256); + digest.update(u32::from(target_ordinal).to_be_bytes()); + digest.update(u32::from(attempt_number).to_be_bytes()); + Ok(Self { + id: RhiPublicationAttemptId(digest.finalize().into()), + outbox_id, + event_sha256, + target_ordinal, + attempt_number, + started_at, + finished_at, + outcome, + }) + } + + /// Returns the exact domain-separated attempt identity. + #[must_use] + pub const fn id(&self) -> RhiPublicationAttemptId { + self.id + } + + /// Returns the immutable committed outbox identity. + #[must_use] + pub const fn outbox_id(&self) -> crate::RhiPublicationOutboxId { + self.outbox_id + } + + /// Returns the digest of the exact committed signed-event bytes. + #[must_use] + pub const fn event_sha256(&self) -> &[u8; 32] { + &self.event_sha256 + } + + /// Returns the bounded zero-based target ordinal. + #[must_use] + pub const fn target_ordinal(&self) -> u8 { + self.target_ordinal + } + + /// Returns the bounded one-based attempt number. + #[must_use] + pub const fn attempt_number(&self) -> u16 { + self.attempt_number + } + + /// Returns the injected attempt start time. + #[must_use] + pub const fn started_at(&self) -> RhiPublicationUnixMilliseconds { + self.started_at + } + + /// Returns the injected attempt finish time. + #[must_use] + pub const fn finished_at(&self) -> RhiPublicationUnixMilliseconds { + self.finished_at + } + + /// Returns the closed observed outcome. + #[must_use] + pub const fn outcome(&self) -> RhiPublicationAttemptOutcome { + self.outcome + } + + /// Returns the sole safe persisted result code for this outcome. + #[must_use] + pub const fn result_code(&self) -> &'static str { + self.outcome.code() + } +} + +impl fmt::Debug for RhiPublicationAttemptEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiPublicationAttemptEvidence") + .field("identity", &"[redacted]") + .field("target_ordinal", &self.target_ordinal) + .field("attempt_number", &self.attempt_number) + .field("started_at", &self.started_at) + .field("finished_at", &self.finished_at) + .field("outcome", &self.outcome) + .finish() + } +} + +/// Stable source-free attempt-evidence failure class. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiPublicationAttemptEvidenceErrorKind { + InvalidTargetOrdinal, + InvalidAttemptNumber, + InvalidTime, +} + +impl RhiPublicationAttemptEvidenceErrorKind { + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidTargetOrdinal => "publication_attempt_target_ordinal_invalid", + Self::InvalidAttemptNumber => "publication_attempt_number_invalid", + Self::InvalidTime => "publication_attempt_time_invalid", + } + } +} + +/// Redacted source-free attempt-evidence failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiPublicationAttemptEvidenceError { + kind: RhiPublicationAttemptEvidenceErrorKind, +} + +impl RhiPublicationAttemptEvidenceError { + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> RhiPublicationAttemptEvidenceErrorKind { + 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 RhiPublicationAttemptEvidenceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + RhiPublicationAttemptEvidenceErrorKind::InvalidTargetOrdinal => { + "RHI publication attempt target ordinal is invalid" + } + RhiPublicationAttemptEvidenceErrorKind::InvalidAttemptNumber => { + "RHI publication attempt number is invalid" + } + RhiPublicationAttemptEvidenceErrorKind::InvalidTime => { + "RHI publication attempt time is invalid" + } + }) + } +} + +impl fmt::Debug for RhiPublicationAttemptEvidenceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiPublicationAttemptEvidenceError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for RhiPublicationAttemptEvidenceError {} + +const fn failure( + kind: RhiPublicationAttemptEvidenceErrorKind, +) -> RhiPublicationAttemptEvidenceError { + RhiPublicationAttemptEvidenceError { kind } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn publication() -> RhiCommittedPublication { + RhiCommittedPublication::test_fixture( + crate::RhiPublicationOutboxId::from_committed_bytes([0x11; 32]), + [0x44; 32], + [0x22; 32], + vec![0x33].into_boxed_slice(), + ) + } + + fn lower_hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(char::from(DIGITS[usize::from(byte >> 4)])); + output.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + output + } + + #[test] + fn states_and_outcomes_are_exact() { + let states = [ + RhiPublicationTargetState::Pending, + RhiPublicationTargetState::Submitted, + RhiPublicationTargetState::Accepted, + RhiPublicationTargetState::Rejected, + RhiPublicationTargetState::RateLimited, + RhiPublicationTargetState::AuthRequired, + RhiPublicationTargetState::Failed, + RhiPublicationTargetState::Unknown, + ]; + assert_eq!( + states.map(RhiPublicationTargetState::code), + [ + "pending", + "submitted", + "accepted", + "rejected", + "rate_limited", + "auth_required", + "failed", + "unknown", + ] + ); + assert!(RhiPublicationTargetState::Accepted.is_accepted_terminal()); + for state in states { + assert_eq!(state.is_accepted_terminal(), state.code() == "accepted"); + } + let outcomes = [ + RhiPublicationAttemptOutcome::Submitted, + RhiPublicationAttemptOutcome::Accepted, + RhiPublicationAttemptOutcome::Rejected, + RhiPublicationAttemptOutcome::RateLimited, + RhiPublicationAttemptOutcome::AuthRequired, + RhiPublicationAttemptOutcome::Failed, + RhiPublicationAttemptOutcome::Unknown, + ]; + assert_eq!( + outcomes.map(RhiPublicationAttemptOutcome::code), + [ + "submitted", + "accepted", + "rejected", + "rate_limited", + "auth_required", + "failed", + "unknown", + ] + ); + for outcome in outcomes { + assert_eq!(outcome.code(), outcome.target_state().code()); + } + } + + #[test] + fn time_and_error_boundaries_are_safe() { + assert_eq!(RhiPublicationUnixMilliseconds::new(0).unwrap().get(), 0); + assert_eq!( + RhiPublicationUnixMilliseconds::new(MAX_UNIX_MILLISECONDS) + .unwrap() + .get(), + MAX_UNIX_MILLISECONDS + ); + let error = RhiPublicationUnixMilliseconds::new(MAX_UNIX_MILLISECONDS + 1) + .expect_err("oversized time"); + assert_eq!( + error.kind(), + RhiPublicationAttemptEvidenceErrorKind::InvalidTime + ); + for kind in [ + RhiPublicationAttemptEvidenceErrorKind::InvalidTargetOrdinal, + RhiPublicationAttemptEvidenceErrorKind::InvalidAttemptNumber, + RhiPublicationAttemptEvidenceErrorKind::InvalidTime, + ] { + let error = failure(kind); + assert!(error.code().starts_with("publication_attempt_")); + assert!(Error::source(&error).is_none()); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains("relay-primary")); + assert!(!rendered.contains("secret")); + } + } + + #[test] + fn attempt_evidence_binds_exact_maximum_fields_and_closed_result() { + let started_at = RhiPublicationUnixMilliseconds::new(42).unwrap(); + let finished_at = RhiPublicationUnixMilliseconds::new(43).unwrap(); + let evidence = RhiPublicationAttemptEvidence::new( + &publication(), + RHI_PUBLICATION_TARGET_ORDINAL_MAXIMUM, + RHI_PUBLICATION_ATTEMPT_NUMBER_MAXIMUM, + started_at, + finished_at, + RhiPublicationAttemptOutcome::RateLimited, + ) + .expect("maximum evidence"); + assert_eq!( + lower_hex(evidence.id().as_bytes()), + "e1acaadff3f4d52ca7e9a8d14026039827bf8386ebb66551402dfd1d7309899c" + ); + assert_eq!( + evidence.outbox_id().as_bytes(), + crate::RhiPublicationOutboxId::from_committed_bytes([0x11; 32]).as_bytes() + ); + assert_eq!(evidence.event_sha256(), &[0x22; 32]); + assert_eq!(evidence.target_ordinal(), 31); + assert_eq!(evidence.attempt_number(), 100); + assert_eq!(evidence.started_at(), started_at); + assert_eq!(evidence.finished_at(), finished_at); + assert_eq!( + evidence.outcome(), + RhiPublicationAttemptOutcome::RateLimited + ); + assert_eq!(evidence.result_code(), "rate_limited"); + let rendered = format!("{evidence:?} {:?}", evidence.id()); + assert!(!rendered.contains(&lower_hex(evidence.id().as_bytes()))); + assert!(!rendered.contains("relay-primary")); + assert!(!rendered.contains("3333")); + } + + #[test] + fn attempt_evidence_rejects_each_invalid_boundary() { + let started_at = RhiPublicationUnixMilliseconds::new(42).unwrap(); + let finished_at = RhiPublicationUnixMilliseconds::new(43).unwrap(); + let cases = [ + ( + 32, + 1, + started_at, + finished_at, + RhiPublicationAttemptEvidenceErrorKind::InvalidTargetOrdinal, + ), + ( + 0, + 0, + started_at, + finished_at, + RhiPublicationAttemptEvidenceErrorKind::InvalidAttemptNumber, + ), + ( + 0, + 101, + started_at, + finished_at, + RhiPublicationAttemptEvidenceErrorKind::InvalidAttemptNumber, + ), + ( + 0, + 1, + finished_at, + started_at, + RhiPublicationAttemptEvidenceErrorKind::InvalidTime, + ), + ]; + for (ordinal, attempt, start, finish, expected) in cases { + assert_eq!( + RhiPublicationAttemptEvidence::new( + &publication(), + ordinal, + attempt, + start, + finish, + RhiPublicationAttemptOutcome::Unknown, + ) + .expect_err("invalid evidence") + .kind(), + expected + ); + } + } +} diff --git a/src/publication_submission.rs b/src/publication_submission.rs @@ -150,6 +150,21 @@ pub struct RhiCommittedPublication { } impl RhiCommittedPublication { + #[cfg(test)] + pub(crate) fn test_fixture( + outbox_id: RhiPublicationOutboxId, + event_id: [u8; 32], + event_sha256: [u8; 32], + exact_signed_event_bytes: Box<[u8]>, + ) -> Self { + Self { + outbox_id, + event_id, + event_sha256, + exact_signed_event_bytes, + } + } + /// Returns the immutable outbox identity. #[must_use] pub const fn outbox_id(&self) -> RhiPublicationOutboxId { diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -12,6 +12,9 @@ const RUNTIME_ADAPTER_CONTRACT: &str = include_str!("../contracts/services_hardening/runtime_adapters.v1.json"); const RUNTIME_FOUNDATION: &str = include_str!("../src/runtime_foundation.rs"); const PUBLICATION: &str = include_str!("../src/publication.rs"); +const PUBLICATION_ATTEMPT: &str = include_str!("../src/publication_attempt.rs"); +const PUBLICATION_ATTEMPT_CONTRACT: &str = + include_str!("../contracts/services_hardening/publication_attempt_evidence.v1.json"); const PUBLICATION_CONTRACT: &str = include_str!("../contracts/services_hardening/publication_outbox.v1.json"); const PUBLICATION_SUBMISSION: &str = include_str!("../src/publication_submission.rs"); @@ -62,6 +65,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/identity_credential.rs"), include_str!("../src/identity_envelope.rs"), include_str!("../src/publication.rs"), + include_str!("../src/publication_attempt.rs"), include_str!("../src/publication_submission.rs"), include_str!("../src/reconciliation_attempt.rs"), include_str!("../src/reconciliation_attestation.rs"), @@ -139,6 +143,7 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "identity_credential", "identity_envelope", "publication", + "publication_attempt", "publication_submission", "reconciliation_attempt", "reconciliation_attestation", @@ -190,6 +195,15 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "RhiPublicationRetryPolicy", "RhiPublicationTarget", "RHI_PUBLICATION_CONTRACT_VERSION", + "RhiPublicationAttemptEvidence", + "RhiPublicationAttemptEvidenceErrorKind", + "RhiPublicationAttemptId", + "RhiPublicationAttemptOutcome", + "RhiPublicationTargetState", + "RhiPublicationUnixMilliseconds", + "RHI_PUBLICATION_ATTEMPT_EVIDENCE_CONTRACT_VERSION", + "RHI_PUBLICATION_ATTEMPT_NUMBER_MAXIMUM", + "RHI_PUBLICATION_TARGET_ORDINAL_MAXIMUM", "RhiCommittedPublication", "RhiPublicationOutboxId", "RhiPublicationSubmissionErrorKind", @@ -280,6 +294,7 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { assert!(!PUBLIC_API.contains("rhi::features::")); assert!(!PUBLIC_API.contains("rhi::runtime_adapters::")); assert!(!PUBLIC_API.contains("rhi::publication::")); + assert!(!PUBLIC_API.contains("rhi::publication_attempt::")); assert!(!PUBLIC_API.contains("rhi::publication_submission::")); } @@ -351,6 +366,7 @@ fn committed_publication_is_bounded_exact_and_never_reconstructed() { "std::net", "tokio::spawn", "SystemTime", + "pub(crate) fn from_committed_parts", ] { assert!( !PUBLICATION_SUBMISSION.contains(forbidden), @@ -362,6 +378,55 @@ fn committed_publication_is_bounded_exact_and_never_reconstructed() { } #[test] +fn publication_attempt_evidence_is_closed_bounded_and_effect_free() { + let contract: serde_json::Value = + serde_json::from_str(PUBLICATION_ATTEMPT_CONTRACT).expect("publication-attempt contract"); + assert_eq!( + contract["schema"], + "radroots.rhi.publication-attempt-evidence" + ); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["bounds"]["target_ordinal"]["maximum"], 31); + assert_eq!(contract["bounds"]["attempt_number"]["maximum"], 100); + assert_eq!( + contract["evidence"]["result_code"], + "exact_closed_outcome_code" + ); + assert_eq!(contract["effects"]["sqlite_read_or_mutation"], false); + assert_eq!(contract["effects"]["relay_or_network"], false); + for required in [ + "pub enum RhiPublicationTargetState", + "pub enum RhiPublicationAttemptOutcome", + "pub struct RhiPublicationAttemptEvidence", + "publication.outbox_id()", + "publication.event_sha256()", + "RHI_PUBLICATION_TARGET_ORDINAL_MAXIMUM", + "RHI_PUBLICATION_ATTEMPT_NUMBER_MAXIMUM", + ] { + assert!( + PUBLICATION_ATTEMPT.contains(required), + "publication-attempt evidence is missing {required}" + ); + } + for forbidden in [ + "sqlx::", + "serde_json", + "EventSink", + "SystemTime", + "std::fs", + "std::net", + "tokio::spawn", + ] { + assert!( + !PUBLICATION_ATTEMPT.contains(forbidden), + "publication-attempt evidence gained forbidden authority {forbidden}" + ); + } + assert!(!ROOT.contains("pub mod publication_attempt")); + assert!(!PUBLIC_API.contains("rhi::publication_attempt::")); +} + +#[test] fn reconciliation_attestation_is_typed_signed_verified_and_effect_free() { let contract: serde_json::Value = serde_json::from_str(RECONCILIATION_ATTESTATION_CONTRACT) .expect("reconciliation-attestation contract"); @@ -648,7 +713,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, 27); + assert_eq!(public_error_count, 28); } #[test] @@ -1083,6 +1148,8 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() { "[`reconciliation_attestation.v1.json`](contracts/services_hardening/reconciliation_attestation.v1.json)", "## Explicit publication authority and durable schema", "[`publication_outbox.v1.json`](contracts/services_hardening/publication_outbox.v1.json)", + "## Bounded publication attempt evidence", + "[`publication_attempt_evidence.v1.json`](contracts/services_hardening/publication_attempt_evidence.v1.json)", "The event body and exact kind-3441 structural tags", "without rebuilding, reserializing, or", "Coverage is exactly `Missing`, `Partial`, `ScopeSatisfied`, or `Unsupported`", diff --git a/tests/services_hardening_publication_attempt_evidence_contract.rs b/tests/services_hardening_publication_attempt_evidence_contract.rs @@ -0,0 +1,99 @@ +#![forbid(unsafe_code)] + +use serde_json::json; + +const CONTRACT: &str = + include_str!("../contracts/services_hardening/publication_attempt_evidence.v1.json"); +const SOURCE: &str = include_str!("../src/publication_attempt.rs"); +const ROOT: &str = include_str!("../src/lib.rs"); + +#[test] +fn exact_target_and_attempt_vocabularies_are_closed() { + let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract JSON"); + assert_eq!( + contract["schema"], + "radroots.rhi.publication-attempt-evidence" + ); + assert_eq!(contract["schema_version"], 1); + assert_eq!(contract["contract_version"], 1); + assert_eq!( + contract["target_states"], + json!([ + "pending", + "submitted", + "accepted", + "rejected", + "rate_limited", + "auth_required", + "failed", + "unknown" + ]) + ); + assert_eq!( + contract["attempt_outcomes"], + json!([ + "submitted", + "accepted", + "rejected", + "rate_limited", + "auth_required", + "failed", + "unknown" + ]) + ); + assert_eq!(contract["bounds"]["target_ordinal"]["maximum"], 31); + assert_eq!(contract["bounds"]["attempt_number"]["maximum"], 100); + assert_eq!( + contract["bounds"]["unix_milliseconds"]["maximum"], + 9_223_372_036_854_775_807_u64 + ); + assert_eq!(contract["evidence"]["raw_relay_result_or_error"], false); + assert_eq!( + contract["semantics"]["submission_alone_proves_delivery"], + false + ); + assert_eq!(contract["effects"]["sqlite_read_or_mutation"], false); + assert_eq!(contract["effects"]["relay_or_network"], false); +} + +#[test] +fn pure_model_has_no_premature_workflow_or_effect_authority() { + for required in [ + "pub enum RhiPublicationTargetState", + "pub enum RhiPublicationAttemptOutcome", + "pub struct RhiPublicationAttemptEvidence", + "pub struct RhiPublicationAttemptId", + "pub struct RhiPublicationUnixMilliseconds", + "radroots.rhi.publication_attempt.v1\\0", + "publication.outbox_id()", + "publication.event_sha256()", + "digest.update(u32::from(target_ordinal).to_be_bytes())", + "digest.update(u32::from(attempt_number).to_be_bytes())", + ] { + assert!( + SOURCE.contains(required), + "missing evidence guard {required}" + ); + } + for forbidden in [ + "sqlx::", + "radroots_transport", + "EventSink", + "SystemTime", + "std::fs", + "std::net", + "tokio::spawn", + "spawn_blocking", + "serde_json", + "publish(", + "send(", + "from_committed_parts", + ] { + assert!( + !SOURCE.contains(forbidden), + "attempt evidence gained forbidden authority {forbidden}" + ); + } + assert!(ROOT.contains("mod publication_attempt;")); + assert!(!ROOT.contains("pub mod publication_attempt;")); +}