rhi

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

commit f7288bd533d9e381947a24567f02e61a8fc33290
parent 2e4f73e4b9367cb012b55641d180f6824f95493f
Author: triesap <tyson@radroots.org>
Date:   Mon, 24 Aug 2026 12:05:55 +0000

refactor(rhi): preserve exact publication bytes

Diffstat:
MAGENTS.md | 5+++++
MREADME | 17+++++++++++++++++
Mcontracts/api_baselines/rhi.txt | 32++++++++++++++++++++++++++++++++
Acontracts/services_hardening/publication_submission.v1.json | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 5+++++
Asrc/publication_submission.rs | 298+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/reconciliation_finalization_commit.rs | 29+++++++++++++++++++++--------
Msrc/state_repository.rs | 6++++++
Mtests/package_boundary.rs | 53++++++++++++++++++++++++++++++++++++++++++++++++++++-
Atests/services_hardening_publication_submission_contract.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_reconciliation_jobs.rs | 34++++++++++++++++++++++++++++++++++
11 files changed, 586 insertions(+), 9 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -287,6 +287,11 @@ compare-and-swap state. Step 198 defines this catalog and performs no SQLite mutation or relay I/O; later publication steps must use only the committed exact signed bytes and may never rebuild, reserialize, or re-sign them. + Read retry/recovery payloads only through the sealed committed-publication + capability joined from the immutable outbox and signed-event rows. Recheck + 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. - 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 @@ -365,6 +365,23 @@ or ambient entropy read. Publication claims and relay submission remain later steps. The exact machine contract is [`reconciliation_finalization_commit.v1.json`](contracts/services_hardening/reconciliation_finalization_commit.v1.json). +## Exact committed publication bytes + +`RhiPublicationOutboxRepository::read_committed_publication` joins one immutable +schema-v7 outbox to its signed-attestation row in one bounded read-only SQLx +transaction. It admits at most 32,768 stored bytes, rechecks the exact outbox, +event-identifier, and SHA-256 bindings, and returns a sealed +`RhiCommittedPublication`. Repeated reads and reads after a clean close/reopen +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 +[`publication_submission.v1.json`](contracts/services_hardening/publication_submission.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 @@ -147,6 +147,12 @@ pub rhi::RhiPublicationMode::Disabled pub rhi::RhiPublicationMode::Required impl rhi::RhiPublicationMode pub const fn rhi::RhiPublicationMode::code(self) -> &'static str +pub enum rhi::RhiPublicationSubmissionErrorKind +pub rhi::RhiPublicationSubmissionErrorKind::Binding +pub rhi::RhiPublicationSubmissionErrorKind::NotFound +pub rhi::RhiPublicationSubmissionErrorKind::Storage +impl rhi::RhiPublicationSubmissionErrorKind +pub const fn rhi::RhiPublicationSubmissionErrorKind::code(self) -> &'static str pub enum rhi::RhiReconciliationAttemptErrorKind pub rhi::RhiReconciliationAttemptErrorKind::InvalidConfiguration pub rhi::RhiReconciliationAttemptErrorKind::InvalidInput @@ -508,6 +514,14 @@ impl core::fmt::Debug for rhi::RhiCliV1Error pub fn rhi::RhiCliV1Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::fmt::Display for rhi::RhiCliV1Error pub fn rhi::RhiCliV1Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiCommittedPublication +impl rhi::RhiCommittedPublication +pub const fn rhi::RhiCommittedPublication::event_id(&self) -> &[u8; 32] +pub const fn rhi::RhiCommittedPublication::event_sha256(&self) -> &[u8; 32] +pub const fn rhi::RhiCommittedPublication::exact_signed_event_bytes(&self) -> &[u8] +pub const fn rhi::RhiCommittedPublication::outbox_id(&self) -> rhi::RhiPublicationOutboxId +impl core::fmt::Debug for rhi::RhiCommittedPublication +pub fn rhi::RhiCommittedPublication::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiConfigApplyError impl rhi::RhiConfigApplyError pub const fn rhi::RhiConfigApplyError::code(self) -> &'static str @@ -684,10 +698,17 @@ impl core::fmt::Debug for rhi::RhiPublicationError pub fn rhi::RhiPublicationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::fmt::Display for rhi::RhiPublicationError pub fn rhi::RhiPublicationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPublicationOutboxId(_) +impl rhi::RhiPublicationOutboxId +pub const fn rhi::RhiPublicationOutboxId::as_bytes(&self) -> &[u8; 32] +impl core::fmt::Debug for rhi::RhiPublicationOutboxId +pub fn rhi::RhiPublicationOutboxId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiPublicationOutboxRepository<'host> impl rhi::RhiPublicationOutboxRepository<'_> pub const fn rhi::RhiPublicationOutboxRepository<'_>::descriptor(&self) -> rhi::RhiStateRepositoryDescriptor pub const fn rhi::RhiPublicationOutboxRepository<'_>::kind(&self) -> rhi::RhiStateRepositoryKind +impl rhi::RhiPublicationOutboxRepository<'_> +pub async fn rhi::RhiPublicationOutboxRepository<'_>::read_committed_publication(&self, rhi::RhiPublicationOutboxId) -> core::result::Result<rhi::RhiCommittedPublication, rhi::RhiPublicationSubmissionError> impl core::fmt::Debug for rhi::RhiPublicationOutboxRepository<'_> pub fn rhi::RhiPublicationOutboxRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiPublicationRetryPolicy @@ -696,6 +717,15 @@ pub const fn rhi::RhiPublicationRetryPolicy::attempt_deadline_milliseconds(self) pub const fn rhi::RhiPublicationRetryPolicy::initial_backoff_milliseconds(self) -> u64 pub const fn rhi::RhiPublicationRetryPolicy::maximum_attempts(self) -> u16 pub const fn rhi::RhiPublicationRetryPolicy::maximum_backoff_milliseconds(self) -> u64 +pub struct rhi::RhiPublicationSubmissionError +impl rhi::RhiPublicationSubmissionError +pub const fn rhi::RhiPublicationSubmissionError::code(self) -> &'static str +pub const fn rhi::RhiPublicationSubmissionError::kind(self) -> rhi::RhiPublicationSubmissionErrorKind +impl core::error::Error for rhi::RhiPublicationSubmissionError +impl core::fmt::Debug for rhi::RhiPublicationSubmissionError +pub fn rhi::RhiPublicationSubmissionError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for rhi::RhiPublicationSubmissionError +pub fn rhi::RhiPublicationSubmissionError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiPublicationTarget impl rhi::RhiPublicationTarget pub const fn rhi::RhiPublicationTarget::ordinal(&self) -> u8 @@ -794,6 +824,7 @@ pub fn rhi::RhiReconciliationFinalizationCommitError::fmt(&self, &mut core::fmt: pub struct rhi::RhiReconciliationFinalizationCommitOutcome impl rhi::RhiReconciliationFinalizationCommitOutcome pub const fn rhi::RhiReconciliationFinalizationCommitOutcome::created(self) -> bool +pub const fn rhi::RhiReconciliationFinalizationCommitOutcome::outbox_id(self) -> core::option::Option<rhi::RhiPublicationOutboxId> pub const fn rhi::RhiReconciliationFinalizationCommitOutcome::publication_mode(self) -> rhi::RhiPublicationMode pub const fn rhi::RhiReconciliationFinalizationCommitOutcome::target_count(self) -> u8 impl core::fmt::Debug for rhi::RhiReconciliationFinalizationCommitOutcome @@ -1415,6 +1446,7 @@ pub const rhi::RHI_PROVIDER_CONTRACT_VERSION: u32 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_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_submission.v1.json b/contracts/services_hardening/publication_submission.v1.json @@ -0,0 +1,63 @@ +{ + "schema": "radroots.rhi.publication-submission", + "schema_version": 1, + "contract_version": 1, + "source": { + "authority": "immutable_schema_v7_publication_outbox_joined_to_signed_attestation_event", + "read": "one_bounded_read_only_sqlx_transaction", + "maximum_signed_event_bytes": 32768, + "required_bindings": [ + "requested_outbox_id", + "outbox_event_id_equals_event_id", + "outbox_event_sha256_equals_event_sha256", + "sha256_exact_stored_bytes_equals_event_sha256" + ] + }, + "sealed_capability": { + "type": "RhiCommittedPublication", + "forgeable": false, + "cloneable": false, + "serializable": false, + "debug": "redacted", + "relay_claim_or_lease": false, + "payload_accessor": "exact_signed_event_bytes" + }, + "retry_and_recovery": { + "input": "same_committed_outbox_identity", + "payload": "same_stored_exact_signed_event_bytes", + "close_reopen_changes_payload": false, + "parse_event": false, + "rebuild_event": false, + "reserialize_event": false, + "resign_event": false + }, + "effects": { + "sqlite_read": true, + "sqlite_mutation": false, + "relay_or_network": false, + "filesystem": false, + "task_spawn": false, + "ambient_clock": false, + "ambient_entropy": false + }, + "deferred": [ + "publication_claim", + "target_state_transition", + "attempt_evidence", + "relay_submission", + "backoff", + "lease_recovery", + "runtime_worker" + ], + "forbidden": [ + "caller_forged_committed_publication", + "unbounded_blob_decode", + "json_or_event_deserialization", + "event_rebuild", + "event_reserialization", + "event_resigning", + "raw_sqlite_handle", + "relay_io", + "hidden_retry_loop" + ] +} 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_submission; mod reconciliation_attempt; mod reconciliation_attestation; mod reconciliation_commit; @@ -71,6 +72,10 @@ pub use publication::{ RhiPublicationAuthority, RhiPublicationError, RhiPublicationErrorKind, RhiPublicationMode, RhiPublicationRetryPolicy, RhiPublicationTarget, }; +pub use publication_submission::{ + RHI_PUBLICATION_SUBMISSION_CONTRACT_VERSION, RhiCommittedPublication, RhiPublicationOutboxId, + RhiPublicationSubmissionError, RhiPublicationSubmissionErrorKind, +}; pub use radroots_event::id::TradeId; pub use radroots_runtime_paths::{ INSTANCE_ID_MAX_BYTES, InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, diff --git a/src/publication_submission.rs b/src/publication_submission.rs @@ -0,0 +1,298 @@ +//! Exact committed publication bytes for later relay submission. + +use core::fmt; +use std::error::Error; + +use radroots_service_sqlite::{ServiceSqliteTransaction, ServiceSqliteTransactionError}; +use sha2::{Digest as _, Sha256}; +use sqlx::Row as _; + +use crate::RhiPublicationOutboxRepository; + +/// Exact version of the committed publication submission contract. +pub const RHI_PUBLICATION_SUBMISSION_CONTRACT_VERSION: u32 = 1; + +const SIGNED_EVENT_BYTES_MAXIMUM: usize = 32_768; + +const READ_COMMITTED_PUBLICATION_SQL: &str = r#"SELECT + CASE WHEN typeof(outbox.outbox_id) = 'blob' AND length(outbox.outbox_id) = 32 + THEN outbox.outbox_id ELSE NULL END AS outbox_id, + CASE WHEN typeof(outbox.event_id) = 'blob' AND length(outbox.event_id) = 32 + THEN outbox.event_id ELSE NULL END AS outbox_event_id, + CASE WHEN typeof(outbox.event_sha256) = 'blob' AND length(outbox.event_sha256) = 32 + THEN outbox.event_sha256 ELSE NULL END AS outbox_event_sha256, + CASE WHEN typeof(event.event_id) = 'blob' AND length(event.event_id) = 32 + THEN event.event_id ELSE NULL END AS event_id, + CASE WHEN typeof(event.event_sha256) = 'blob' AND length(event.event_sha256) = 32 + THEN event.event_sha256 ELSE NULL END AS event_sha256, + CASE + WHEN typeof(event.canonical_event_json) = 'blob' + AND length(event.canonical_event_json) BETWEEN 1 AND 32768 + THEN event.canonical_event_json + ELSE NULL + END AS exact_signed_event_bytes +FROM publication_outbox AS outbox +LEFT JOIN signed_attestation_events AS event ON event.event_id = outbox.event_id +WHERE outbox.outbox_id = ? +LIMIT 2"#; + +/// Stable source-free committed-publication read failure class. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiPublicationSubmissionErrorKind { + NotFound, + Binding, + Storage, +} + +impl RhiPublicationSubmissionErrorKind { + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::NotFound => "publication_submission_not_found", + Self::Binding => "publication_submission_binding_invalid", + Self::Storage => "publication_submission_storage_failed", + } + } +} + +/// Redacted source-free committed-publication read failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiPublicationSubmissionError { + kind: RhiPublicationSubmissionErrorKind, +} + +impl RhiPublicationSubmissionError { + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> RhiPublicationSubmissionErrorKind { + 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 RhiPublicationSubmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + RhiPublicationSubmissionErrorKind::NotFound => { + "RHI committed publication was not found" + } + RhiPublicationSubmissionErrorKind::Binding => { + "RHI committed publication binding is invalid" + } + RhiPublicationSubmissionErrorKind::Storage => { + "RHI committed publication could not be read" + } + }) + } +} + +impl fmt::Debug for RhiPublicationSubmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiPublicationSubmissionError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for RhiPublicationSubmissionError {} + +/// Stable opaque identity of one immutable publication outbox. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct RhiPublicationOutboxId([u8; 32]); + +impl RhiPublicationOutboxId { + pub(crate) const fn from_committed_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Returns the exact identity bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for RhiPublicationOutboxId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RhiPublicationOutboxId([redacted])") + } +} + +/// Sealed exact event bytes read from one immutable committed outbox. +/// +/// This value is durable evidence, not a relay claim or lease. Later +/// submission code may borrow [`Self::exact_signed_event_bytes`] but must not +/// parse, rebuild, reserialize, or re-sign it. +/// +/// ```compile_fail +/// use rhi::{RhiCommittedPublication, RhiPublicationOutboxId}; +/// +/// let _forged = RhiCommittedPublication { +/// outbox_id: RhiPublicationOutboxId::from_committed_bytes([0; 32]), +/// event_id: [0; 32], +/// event_sha256: [0; 32], +/// exact_signed_event_bytes: Box::new([]), +/// }; +/// ``` +#[must_use = "committed publication bytes must be submitted exactly or deliberately discarded"] +pub struct RhiCommittedPublication { + outbox_id: RhiPublicationOutboxId, + event_id: [u8; 32], + event_sha256: [u8; 32], + exact_signed_event_bytes: Box<[u8]>, +} + +impl RhiCommittedPublication { + /// Returns the immutable outbox identity. + #[must_use] + pub const fn outbox_id(&self) -> RhiPublicationOutboxId { + self.outbox_id + } + + /// Returns the independently verified Nostr event identifier. + #[must_use] + pub const fn event_id(&self) -> &[u8; 32] { + &self.event_id + } + + /// Returns the SHA-256 digest of the exact stored signed-event bytes. + #[must_use] + pub const fn event_sha256(&self) -> &[u8; 32] { + &self.event_sha256 + } + + /// Returns the exact committed signed-event bytes without transformation. + #[must_use] + pub const fn exact_signed_event_bytes(&self) -> &[u8] { + &self.exact_signed_event_bytes + } +} + +impl fmt::Debug for RhiCommittedPublication { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RhiCommittedPublication([redacted])") + } +} + +impl RhiPublicationOutboxRepository<'_> { + /// Reads the exact committed signed bytes for one immutable outbox. + /// + /// The operation performs one bounded read-only SQLx transaction and no + /// JSON/event decoding, signing, relay, network, filesystem, clock, + /// entropy, or task operation. Repeated reads and reads after reopen + /// return the same bytes or fail closed. + pub async fn read_committed_publication( + &self, + outbox_id: RhiPublicationOutboxId, + ) -> Result<RhiCommittedPublication, RhiPublicationSubmissionError> { + self.host() + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { read_committed(transaction, outbox_id).await }) + }) + .await + .map_err(map_transaction_error) + } +} + +async fn read_committed( + transaction: &mut ServiceSqliteTransaction<'_>, + requested_outbox_id: RhiPublicationOutboxId, +) -> Result<RhiCommittedPublication, ReadError> { + let rows = sqlx::query(READ_COMMITTED_PUBLICATION_SQL) + .bind(requested_outbox_id.as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| ReadError::Storage)?; + let [row] = rows.as_slice() else { + return if rows.is_empty() { + Err(ReadError::NotFound) + } else { + Err(ReadError::Binding) + }; + }; + let outbox_id = blob32(row, "outbox_id")?; + let outbox_event_id = blob32(row, "outbox_event_id")?; + let outbox_event_sha256 = blob32(row, "outbox_event_sha256")?; + let event_id = blob32(row, "event_id")?; + let event_sha256 = blob32(row, "event_sha256")?; + let exact_signed_event_bytes = row + .try_get::<Option<Vec<u8>>, _>("exact_signed_event_bytes") + .map_err(|_| ReadError::Binding)? + .ok_or(ReadError::Binding)?; + if outbox_id != *requested_outbox_id.as_bytes() + || outbox_event_id != event_id + || outbox_event_sha256 != event_sha256 + || exact_signed_event_bytes.is_empty() + || exact_signed_event_bytes.len() > SIGNED_EVENT_BYTES_MAXIMUM + || <[u8; 32]>::from(Sha256::digest(&exact_signed_event_bytes)) != event_sha256 + { + return Err(ReadError::Binding); + } + Ok(RhiCommittedPublication { + outbox_id: RhiPublicationOutboxId::from_committed_bytes(outbox_id), + event_id, + event_sha256, + exact_signed_event_bytes: exact_signed_event_bytes.into_boxed_slice(), + }) +} + +fn blob32(row: &sqlx::sqlite::SqliteRow, column: &str) -> Result<[u8; 32], ReadError> { + row.try_get::<Option<Vec<u8>>, _>(column) + .map_err(|_| ReadError::Binding)? + .ok_or(ReadError::Binding)? + .try_into() + .map_err(|_| ReadError::Binding) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ReadError { + NotFound, + Binding, + Storage, +} + +fn map_transaction_error( + error: ServiceSqliteTransactionError<ReadError>, +) -> RhiPublicationSubmissionError { + failure(match error.operation_error().copied() { + Some(ReadError::NotFound) => RhiPublicationSubmissionErrorKind::NotFound, + Some(ReadError::Binding) => RhiPublicationSubmissionErrorKind::Binding, + Some(ReadError::Storage) | None => RhiPublicationSubmissionErrorKind::Storage, + }) +} + +const fn failure(kind: RhiPublicationSubmissionErrorKind) -> RhiPublicationSubmissionError { + RhiPublicationSubmissionError { kind } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn errors_and_capabilities_are_redacted() { + for kind in [ + RhiPublicationSubmissionErrorKind::NotFound, + RhiPublicationSubmissionErrorKind::Binding, + RhiPublicationSubmissionErrorKind::Storage, + ] { + let error = failure(kind); + assert_eq!(error.kind(), kind); + assert!(error.code().starts_with("publication_submission_")); + assert!(Error::source(&error).is_none()); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains("relay-primary")); + assert!(!rendered.contains("SELECT")); + } + let id = RhiPublicationOutboxId::from_committed_bytes([0x51; 32]); + assert_eq!(format!("{id:?}"), "RhiPublicationOutboxId([redacted])"); + } +} diff --git a/src/reconciliation_finalization_commit.rs b/src/reconciliation_finalization_commit.rs @@ -13,9 +13,9 @@ use radroots_trade::evidence::{ use sha2::{Digest, Sha256}; use crate::{ - RhiPublicationAuthority, RhiPublicationMode, RhiReconciliationAttemptRepository, - RhiReconciliationLease, RhiReconciliationOutcome, RhiReconciliationUnixMilliseconds, - RhiSignedEvidenceAttestation, RhiStateHostMode, + RhiPublicationAuthority, RhiPublicationMode, RhiPublicationOutboxId, + RhiReconciliationAttemptRepository, RhiReconciliationLease, RhiReconciliationOutcome, + RhiReconciliationUnixMilliseconds, RhiSignedEvidenceAttestation, RhiStateHostMode, reconciliation_finalization::{FinalizationIdentity, validate_finalization_identity}, }; @@ -164,6 +164,7 @@ pub struct RhiReconciliationFinalizationCommitOutcome { created: bool, publication_mode: RhiPublicationMode, target_count: u8, + outbox_id: Option<RhiPublicationOutboxId>, } impl RhiReconciliationFinalizationCommitOutcome { @@ -184,6 +185,12 @@ impl RhiReconciliationFinalizationCommitOutcome { pub const fn target_count(self) -> u8 { self.target_count } + + /// Returns the immutable outbox identity when publication is required. + #[must_use] + pub const fn outbox_id(self) -> Option<RhiPublicationOutboxId> { + self.outbox_id + } } impl fmt::Debug for RhiReconciliationFinalizationCommitOutcome { @@ -193,6 +200,7 @@ impl fmt::Debug for RhiReconciliationFinalizationCommitOutcome { .field("created", &self.created) .field("publication_mode", &self.publication_mode) .field("target_count", &self.target_count) + .field("outbox_id", &self.outbox_id.map(|_| "[redacted]")) .finish() } } @@ -413,16 +421,21 @@ impl FinalizationRecord { } fn outcome(&self, created: bool) -> RhiReconciliationFinalizationCommitOutcome { - let (publication_mode, target_count) = match &self.publication { - FinalizationPublication::Disabled => (RhiPublicationMode::Disabled, 0), - FinalizationPublication::Required(required) => { - (RhiPublicationMode::Required, required.target_count) - } + let (publication_mode, target_count, outbox_id) = match &self.publication { + FinalizationPublication::Disabled => (RhiPublicationMode::Disabled, 0, None), + FinalizationPublication::Required(required) => ( + RhiPublicationMode::Required, + required.target_count, + Some(RhiPublicationOutboxId::from_committed_bytes( + required.outbox_id, + )), + ), }; RhiReconciliationFinalizationCommitOutcome { created, publication_mode, target_count, + outbox_id, } } } diff --git a/src/state_repository.rs b/src/state_repository.rs @@ -473,6 +473,12 @@ impl<'host> RhiReconciliationAttemptRepository<'host> { } } +impl<'host> RhiPublicationOutboxRepository<'host> { + pub(crate) const fn host(&self) -> &'host RhiStateHost { + self.host + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -14,6 +14,9 @@ const RUNTIME_FOUNDATION: &str = include_str!("../src/runtime_foundation.rs"); const PUBLICATION: &str = include_str!("../src/publication.rs"); const PUBLICATION_CONTRACT: &str = include_str!("../contracts/services_hardening/publication_outbox.v1.json"); +const PUBLICATION_SUBMISSION: &str = include_str!("../src/publication_submission.rs"); +const PUBLICATION_SUBMISSION_CONTRACT: &str = + include_str!("../contracts/services_hardening/publication_submission.v1.json"); const RECONCILIATION_ATTEMPTS: &str = include_str!("../src/reconciliation_attempt.rs"); const RECONCILIATION_COMMIT: &str = include_str!("../src/reconciliation_commit.rs"); const RECONCILIATION_ATTESTATION: &str = include_str!("../src/reconciliation_attestation.rs"); @@ -59,6 +62,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_submission.rs"), include_str!("../src/reconciliation_attempt.rs"), include_str!("../src/reconciliation_attestation.rs"), include_str!("../src/reconciliation_commit.rs"), @@ -135,6 +139,7 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "identity_credential", "identity_envelope", "publication", + "publication_submission", "reconciliation_attempt", "reconciliation_attestation", "reconciliation_commit", @@ -185,6 +190,10 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "RhiPublicationRetryPolicy", "RhiPublicationTarget", "RHI_PUBLICATION_CONTRACT_VERSION", + "RhiCommittedPublication", + "RhiPublicationOutboxId", + "RhiPublicationSubmissionErrorKind", + "RHI_PUBLICATION_SUBMISSION_CONTRACT_VERSION", "RhiReconciliationAttemptPlan", "RhiReconciliationSourceRequest", "RhiReconciliationSourceResult", @@ -271,6 +280,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_submission::")); } #[test] @@ -311,6 +321,47 @@ fn publication_authority_is_config_derived_sealed_and_effect_free() { } #[test] +fn committed_publication_is_bounded_exact_and_never_reconstructed() { + let contract: serde_json::Value = serde_json::from_str(PUBLICATION_SUBMISSION_CONTRACT) + .expect("publication-submission contract"); + assert_eq!(contract["schema"], "radroots.rhi.publication-submission"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["source"]["maximum_signed_event_bytes"], 32_768); + assert_eq!(contract["retry_and_recovery"]["parse_event"], false); + assert_eq!(contract["retry_and_recovery"]["reserialize_event"], false); + assert_eq!(contract["retry_and_recovery"]["resign_event"], false); + for required in [ + "pub async fn read_committed_publication(", + "READ_COMMITTED_PUBLICATION_SQL", + "length(event.canonical_event_json) BETWEEN 1 AND 32768", + "Sha256::digest(&exact_signed_event_bytes)", + "pub const fn exact_signed_event_bytes(&self) -> &[u8]", + ] { + assert!( + PUBLICATION_SUBMISSION.contains(required), + "committed publication is missing {required}" + ); + } + for forbidden in [ + "serde_json", + "Nip01EventWire", + "SignedEvent", + "sign_nostr_event", + "EventSink", + "std::net", + "tokio::spawn", + "SystemTime", + ] { + assert!( + !PUBLICATION_SUBMISSION.contains(forbidden), + "committed publication gained reconstruction or I/O authority {forbidden}" + ); + } + assert!(!ROOT.contains("pub mod publication_submission")); + assert!(!PUBLIC_API.contains("rhi::publication_submission::")); +} + +#[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"); @@ -597,7 +648,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, 26); + assert_eq!(public_error_count, 27); } #[test] diff --git a/tests/services_hardening_publication_submission_contract.rs b/tests/services_hardening_publication_submission_contract.rs @@ -0,0 +1,53 @@ +#![forbid(unsafe_code)] + +use serde_json::json; + +const CONTRACT: &str = + include_str!("../contracts/services_hardening/publication_submission.v1.json"); + +#[test] +fn exact_committed_submission_contract_is_closed() { + let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract JSON"); + assert_eq!(contract["schema"], "radroots.rhi.publication-submission"); + assert_eq!(contract["schema_version"], 1); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["source"]["maximum_signed_event_bytes"], 32_768); + assert_eq!( + contract["source"]["required_bindings"], + json!([ + "requested_outbox_id", + "outbox_event_id_equals_event_id", + "outbox_event_sha256_equals_event_sha256", + "sha256_exact_stored_bytes_equals_event_sha256" + ]) + ); + assert_eq!(contract["sealed_capability"]["forgeable"], false); + assert_eq!(contract["sealed_capability"]["cloneable"], false); + assert_eq!(contract["retry_and_recovery"]["parse_event"], false); + assert_eq!(contract["retry_and_recovery"]["rebuild_event"], false); + assert_eq!(contract["retry_and_recovery"]["reserialize_event"], false); + assert_eq!(contract["retry_and_recovery"]["resign_event"], false); + assert_eq!(contract["effects"]["sqlite_read"], true); + assert_eq!(contract["effects"]["sqlite_mutation"], false); + assert_eq!(contract["effects"]["relay_or_network"], false); + for forbidden in [ + "caller_forged_committed_publication", + "unbounded_blob_decode", + "json_or_event_deserialization", + "event_rebuild", + "event_reserialization", + "event_resigning", + "raw_sqlite_handle", + "relay_io", + "hidden_retry_loop", + ] { + assert!( + contract["forbidden"] + .as_array() + .expect("forbidden inventory") + .iter() + .any(|value| value == forbidden), + "missing forbidden boundary {forbidden}" + ); + } +} diff --git a/tests/services_hardening_reconciliation_jobs.rs b/tests/services_hardening_reconciliation_jobs.rs @@ -1940,6 +1940,22 @@ async fn atomic_finalization_commits_exact_required_inventory_and_reconciles_ret assert!(first.created()); assert_eq!(first.publication_mode(), RhiPublicationMode::Required); assert_eq!(first.target_count(), 2); + let outbox_id = first.outbox_id().expect("required outbox identity"); + let committed = repositories + .publication_outbox() + .read_committed_publication(outbox_id) + .await + .expect("committed exact publication"); + assert_eq!(committed.outbox_id(), outbox_id); + assert_eq!(committed.event_id(), signed.event_id()); + assert_eq!(committed.event_sha256(), signed.signed_event_sha256()); + assert_eq!( + committed.exact_signed_event_bytes(), + signed.signed_event_bytes() + ); + let committed_debug = format!("{committed:?} {outbox_id:?}"); + assert!(!committed_debug.contains("relay-primary")); + assert!(!committed_debug.contains("{\"id\"")); let mut progressed = fixture_connection(&runtime).await; let checkpoint = sqlx::query( @@ -1963,6 +1979,7 @@ SET cursor_created_at_unix_s = cursor_created_at_unix_s + 1, assert!(!retry.created()); assert_eq!(retry.publication_mode(), RhiPublicationMode::Required); assert_eq!(retry.target_count(), 2); + assert_eq!(retry.outbox_id(), Some(outbox_id)); let mut connection = fixture_connection(&runtime).await; let counts: (i64, i64, i64, i64, i64, i64, i64) = sqlx::query_as( @@ -1999,7 +2016,23 @@ JOIN publication_outbox AS outbox ON outbox.event_id = event.event_id"#, assert_eq!(exact.4, 2); connection.close().await.expect("fixture close"); + let exact_signed_event_bytes = signed.signed_event_bytes().to_vec(); host.close().await.expect("host close"); + let inspection = open_rhi_state_inspection(&runtime, &metadata) + .await + .expect("reopened inspection"); + let recovered = inspection + .repositories() + .publication_outbox() + .read_committed_publication(outbox_id) + .await + .expect("recovered exact publication"); + assert_eq!( + recovered.exact_signed_event_bytes(), + exact_signed_event_bytes + ); + assert_eq!(recovered.event_sha256(), signed.signed_event_sha256()); + inspection.close().await.expect("inspection close"); drop(( signed, publication, @@ -2034,6 +2067,7 @@ async fn atomic_finalization_disabled_mode_creates_no_publication_rows() { assert!(outcome.created()); assert_eq!(outcome.publication_mode(), RhiPublicationMode::Disabled); assert_eq!(outcome.target_count(), 0); + assert_eq!(outcome.outbox_id(), None); let mut connection = fixture_connection(&runtime).await; let counts: (i64, i64, i64) = sqlx::query_as( r#"SELECT