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:
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