rhi

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

commit 7e457dd7496b1ff5375acd60de51c58f8b569c2e
parent 662f79fd5a61302aa09481f95059f0e43bf24506
Author: triesap <tyson@radroots.org>
Date:   Mon, 24 Aug 2026 11:12:52 +0000

refactor(rhi): freeze publication authority

Diffstat:
MAGENTS.md | 13++++++++++++-
MCargo.toml | 2+-
MREADME | 40++++++++++++++++++++++++++++++++--------
Mcontracts/api_baselines/rhi.txt | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/services_hardening/publication_outbox.v1.json | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mradroots.service.source-lock.v2.toml | 2+-
Msrc/lib.rs | 11+++++++++--
Asrc/publication.rs | 533+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/state_catalog.rs | 762+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/build_policy.rs | 4++--
Mtests/package_boundary.rs | 56+++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Atests/services_hardening_publication_contract.rs | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_state_catalog.rs | 57++++++++++++++++++++++++++++++++++++++++++++++++---------
Mtests/services_hardening_state_host.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_state_resilience.rs | 2+-
Mtests/source_guards.rs | 25+++++++++++++++++++++++++
16 files changed, 1886 insertions(+), 36 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -251,7 +251,9 @@ governed RHI schema-v2 configuration-binding migration, schema-v3 immutable trade-evidence migration, schema-v4 source-checkpoint and dirty-generation migration, schema-v5 bounded reconciliation-job migration, - and schema-v6 immutable reconciliation-attempt/source-result migration. + schema-v6 immutable reconciliation-attempt/source-result migration, and + schema-v7 immutable manifest, projection, report, exact signed-event, and + publication-workflow migration. Retain at most 1,024 consecutive immutable configuration generations containing only normalized config/evidence-policy digests, public identity, exact contract versions, @@ -268,6 +270,15 @@ use intent-open, discover source generation under retained authority, and match the latest durable binding; configuration apply is an exclusive offline operation. +- Derive publication authority only from one complete validated configuration. + Required mode preserves the exact ordered write-relay target inventory, + requiredness, retry bounds, queue capacity, and domain-separated identities; + disabled mode has no target, retry, network, or hidden fallback authority. + Keep manifest, projection, report, signed-event bytes, and attempt rows + immutable, and expose outbox/target progress only through versioned + 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. - 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/Cargo.toml b/Cargo.toml @@ -20,7 +20,7 @@ service = "rhi" host_feature_profile = "service-host" nix_material = "absent" config_contract_version = 1 -state_contract_version = 6 +state_contract_version = 7 admin_contract_version = 1 status_contract_version = 1 provider_contract_version = 1 diff --git a/README b/README @@ -316,11 +316,33 @@ The sealed result owns the finalization fence, canonical report, verified event identifier, exact signed bytes, and the SHA-256 of those bytes. Later persistence must retain those bytes without rebuilding, reserializing, or re-signing them. This checkpoint performs no SQLite, filesystem, relay, -network, task, ambient-clock, or ambient-entropy operation. Schema-v7 storage, -the generation-fenced final transaction, publication, and job finalization -remain with Steps 198 and 199. The exact machine contract is +network, task, ambient-clock, or ambient-entropy operation. The schema-v7 +catalog is frozen by Step 198; the generation-fenced write, publication, and +job finalization remain with Step 199 and its successors. The exact machine contract is [`reconciliation_attestation.v1.json`](contracts/services_hardening/reconciliation_attestation.v1.json). +## Explicit publication authority and durable schema + +`RhiPublicationAuthority::from_config` is the sole public derivation boundary +for publication intent. It consumes one complete validated RHI configuration +and returns exactly `Required` or `Disabled`. Required authority preserves the +explicit ordered write-relay targets, each target's requiredness, the bounded +retry policy, and queue capacity under separate domain-separated target-set +and complete-authority digests. Disabled authority contains no target or retry +state and authorizes no hidden network work. The sealed result exposes no URL, +credential, transport, SQLite, clock, entropy, or task authority. + +Schema v7 freezes immutable manifest, projection, report, exact signed-event, +and publication-attempt records plus compare-and-swap outbox and target rows. +No-update/no-delete triggers protect semantic payload and target identities; +the closed target evidence vocabulary distinguishes pending, submitted, +accepted, rejected, rate-limited, auth-required, failed, and unknown. This +checkpoint defines and verifies the catalog only. Step 199 owns the first +atomic finalization write, and Steps 200-203 own claims, relay submission, +outcomes, retry, recovery, and wave qualification. The exact machine contract +is +[`publication_outbox.v1.json`](contracts/services_hardening/publication_outbox.v1.json). + ## Existing-state runtime foundation `open_rhi_runtime_foundation` opens only an already initialized database from @@ -419,15 +441,17 @@ RHI owns one `state.sqlite` per service instance. Create-new initialization starts from the shared schema-v1 baseline and immediately applies the pinned schema-v2 configuration migration, schema-v3 immutable trade-evidence migration, schema-v4 source-checkpoint and dirty-generation migration, -schema-v5 bounded reconciliation-job migration, and schema-v6 immutable -reconciliation-result migration. -Version six contains the six shared immutable service-metadata and +schema-v5 bounded reconciliation-job migration, schema-v6 immutable +reconciliation-result migration, and schema-v7 immutable report, signed-event, +and publication-workflow migration. +Version seven contains the six shared immutable service-metadata and migration-ledger objects, the bounded append-only `rhi_config_bindings` table, separate immutable tables for canonical mutations, signed Nostr events, and accepted source observations, and generation-guarded relay checkpoints and per-trade dirty generations with their enforcement triggers and indexes. -It also retains immutable attempt and source-result rows under no-update and -no-delete triggers. +It also retains immutable attempt, source-result, manifest, projection, report, +signed-event, and publication-attempt rows plus compare-and-swap outbox and +target state under exact transition and retention triggers. Exact literal SHA-256 values bind both migrations, every schema snapshot, and the schema catalog. RHI validates every identity before it can become database authority. diff --git a/contracts/api_baselines/rhi.txt b/contracts/api_baselines/rhi.txt @@ -137,6 +137,16 @@ pub enum rhi::RhiPublicationCommandV1 pub rhi::RhiPublicationCommandV1::Backlog pub rhi::RhiPublicationCommandV1::Retry pub rhi::RhiPublicationCommandV1::Targets +pub enum rhi::RhiPublicationErrorKind +pub rhi::RhiPublicationErrorKind::InvalidConfiguration +pub rhi::RhiPublicationErrorKind::TargetInventory +impl rhi::RhiPublicationErrorKind +pub const fn rhi::RhiPublicationErrorKind::code(self) -> &'static str +pub enum rhi::RhiPublicationMode +pub rhi::RhiPublicationMode::Disabled +pub rhi::RhiPublicationMode::Required +impl rhi::RhiPublicationMode +pub const fn rhi::RhiPublicationMode::code(self) -> &'static str pub enum rhi::RhiReconciliationAttemptErrorKind pub rhi::RhiReconciliationAttemptErrorKind::InvalidConfiguration pub rhi::RhiReconciliationAttemptErrorKind::InvalidInput @@ -639,12 +649,45 @@ pub const fn rhi::RhiPublicationAttemptRepository<'_>::descriptor(&self) -> rhi: pub const fn rhi::RhiPublicationAttemptRepository<'_>::kind(&self) -> rhi::RhiStateRepositoryKind impl core::fmt::Debug for rhi::RhiPublicationAttemptRepository<'_> pub fn rhi::RhiPublicationAttemptRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPublicationAuthority +impl rhi::RhiPublicationAuthority +pub const fn rhi::RhiPublicationAuthority::authority_sha256(&self) -> &[u8; 32] +pub fn rhi::RhiPublicationAuthority::from_config(&rhi::RhiConfigDocumentV1) -> core::result::Result<Self, rhi::RhiPublicationError> +pub const fn rhi::RhiPublicationAuthority::mode(&self) -> rhi::RhiPublicationMode +pub const fn rhi::RhiPublicationAuthority::queue_capacity(&self) -> u32 +pub const fn rhi::RhiPublicationAuthority::retry_policy(&self) -> core::option::Option<rhi::RhiPublicationRetryPolicy> +pub const fn rhi::RhiPublicationAuthority::target_set_sha256(&self) -> &[u8; 32] +pub fn rhi::RhiPublicationAuthority::targets(&self) -> &[rhi::RhiPublicationTarget] +impl core::fmt::Debug for rhi::RhiPublicationAuthority +pub fn rhi::RhiPublicationAuthority::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPublicationError +impl rhi::RhiPublicationError +pub const fn rhi::RhiPublicationError::code(self) -> &'static str +pub const fn rhi::RhiPublicationError::kind(self) -> rhi::RhiPublicationErrorKind +impl core::error::Error for rhi::RhiPublicationError +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::RhiPublicationOutboxRepository<'host> impl rhi::RhiPublicationOutboxRepository<'_> pub const fn rhi::RhiPublicationOutboxRepository<'_>::descriptor(&self) -> rhi::RhiStateRepositoryDescriptor pub const fn rhi::RhiPublicationOutboxRepository<'_>::kind(&self) -> rhi::RhiStateRepositoryKind impl core::fmt::Debug for rhi::RhiPublicationOutboxRepository<'_> pub fn rhi::RhiPublicationOutboxRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPublicationRetryPolicy +impl rhi::RhiPublicationRetryPolicy +pub const fn rhi::RhiPublicationRetryPolicy::attempt_deadline_milliseconds(self) -> u64 +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::RhiPublicationTarget +impl rhi::RhiPublicationTarget +pub const fn rhi::RhiPublicationTarget::ordinal(&self) -> u8 +pub fn rhi::RhiPublicationTarget::relay_id(&self) -> &str +pub const fn rhi::RhiPublicationTarget::required(&self) -> bool +impl core::fmt::Debug for rhi::RhiPublicationTarget +pub fn rhi::RhiPublicationTarget::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiPublicationTargetRepository<'host> impl rhi::RhiPublicationTargetRepository<'_> pub const fn rhi::RhiPublicationTargetRepository<'_>::descriptor(&self) -> rhi::RhiStateRepositoryDescriptor @@ -1336,6 +1379,9 @@ 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_CONTRACT_VERSION: u32 +pub const rhi::RHI_PUBLICATION_MAX_ATTEMPTS: u16 +pub const rhi::RHI_PUBLICATION_MAX_TARGETS: usize 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 @@ -1375,6 +1421,9 @@ pub const rhi::RHI_STATE_SCHEMA_VERSION_5_SHA256: [u8; 32] pub const rhi::RHI_STATE_SCHEMA_VERSION_6_MIGRATION_SHA256: [u8; 32] pub const rhi::RHI_STATE_SCHEMA_VERSION_6_OBJECT_COUNT: u32 pub const rhi::RHI_STATE_SCHEMA_VERSION_6_SHA256: [u8; 32] +pub const rhi::RHI_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256: [u8; 32] +pub const rhi::RHI_STATE_SCHEMA_VERSION_7_OBJECT_COUNT: u32 +pub const rhi::RHI_STATE_SCHEMA_VERSION_7_SHA256: [u8; 32] pub const rhi::RHI_STATUS_CONTRACT_VERSION: u32 pub const rhi::RHI_TRADE_EVENT_EXTRA_FIELD_MAX_COUNT: usize pub const rhi::RHI_TRADE_EVENT_EXTRA_JSON_MAX_BYTES: usize diff --git a/contracts/services_hardening/publication_outbox.v1.json b/contracts/services_hardening/publication_outbox.v1.json @@ -0,0 +1,86 @@ +{ + "schema": "radroots.rhi.publication-outbox", + "schema_version": 1, + "contract_version": 1, + "state_schema_version": 7, + "publication_modes": ["required", "disabled"], + "authority": { + "source": "complete_validated_rhi_config_v1", + "required": { + "target_count": { "minimum": 1, "maximum": 32 }, + "target_order": "exact_configured_order", + "relay_authority": ["stable_id", "write_true", "required_boolean"], + "retry": ["maximum_attempts", "initial_backoff_ms", "maximum_backoff_ms", "attempt_deadline_ms"] + }, + "disabled": { + "targets": "absent", + "retry": "absent", + "network_work": false + }, + "target_set_digest": { + "algorithm": "sha256", + "domain": "radroots.rhi.publication_target_set.v1\\0", + "framing": ["target_count_u32_be", "ordinal_u32_be", "relay_id_length_u64_be", "relay_id_utf8", "required_u8"] + }, + "complete_digest": { + "algorithm": "sha256", + "domain": "radroots.rhi.publication_authority.v1\\0", + "framing": ["mode_u8", "target_count_u32_be", "target_set_sha256", "retry_presence_u8", "retry_fields_be_when_present", "queue_capacity_u32_be"] + } + }, + "schema_objects": { + "immutable": [ + "evidence_manifests", + "trade_projections", + "attestation_reports", + "signed_attestation_events", + "publication_attempts" + ], + "compare_and_swap": ["publication_outbox", "publication_targets"], + "outbox_states": ["pending", "leased", "complete", "blocked"], + "target_states": ["pending", "submitted", "accepted", "rejected", "rate_limited", "auth_required", "failed", "unknown"], + "attempt_outcomes": ["submitted", "accepted", "rejected", "rate_limited", "auth_required", "failed", "unknown"], + "retention": "no_delete", + "accepted_target_is_terminal": true + }, + "payload": { + "signed_bytes_maximum": 32768, + "stored_bytes": "exact_independently_verified_signed_event_json", + "stored_digest": "sha256_of_exact_signed_bytes", + "retry_source": "stored_bytes_only", + "target_set": "immutable_after_initial_commit" + }, + "effects": { + "config_read": true, + "sqlite_catalog_definition": true, + "sqlite_query_or_mutation": false, + "relay_or_network": false, + "task_spawn": false, + "ambient_clock": false, + "ambient_entropy": false + }, + "forbidden": [ + "implicit_publication_mode", + "disabled_mode_target_or_retry_state", + "caller_forged_target", + "non_write_relay_target", + "mutable_signed_payload", + "mutable_target_identity", + "raw_upstream_error_text", + "raw_sqlite_handle", + "relay_io", + "event_rebuild", + "event_reserialize", + "event_resign", + "unbounded_queue_or_target_inventory" + ], + "deferred": [ + "atomic_finalization_commit", + "publication_claim", + "relay_submission", + "target_outcome_transition", + "retry_and_recovery", + "runtime_worker", + "admin_routes" + ] +} diff --git a/radroots.service.source-lock.v2.toml b/radroots.service.source-lock.v2.toml @@ -16,7 +16,7 @@ material = "absent" [contract_versions] config = 1 -state = 6 +state = 7 admin = 1 status = 1 provider = 1 diff --git a/src/lib.rs b/src/lib.rs @@ -8,6 +8,7 @@ mod config_v1; mod features; mod identity_credential; mod identity_envelope; +mod publication; mod reconciliation_attempt; mod reconciliation_attestation; mod reconciliation_commit; @@ -64,6 +65,11 @@ pub use identity_envelope::{ RhiIdentityRole, RhiWrappingCredential, open_rhi_encrypted_identity, provision_rhi_encrypted_identity, }; +pub use publication::{ + RHI_PUBLICATION_CONTRACT_VERSION, RHI_PUBLICATION_MAX_ATTEMPTS, RHI_PUBLICATION_MAX_TARGETS, + RhiPublicationAuthority, RhiPublicationError, RhiPublicationErrorKind, RhiPublicationMode, + RhiPublicationRetryPolicy, RhiPublicationTarget, +}; pub use radroots_event::id::TradeId; pub use radroots_runtime_paths::{ INSTANCE_ID_MAX_BYTES, InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, @@ -153,8 +159,9 @@ pub use state_catalog::{ RHI_STATE_SCHEMA_VERSION_5_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_5_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_5_SHA256, RHI_STATE_SCHEMA_VERSION_6_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_6_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_6_SHA256, - RhiStateCatalogError, RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog, - validate_rhi_state_catalogs, + RHI_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_7_OBJECT_COUNT, + RHI_STATE_SCHEMA_VERSION_7_SHA256, RhiStateCatalogError, RhiStateCatalogErrorKind, + rhi_migration_catalog, rhi_schema_catalog, validate_rhi_state_catalogs, }; pub use state_config::{ RHI_CONFIG_BINDING_MAX_GENERATIONS, RhiConfigApplyError, RhiConfigApplyErrorKind, diff --git a/src/publication.rs b/src/publication.rs @@ -0,0 +1,533 @@ +//! Explicit publication authority and immutable target-set identity. + +use core::fmt; +use std::error::Error; + +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::RhiConfigDocumentV1; + +/// Exact version of the RHI publication-authority contract. +pub const RHI_PUBLICATION_CONTRACT_VERSION: u32 = 1; + +/// Absolute number of publication targets admitted by the v1 contract. +pub const RHI_PUBLICATION_MAX_TARGETS: usize = 32; + +/// Absolute number of durable publication attempts admitted per target. +pub const RHI_PUBLICATION_MAX_ATTEMPTS: u16 = 100; + +const AUTHORITY_DOMAIN: &[u8] = b"radroots.rhi.publication_authority.v1\0"; +const TARGET_SET_DOMAIN: &[u8] = b"radroots.rhi.publication_target_set.v1\0"; + +/// Closed publication authority selected by the complete validated configuration. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RhiPublicationMode { + Required, + Disabled, +} + +impl RhiPublicationMode { + /// Returns the exact machine-contract spelling. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::Required => "required", + Self::Disabled => "disabled", + } + } +} + +/// Immutable retry authority copied from one validated required-publication config. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RhiPublicationRetryPolicy { + maximum_attempts: u16, + initial_backoff_milliseconds: u64, + maximum_backoff_milliseconds: u64, + attempt_deadline_milliseconds: u64, +} + +impl RhiPublicationRetryPolicy { + /// Returns the total allowed attempts for each target. + #[must_use] + pub const fn maximum_attempts(self) -> u16 { + self.maximum_attempts + } + + /// Returns the configured initial retry bound in whole milliseconds. + #[must_use] + pub const fn initial_backoff_milliseconds(self) -> u64 { + self.initial_backoff_milliseconds + } + + /// Returns the configured maximum retry bound in whole milliseconds. + #[must_use] + pub const fn maximum_backoff_milliseconds(self) -> u64 { + self.maximum_backoff_milliseconds + } + + /// Returns the absolute per-attempt duration bound in whole milliseconds. + #[must_use] + pub const fn attempt_deadline_milliseconds(self) -> u64 { + self.attempt_deadline_milliseconds + } +} + +/// One immutable publication target derived from the configured relay inventory. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct RhiPublicationTarget { + ordinal: u8, + relay_id: Box<str>, + required: bool, +} + +impl RhiPublicationTarget { + /// Returns the stable zero-based position from the configured target inventory. + #[must_use] + pub const fn ordinal(&self) -> u8 { + self.ordinal + } + + /// Returns the validated stable relay identifier. + #[must_use] + pub fn relay_id(&self) -> &str { + &self.relay_id + } + + /// Returns whether this relay is required by the governed relay authority. + #[must_use] + pub const fn required(&self) -> bool { + self.required + } +} + +impl fmt::Debug for RhiPublicationTarget { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiPublicationTarget") + .field("ordinal", &self.ordinal) + .field("relay_id", &"[redacted]") + .field("required", &self.required) + .finish() + } +} + +/// Sealed immutable publication authority derived from one validated config. +/// +/// Disabled authority contains no target or retry state. Required authority +/// preserves the complete configured target order and binds every target's +/// requiredness, the retry policy, and queue bound into one domain-separated +/// digest. Construction performs no I/O. +/// +/// ```compile_fail +/// use rhi::RhiPublicationAuthority; +/// +/// let _forged = RhiPublicationAuthority { mode: todo!() }; +/// ``` +#[derive(Clone, PartialEq, Eq)] +pub struct RhiPublicationAuthority { + mode: RhiPublicationMode, + targets: Box<[RhiPublicationTarget]>, + retry: Option<RhiPublicationRetryPolicy>, + queue_capacity: u32, + target_set_sha256: [u8; 32], + authority_sha256: [u8; 32], +} + +impl RhiPublicationAuthority { + /// Derives the only publication authority from one complete admitted config. + pub fn from_config(config: &RhiConfigDocumentV1) -> Result<Self, RhiPublicationError> { + derive_authority(config.normalized()) + } + + /// Returns the explicit configured publication mode. + #[must_use] + pub const fn mode(&self) -> RhiPublicationMode { + self.mode + } + + /// Returns the immutable configured target inventory. + #[must_use] + pub fn targets(&self) -> &[RhiPublicationTarget] { + &self.targets + } + + /// Returns retry authority only when publication is required. + #[must_use] + pub const fn retry_policy(&self) -> Option<RhiPublicationRetryPolicy> { + self.retry + } + + /// Returns the configured durable publication queue bound. + #[must_use] + pub const fn queue_capacity(&self) -> u32 { + self.queue_capacity + } + + /// Returns the domain-separated immutable target-set identity. + #[must_use] + pub const fn target_set_sha256(&self) -> &[u8; 32] { + &self.target_set_sha256 + } + + /// Returns the domain-separated identity of the complete publication authority. + #[must_use] + pub const fn authority_sha256(&self) -> &[u8; 32] { + &self.authority_sha256 + } +} + +impl fmt::Debug for RhiPublicationAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiPublicationAuthority") + .field("mode", &self.mode) + .field("target_count", &self.targets.len()) + .field("queue_capacity", &self.queue_capacity) + .finish_non_exhaustive() + } +} + +/// Stable source-free publication-authority construction failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiPublicationErrorKind { + InvalidConfiguration, + TargetInventory, +} + +impl RhiPublicationErrorKind { + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidConfiguration => "publication_configuration_invalid", + Self::TargetInventory => "publication_target_inventory_invalid", + } + } +} + +/// Redacted source-free publication-authority failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiPublicationError { + kind: RhiPublicationErrorKind, +} + +impl RhiPublicationError { + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> RhiPublicationErrorKind { + 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 RhiPublicationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + RhiPublicationErrorKind::InvalidConfiguration => { + "RHI publication configuration is invalid" + } + RhiPublicationErrorKind::TargetInventory => { + "RHI publication target inventory is invalid" + } + }) + } +} + +impl fmt::Debug for RhiPublicationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiPublicationError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for RhiPublicationError {} + +fn derive_authority(document: &Value) -> Result<RhiPublicationAuthority, RhiPublicationError> { + let mode = match string(document, "/publication/mode")? { + "required" => RhiPublicationMode::Required, + "disabled" => RhiPublicationMode::Disabled, + _ => return Err(failure(RhiPublicationErrorKind::InvalidConfiguration)), + }; + let queue_capacity = + integer(document, "/resource_limits/queues/publication").and_then(|value| { + u32::try_from(value).map_err(|_| failure(RhiPublicationErrorKind::InvalidConfiguration)) + })?; + if queue_capacity == 0 || queue_capacity > 65_536 { + return Err(failure(RhiPublicationErrorKind::InvalidConfiguration)); + } + + let (targets, retry) = match mode { + RhiPublicationMode::Disabled => { + if document.pointer("/publication/target_relay_ids").is_some() + || document.pointer("/publication/retry").is_some() + { + return Err(failure(RhiPublicationErrorKind::InvalidConfiguration)); + } + (Vec::new(), None) + } + RhiPublicationMode::Required => { + let target_ids = document + .pointer("/publication/target_relay_ids") + .and_then(Value::as_array) + .ok_or_else(|| failure(RhiPublicationErrorKind::TargetInventory))?; + if target_ids.is_empty() || target_ids.len() > RHI_PUBLICATION_MAX_TARGETS { + return Err(failure(RhiPublicationErrorKind::TargetInventory)); + } + let relays = document + .pointer("/relays") + .and_then(Value::as_array) + .ok_or_else(|| failure(RhiPublicationErrorKind::TargetInventory))?; + let mut targets = Vec::with_capacity(target_ids.len()); + for (ordinal, target_id) in target_ids.iter().enumerate() { + let relay_id = target_id + .as_str() + .filter(|value| valid_relay_id(value)) + .ok_or_else(|| failure(RhiPublicationErrorKind::TargetInventory))?; + if targets + .iter() + .any(|target: &RhiPublicationTarget| target.relay_id() == relay_id) + { + return Err(failure(RhiPublicationErrorKind::TargetInventory)); + } + let relay = relays + .iter() + .find(|relay| relay.pointer("/id").and_then(Value::as_str) == Some(relay_id)) + .ok_or_else(|| failure(RhiPublicationErrorKind::TargetInventory))?; + if relay.pointer("/write").and_then(Value::as_bool) != Some(true) { + return Err(failure(RhiPublicationErrorKind::TargetInventory)); + } + targets.push(RhiPublicationTarget { + ordinal: u8::try_from(ordinal) + .map_err(|_| failure(RhiPublicationErrorKind::TargetInventory))?, + relay_id: relay_id.into(), + required: relay + .pointer("/required") + .and_then(Value::as_bool) + .ok_or_else(|| failure(RhiPublicationErrorKind::TargetInventory))?, + }); + } + let maximum_attempts = + integer(document, "/publication/retry/max_attempts").and_then(|value| { + u16::try_from(value) + .map_err(|_| failure(RhiPublicationErrorKind::InvalidConfiguration)) + })?; + let retry = RhiPublicationRetryPolicy { + maximum_attempts, + initial_backoff_milliseconds: integer( + document, + "/publication/retry/initial_backoff_ms", + )?, + maximum_backoff_milliseconds: integer( + document, + "/publication/retry/maximum_backoff_ms", + )?, + attempt_deadline_milliseconds: integer( + document, + "/publication/retry/attempt_deadline_ms", + )?, + }; + if retry.maximum_attempts == 0 + || retry.maximum_attempts > RHI_PUBLICATION_MAX_ATTEMPTS + || retry.initial_backoff_milliseconds == 0 + || retry.initial_backoff_milliseconds > retry.maximum_backoff_milliseconds + || retry.maximum_backoff_milliseconds > 3_600_000 + || !(100..=30_000).contains(&retry.attempt_deadline_milliseconds) + { + return Err(failure(RhiPublicationErrorKind::InvalidConfiguration)); + } + (targets, Some(retry)) + } + }; + + let target_set_sha256 = target_set_digest(&targets)?; + let authority_sha256 = authority_digest( + mode, + &target_set_sha256, + targets.len(), + retry, + queue_capacity, + )?; + Ok(RhiPublicationAuthority { + mode, + targets: targets.into_boxed_slice(), + retry, + queue_capacity, + target_set_sha256, + authority_sha256, + }) +} + +fn target_set_digest(targets: &[RhiPublicationTarget]) -> Result<[u8; 32], RhiPublicationError> { + let mut digest = Sha256::new(); + digest.update(TARGET_SET_DOMAIN); + digest.update( + u32::try_from(targets.len()) + .map_err(|_| failure(RhiPublicationErrorKind::TargetInventory))? + .to_be_bytes(), + ); + for target in targets { + digest.update(u32::from(target.ordinal).to_be_bytes()); + digest.update( + u64::try_from(target.relay_id.len()) + .map_err(|_| failure(RhiPublicationErrorKind::TargetInventory))? + .to_be_bytes(), + ); + digest.update(target.relay_id.as_bytes()); + digest.update([u8::from(target.required)]); + } + Ok(digest.finalize().into()) +} + +fn authority_digest( + mode: RhiPublicationMode, + target_set_sha256: &[u8; 32], + target_count: usize, + retry: Option<RhiPublicationRetryPolicy>, + queue_capacity: u32, +) -> Result<[u8; 32], RhiPublicationError> { + let mut digest = Sha256::new(); + digest.update(AUTHORITY_DOMAIN); + digest.update([match mode { + RhiPublicationMode::Disabled => 0, + RhiPublicationMode::Required => 1, + }]); + digest.update( + u32::try_from(target_count) + .map_err(|_| failure(RhiPublicationErrorKind::TargetInventory))? + .to_be_bytes(), + ); + digest.update(target_set_sha256); + match retry { + Some(retry) => { + digest.update([1]); + digest.update(retry.maximum_attempts.to_be_bytes()); + digest.update(retry.initial_backoff_milliseconds.to_be_bytes()); + digest.update(retry.maximum_backoff_milliseconds.to_be_bytes()); + digest.update(retry.attempt_deadline_milliseconds.to_be_bytes()); + } + None => digest.update([0]), + } + digest.update(queue_capacity.to_be_bytes()); + Ok(digest.finalize().into()) +} + +fn string<'a>(document: &'a Value, pointer: &str) -> Result<&'a str, RhiPublicationError> { + document + .pointer(pointer) + .and_then(Value::as_str) + .ok_or_else(|| failure(RhiPublicationErrorKind::InvalidConfiguration)) +} + +fn integer(document: &Value, pointer: &str) -> Result<u64, RhiPublicationError> { + document + .pointer(pointer) + .and_then(Value::as_u64) + .ok_or_else(|| failure(RhiPublicationErrorKind::InvalidConfiguration)) +} + +fn valid_relay_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value.as_bytes()[0].is_ascii_lowercase() + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') + }) +} + +const fn failure(kind: RhiPublicationErrorKind) -> RhiPublicationError { + RhiPublicationError { kind } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RhiConfigProfile, parse_rhi_config_v1}; + + const EXAMPLE: &[u8] = include_bytes!("../contracts/services_hardening/config.v1.example.toml"); + + #[test] + fn required_and_disabled_authority_are_exact_and_deterministic() { + let config = parse_rhi_config_v1(EXAMPLE, RhiConfigProfile::Production).expect("config"); + let first = RhiPublicationAuthority::from_config(&config).expect("authority"); + let second = RhiPublicationAuthority::from_config(&config).expect("authority"); + assert_eq!(first, second); + assert_eq!(first.mode(), RhiPublicationMode::Required); + assert_eq!(first.queue_capacity(), 4_096); + assert_eq!(first.targets().len(), 2); + assert_eq!(first.targets()[0].ordinal(), 0); + assert_eq!(first.targets()[0].relay_id(), "relay-primary"); + assert!(first.targets()[0].required()); + assert_eq!(first.targets()[1].ordinal(), 1); + assert_eq!(first.targets()[1].relay_id(), "relay-secondary"); + assert!(!first.targets()[1].required()); + let retry = first.retry_policy().expect("required retry"); + assert_eq!(retry.maximum_attempts(), 10); + assert_eq!(retry.initial_backoff_milliseconds(), 250); + assert_eq!(retry.maximum_backoff_milliseconds(), 30_000); + assert_eq!(retry.attempt_deadline_milliseconds(), 15_000); + assert_ne!(first.target_set_sha256(), &[0; 32]); + assert_ne!(first.authority_sha256(), &[0; 32]); + + let source = core::str::from_utf8(EXAMPLE).expect("utf8"); + let publication = source.find("[publication]").expect("publication section"); + let presence = source.find("[presence]").expect("presence section"); + let disabled = format!( + "{}[publication]\nmode = \"disabled\"\n\n{}", + &source[..publication], + &source[presence..] + ); + let config = parse_rhi_config_v1(disabled.as_bytes(), RhiConfigProfile::Production) + .expect("disabled config"); + let disabled = RhiPublicationAuthority::from_config(&config).expect("disabled authority"); + assert_eq!(disabled.mode(), RhiPublicationMode::Disabled); + assert!(disabled.targets().is_empty()); + assert_eq!(disabled.retry_policy(), None); + assert_eq!(disabled.queue_capacity(), 4_096); + assert_ne!(disabled.authority_sha256(), first.authority_sha256()); + } + + #[test] + fn target_order_requiredness_and_retry_change_the_authority_digest() { + let source = core::str::from_utf8(EXAMPLE).expect("utf8"); + let baseline = parse_rhi_config_v1(EXAMPLE, RhiConfigProfile::Production).expect("config"); + let baseline = RhiPublicationAuthority::from_config(&baseline).expect("authority"); + for changed in [ + source.replace( + "target_relay_ids = [\"relay-primary\", \"relay-secondary\"]", + "target_relay_ids = [\"relay-secondary\", \"relay-primary\"]", + ), + source.replacen("required = true", "required = false", 1), + source.replace("max_attempts = 10", "max_attempts = 9"), + source.replace("publication = 4096", "publication = 4095"), + ] { + let config = parse_rhi_config_v1(changed.as_bytes(), RhiConfigProfile::Production) + .expect("changed config"); + let changed = RhiPublicationAuthority::from_config(&config).expect("authority"); + assert_ne!(changed.authority_sha256(), baseline.authority_sha256()); + } + } + + #[test] + fn public_diagnostics_are_source_free_and_redacted() { + for kind in [ + RhiPublicationErrorKind::InvalidConfiguration, + RhiPublicationErrorKind::TargetInventory, + ] { + let error = failure(kind); + assert_eq!(error.kind(), kind); + assert!(error.code().starts_with("publication_")); + assert!(Error::source(&error).is_none()); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains("relay-secret")); + assert!(!rendered.contains("wss://")); + } + } +} diff --git a/src/state_catalog.rs b/src/state_catalog.rs @@ -12,7 +12,7 @@ use radroots_service_sqlite::{ pub const RHI_STATE_BASE_SCHEMA_VERSION: u32 = 1; /// The newest governed RHI state schema understood by this binary. -pub const RHI_STATE_SCHEMA_VERSION: u32 = 6; +pub const RHI_STATE_SCHEMA_VERSION: u32 = 7; /// The shared metadata and migration-ledger objects present at schema v1. pub const RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6; @@ -32,10 +32,13 @@ pub const RHI_STATE_SCHEMA_VERSION_5_OBJECT_COUNT: u32 = 33; /// The shared objects plus immutable reconciliation attempt/source results. pub const RHI_STATE_SCHEMA_VERSION_6_OBJECT_COUNT: u32 = 39; +/// The shared objects plus immutable report and publication workflow state. +pub const RHI_STATE_SCHEMA_VERSION_7_OBJECT_COUNT: u32 = 63; + /// SHA-256 identity of the ordered migration catalog rooted at schema v1. pub const RHI_MIGRATION_CATALOG_SHA256: [u8; 32] = [ - 0x32, 0xf4, 0x9f, 0x1c, 0x50, 0xf5, 0x4c, 0x72, 0x2d, 0x94, 0xd9, 0x34, 0x3a, 0x05, 0x2e, 0x67, - 0x14, 0x2d, 0x00, 0x74, 0x7a, 0x0b, 0xb1, 0xcc, 0x1c, 0xb5, 0xca, 0xba, 0xfa, 0x30, 0xfe, 0x4c, + 0xbf, 0x95, 0x58, 0x84, 0xe9, 0x73, 0xde, 0x04, 0xb9, 0x8a, 0x57, 0x98, 0x65, 0x62, 0xef, 0x38, + 0x05, 0xad, 0x82, 0xce, 0x3d, 0xa6, 0xa8, 0x3a, 0xb5, 0x89, 0x0a, 0x50, 0xe0, 0x6b, 0xd5, 0xf4, ]; /// SHA-256 identity of the exact schema-v1 object snapshot. @@ -104,10 +107,22 @@ pub const RHI_STATE_SCHEMA_VERSION_6_SHA256: [u8; 32] = [ 0xd5, 0x1f, 0x92, 0x94, 0xc7, 0x52, 0x72, 0x04, 0x1f, 0x34, 0x9e, 0x95, 0xce, 0xd5, 0x0f, 0xb4, ]; +/// SHA-256 identity of the schema-v7 report/publication migration. +pub const RHI_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256: [u8; 32] = [ + 0x9d, 0xeb, 0xf4, 0xf3, 0xca, 0xad, 0x4d, 0x01, 0x83, 0x11, 0xcb, 0x16, 0x9b, 0xf9, 0x28, 0x22, + 0x64, 0xd6, 0xab, 0xfc, 0x49, 0xe7, 0x18, 0x84, 0x0b, 0x9c, 0xbf, 0xad, 0x42, 0xed, 0x35, 0x7c, +]; + +/// SHA-256 identity of the exact schema-v7 object snapshot. +pub const RHI_STATE_SCHEMA_VERSION_7_SHA256: [u8; 32] = [ + 0x84, 0x0a, 0xa8, 0x3c, 0x68, 0x9f, 0x9d, 0xf9, 0x9d, 0x26, 0xc5, 0xb4, 0xef, 0x11, 0x71, 0x31, + 0xc2, 0x70, 0xef, 0x75, 0x22, 0x67, 0x40, 0x37, 0xde, 0xf7, 0x96, 0x28, 0xa2, 0xb0, 0x39, 0x46, +]; + /// SHA-256 identity of the schema catalog bound to the migration catalog. pub const RHI_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [ - 0x8c, 0x19, 0x82, 0xb2, 0x95, 0xd6, 0x54, 0x4b, 0xbe, 0x6d, 0xf6, 0x79, 0x42, 0x4e, 0xe6, 0xca, - 0xf3, 0x47, 0x68, 0x31, 0xb3, 0x8d, 0xe9, 0x94, 0x6b, 0x11, 0x5f, 0xbc, 0x5b, 0x24, 0x6b, 0x03, + 0xec, 0x31, 0x80, 0x9d, 0x62, 0x07, 0xfd, 0x98, 0xb2, 0x04, 0xe5, 0x69, 0x39, 0x73, 0x11, 0x97, + 0xf2, 0x97, 0x87, 0xbd, 0x1f, 0xef, 0x62, 0x8d, 0x9b, 0x34, 0x5d, 0xb0, 0x20, 0x71, 0x1f, 0xec, ]; macro_rules! rhi_config_bindings_table_sql { @@ -748,6 +763,429 @@ const CREATE_RECONCILIATION_RESULTS_MIGRATION_SQL: &str = concat!( ";", ); +macro_rules! evidence_manifests_table_sql { + () => { + r#"CREATE TABLE evidence_manifests ( + manifest_sha256 BLOB NOT NULL PRIMARY KEY CHECK (length(manifest_sha256) = 32), + attempt_id BLOB NOT NULL UNIQUE CHECK (length(attempt_id) = 32), + trade_id BLOB NOT NULL CHECK (length(trade_id) = 16), + trade_generation INTEGER NOT NULL + CHECK (trade_generation BETWEEN 1 AND 9223372036854775807), + evidence_policy_sha256 BLOB NOT NULL CHECK (length(evidence_policy_sha256) = 32), + canonical_manifest BLOB NOT NULL + CHECK (length(canonical_manifest) BETWEEN 1 AND 16777216), + observed_at_unix_s INTEGER NOT NULL + CHECK (observed_at_unix_s BETWEEN 0 AND 9223372036854775807), + source_count INTEGER NOT NULL CHECK (source_count BETWEEN 1 AND 16), + observation_count INTEGER NOT NULL CHECK (observation_count BETWEEN 0 AND 65536), + FOREIGN KEY (attempt_id) REFERENCES evidence_reconciliations (attempt_id) +) STRICT"# + }; +} + +macro_rules! trade_projections_table_sql { + () => { + r#"CREATE TABLE trade_projections ( + projection_sha256 BLOB NOT NULL PRIMARY KEY CHECK (length(projection_sha256) = 32), + manifest_sha256 BLOB NOT NULL UNIQUE CHECK (length(manifest_sha256) = 32), + shared_projection_sha256 BLOB NOT NULL CHECK (length(shared_projection_sha256) = 32), + reducer_contract TEXT NOT NULL CHECK (reducer_contract = 'radroots.trade.reducer.v1'), + reducer_contract_version INTEGER NOT NULL CHECK (reducer_contract_version = 1), + issue_count INTEGER NOT NULL CHECK (issue_count BETWEEN 0 AND 65536), + FOREIGN KEY (manifest_sha256) REFERENCES evidence_manifests (manifest_sha256) +) STRICT"# + }; +} + +macro_rules! attestation_reports_table_sql { + () => { + r#"CREATE TABLE attestation_reports ( + statement_sha256 BLOB NOT NULL PRIMARY KEY CHECK (length(statement_sha256) = 32), + manifest_sha256 BLOB NOT NULL CHECK (length(manifest_sha256) = 32), + projection_sha256 BLOB NOT NULL CHECK (length(projection_sha256) = 32), + trade_id BLOB NOT NULL CHECK (length(trade_id) = 16), + claim_mutation_id BLOB NOT NULL CHECK (length(claim_mutation_id) = 32), + issuer_public_key BLOB NOT NULL CHECK (length(issuer_public_key) = 32), + outcome TEXT NOT NULL CHECK (outcome IN ('valid', 'invalid', 'indeterminate')), + canonical_report BLOB NOT NULL CHECK (length(canonical_report) BETWEEN 1 AND 16384), + observed_at_unix_s INTEGER NOT NULL + CHECK (observed_at_unix_s BETWEEN 0 AND 9223372036854775807), + supersedes_statement_sha256 BLOB CHECK (length(supersedes_statement_sha256) = 32), + supersedes_event_id BLOB CHECK (length(supersedes_event_id) = 32), + FOREIGN KEY (manifest_sha256) REFERENCES evidence_manifests (manifest_sha256), + FOREIGN KEY (projection_sha256) REFERENCES trade_projections (projection_sha256), + FOREIGN KEY (supersedes_statement_sha256) + REFERENCES attestation_reports (statement_sha256), + CHECK ((supersedes_statement_sha256 IS NULL) = (supersedes_event_id IS NULL)) +) STRICT"# + }; +} + +macro_rules! attestation_reports_supersession_sql { + () => { + r#"CREATE UNIQUE INDEX attestation_reports_one_successor +ON attestation_reports (supersedes_statement_sha256) +WHERE supersedes_statement_sha256 IS NOT NULL"# + }; +} + +macro_rules! signed_attestation_events_table_sql { + () => { + r#"CREATE TABLE signed_attestation_events ( + event_id BLOB NOT NULL PRIMARY KEY CHECK (length(event_id) = 32), + statement_sha256 BLOB NOT NULL UNIQUE CHECK (length(statement_sha256) = 32), + event_sha256 BLOB NOT NULL UNIQUE CHECK (length(event_sha256) = 32), + issuer_public_key BLOB NOT NULL CHECK (length(issuer_public_key) = 32), + authored_at_unix_s INTEGER NOT NULL + CHECK (authored_at_unix_s BETWEEN 0 AND 9223372036854775807), + canonical_event_json BLOB NOT NULL + CHECK (length(canonical_event_json) BETWEEN 1 AND 32768), + FOREIGN KEY (statement_sha256) REFERENCES attestation_reports (statement_sha256) +) STRICT"# + }; +} + +macro_rules! publication_outbox_table_sql { + () => { + r#"CREATE TABLE publication_outbox ( + outbox_id BLOB NOT NULL PRIMARY KEY CHECK (length(outbox_id) = 32), + event_id BLOB NOT NULL UNIQUE CHECK (length(event_id) = 32), + event_sha256 BLOB NOT NULL CHECK (length(event_sha256) = 32), + publication_authority_sha256 BLOB NOT NULL + CHECK (length(publication_authority_sha256) = 32), + target_set_sha256 BLOB NOT NULL CHECK (length(target_set_sha256) = 32), + target_count INTEGER NOT NULL CHECK (target_count BETWEEN 1 AND 32), + required_target_count INTEGER NOT NULL + CHECK (required_target_count BETWEEN 0 AND target_count), + max_attempts INTEGER NOT NULL CHECK (max_attempts BETWEEN 1 AND 100), + initial_backoff_ms INTEGER NOT NULL CHECK (initial_backoff_ms BETWEEN 1 AND 60000), + maximum_backoff_ms INTEGER NOT NULL CHECK (maximum_backoff_ms BETWEEN 1 AND 3600000), + attempt_deadline_ms INTEGER NOT NULL CHECK (attempt_deadline_ms BETWEEN 100 AND 30000), + state TEXT NOT NULL CHECK (state IN ('pending', 'leased', 'complete', 'blocked')), + revision INTEGER NOT NULL CHECK (revision BETWEEN 1 AND 9223372036854775807), + next_attempt_unix_ms INTEGER, + lease_owner BLOB, + lease_expires_unix_ms INTEGER, + created_at_unix_ms INTEGER NOT NULL + CHECK (created_at_unix_ms BETWEEN 0 AND 9223372036854775807), + updated_at_unix_ms INTEGER NOT NULL + CHECK (updated_at_unix_ms BETWEEN created_at_unix_ms AND 9223372036854775807), + FOREIGN KEY (event_id) REFERENCES signed_attestation_events (event_id), + CHECK (initial_backoff_ms <= maximum_backoff_ms), + CHECK ( + (state = 'pending' + AND next_attempt_unix_ms IS NOT NULL + AND next_attempt_unix_ms BETWEEN 0 AND 9223372036854775807 + AND lease_owner IS NULL AND lease_expires_unix_ms IS NULL) + OR (state = 'leased' + AND next_attempt_unix_ms IS NULL + AND lease_owner IS NOT NULL + AND length(lease_owner) = 16 + AND lease_expires_unix_ms IS NOT NULL + AND lease_expires_unix_ms BETWEEN 1 AND 9223372036854775807) + OR (state IN ('complete', 'blocked') + AND next_attempt_unix_ms IS NULL + AND lease_owner IS NULL AND lease_expires_unix_ms IS NULL) + ) +) STRICT"# + }; +} + +macro_rules! publication_outbox_schedule_sql { + () => { + r#"CREATE INDEX publication_outbox_by_schedule +ON publication_outbox ( + state, next_attempt_unix_ms, lease_expires_unix_ms, + created_at_unix_ms, outbox_id +)"# + }; +} + +macro_rules! publication_outbox_guard_update_sql { + () => { + r#"CREATE TRIGGER publication_outbox_guard_update +BEFORE UPDATE ON publication_outbox +WHEN NEW.outbox_id != OLD.outbox_id + OR NEW.event_id != OLD.event_id + OR NEW.event_sha256 != OLD.event_sha256 + OR NEW.publication_authority_sha256 != OLD.publication_authority_sha256 + OR NEW.target_set_sha256 != OLD.target_set_sha256 + OR NEW.target_count != OLD.target_count + OR NEW.required_target_count != OLD.required_target_count + OR NEW.max_attempts != OLD.max_attempts + OR NEW.initial_backoff_ms != OLD.initial_backoff_ms + OR NEW.maximum_backoff_ms != OLD.maximum_backoff_ms + OR NEW.attempt_deadline_ms != OLD.attempt_deadline_ms + OR NEW.created_at_unix_ms != OLD.created_at_unix_ms + OR NEW.revision != OLD.revision + 1 + OR NEW.updated_at_unix_ms < OLD.updated_at_unix_ms + OR NOT ( + (OLD.state = 'pending' AND NEW.state IN ('leased', 'blocked')) + OR (OLD.state = 'leased' + AND NEW.state IN ('leased', 'pending', 'complete', 'blocked')) + OR (OLD.state = 'blocked' AND NEW.state = 'pending') + ) +BEGIN + SELECT RAISE(ABORT, 'publication outbox transition is invalid'); +END"# + }; +} + +macro_rules! publication_targets_table_sql { + () => { + r#"CREATE TABLE publication_targets ( + outbox_id BLOB NOT NULL CHECK (length(outbox_id) = 32), + target_ordinal INTEGER NOT NULL CHECK (target_ordinal BETWEEN 0 AND 31), + relay_id TEXT NOT NULL + CHECK (length(CAST(relay_id AS BLOB)) BETWEEN 1 AND 64) + CHECK (relay_id NOT GLOB '*[^a-z0-9_-]*') + CHECK (substr(relay_id, 1, 1) GLOB '[a-z]'), + required INTEGER NOT NULL CHECK (required IN (0, 1)), + state TEXT NOT NULL CHECK (state IN ( + 'pending', 'submitted', 'accepted', 'rejected', 'rate_limited', + 'auth_required', 'failed', 'unknown' + )), + revision INTEGER NOT NULL CHECK (revision BETWEEN 1 AND 9223372036854775807), + attempt_count INTEGER NOT NULL CHECK (attempt_count BETWEEN 0 AND 100), + next_attempt_unix_ms INTEGER + CHECK (next_attempt_unix_ms BETWEEN 0 AND 9223372036854775807), + last_attempt_id BLOB CHECK (length(last_attempt_id) = 32), + updated_at_unix_ms INTEGER NOT NULL + CHECK (updated_at_unix_ms BETWEEN 0 AND 9223372036854775807), + PRIMARY KEY (outbox_id, target_ordinal), + UNIQUE (outbox_id, relay_id), + FOREIGN KEY (outbox_id) REFERENCES publication_outbox (outbox_id), + CHECK ((attempt_count = 0) = (last_attempt_id IS NULL)) +) STRICT"# + }; +} + +macro_rules! publication_targets_schedule_sql { + () => { + r#"CREATE INDEX publication_targets_by_schedule +ON publication_targets (state, next_attempt_unix_ms, updated_at_unix_ms, outbox_id, target_ordinal)"# + }; +} + +macro_rules! publication_targets_guard_update_sql { + () => { + r#"CREATE TRIGGER publication_targets_guard_update +BEFORE UPDATE ON publication_targets +WHEN NEW.outbox_id != OLD.outbox_id + OR NEW.target_ordinal != OLD.target_ordinal + OR NEW.relay_id != OLD.relay_id + OR NEW.required != OLD.required + OR NEW.revision != OLD.revision + 1 + OR NEW.attempt_count < OLD.attempt_count + OR NEW.attempt_count > OLD.attempt_count + 1 + OR NEW.updated_at_unix_ms < OLD.updated_at_unix_ms + OR OLD.state = 'accepted' +BEGIN + SELECT RAISE(ABORT, 'publication target transition is invalid'); +END"# + }; +} + +macro_rules! publication_attempts_table_sql { + () => { + r#"CREATE TABLE publication_attempts ( + attempt_id BLOB NOT NULL PRIMARY KEY CHECK (length(attempt_id) = 32), + outbox_id BLOB NOT NULL CHECK (length(outbox_id) = 32), + target_ordinal INTEGER NOT NULL CHECK (target_ordinal BETWEEN 0 AND 31), + attempt_number INTEGER NOT NULL CHECK (attempt_number BETWEEN 1 AND 100), + event_sha256 BLOB NOT NULL CHECK (length(event_sha256) = 32), + lease_owner BLOB NOT NULL CHECK (length(lease_owner) = 16), + started_at_unix_ms INTEGER NOT NULL + CHECK (started_at_unix_ms BETWEEN 0 AND 9223372036854775807), + finished_at_unix_ms INTEGER NOT NULL + CHECK (finished_at_unix_ms BETWEEN started_at_unix_ms AND 9223372036854775807), + outcome TEXT NOT NULL CHECK (outcome IN ( + 'submitted', 'accepted', 'rejected', 'rate_limited', + 'auth_required', 'failed', 'unknown' + )), + result_code TEXT NOT NULL + CHECK (length(CAST(result_code AS BLOB)) BETWEEN 1 AND 64) + CHECK (result_code NOT GLOB '*[^a-z0-9_]*') + CHECK (substr(result_code, 1, 1) GLOB '[a-z]'), + UNIQUE (outbox_id, target_ordinal, attempt_number), + FOREIGN KEY (outbox_id, target_ordinal) + REFERENCES publication_targets (outbox_id, target_ordinal) +) STRICT"# + }; +} + +const CREATE_EVIDENCE_MANIFESTS_TABLE_SQL: &str = evidence_manifests_table_sql!(); +const CREATE_EVIDENCE_MANIFESTS_NO_UPDATE_SQL: &str = immutable_no_update_sql!( + "evidence_manifests_no_update", + "evidence_manifests", + "evidence manifests are immutable" +); +const CREATE_EVIDENCE_MANIFESTS_NO_DELETE_SQL: &str = immutable_no_delete_sql!( + "evidence_manifests_no_delete", + "evidence_manifests", + "evidence manifests are retained" +); +const CREATE_TRADE_PROJECTIONS_TABLE_SQL: &str = trade_projections_table_sql!(); +const CREATE_TRADE_PROJECTIONS_NO_UPDATE_SQL: &str = immutable_no_update_sql!( + "trade_projections_no_update", + "trade_projections", + "trade projections are immutable" +); +const CREATE_TRADE_PROJECTIONS_NO_DELETE_SQL: &str = immutable_no_delete_sql!( + "trade_projections_no_delete", + "trade_projections", + "trade projections are retained" +); +const CREATE_ATTESTATION_REPORTS_TABLE_SQL: &str = attestation_reports_table_sql!(); +const CREATE_ATTESTATION_REPORTS_SUPERSESSION_SQL: &str = attestation_reports_supersession_sql!(); +const CREATE_ATTESTATION_REPORTS_NO_UPDATE_SQL: &str = immutable_no_update_sql!( + "attestation_reports_no_update", + "attestation_reports", + "attestation reports are immutable" +); +const CREATE_ATTESTATION_REPORTS_NO_DELETE_SQL: &str = immutable_no_delete_sql!( + "attestation_reports_no_delete", + "attestation_reports", + "attestation reports are retained" +); +const CREATE_SIGNED_ATTESTATION_EVENTS_TABLE_SQL: &str = signed_attestation_events_table_sql!(); +const CREATE_SIGNED_ATTESTATION_EVENTS_NO_UPDATE_SQL: &str = immutable_no_update_sql!( + "signed_attestation_events_no_update", + "signed_attestation_events", + "signed attestation events are immutable" +); +const CREATE_SIGNED_ATTESTATION_EVENTS_NO_DELETE_SQL: &str = immutable_no_delete_sql!( + "signed_attestation_events_no_delete", + "signed_attestation_events", + "signed attestation events are retained" +); +const CREATE_PUBLICATION_OUTBOX_TABLE_SQL: &str = publication_outbox_table_sql!(); +const CREATE_PUBLICATION_OUTBOX_SCHEDULE_SQL: &str = publication_outbox_schedule_sql!(); +const CREATE_PUBLICATION_OUTBOX_GUARD_UPDATE_SQL: &str = publication_outbox_guard_update_sql!(); +const CREATE_PUBLICATION_OUTBOX_NO_DELETE_SQL: &str = immutable_no_delete_sql!( + "publication_outbox_no_delete", + "publication_outbox", + "publication outbox rows are retained" +); +const CREATE_PUBLICATION_TARGETS_TABLE_SQL: &str = publication_targets_table_sql!(); +const CREATE_PUBLICATION_TARGETS_SCHEDULE_SQL: &str = publication_targets_schedule_sql!(); +const CREATE_PUBLICATION_TARGETS_GUARD_UPDATE_SQL: &str = publication_targets_guard_update_sql!(); +const CREATE_PUBLICATION_TARGETS_NO_DELETE_SQL: &str = immutable_no_delete_sql!( + "publication_targets_no_delete", + "publication_targets", + "publication targets are retained" +); +const CREATE_PUBLICATION_ATTEMPTS_TABLE_SQL: &str = publication_attempts_table_sql!(); +const CREATE_PUBLICATION_ATTEMPTS_NO_UPDATE_SQL: &str = immutable_no_update_sql!( + "publication_attempts_no_update", + "publication_attempts", + "publication attempts are immutable" +); +const CREATE_PUBLICATION_ATTEMPTS_NO_DELETE_SQL: &str = immutable_no_delete_sql!( + "publication_attempts_no_delete", + "publication_attempts", + "publication attempts are retained" +); + +const CREATE_REPORT_PUBLICATION_MIGRATION_SQL: &str = concat!( + evidence_manifests_table_sql!(), + ";\n", + immutable_no_update_sql!( + "evidence_manifests_no_update", + "evidence_manifests", + "evidence manifests are immutable" + ), + ";\n", + immutable_no_delete_sql!( + "evidence_manifests_no_delete", + "evidence_manifests", + "evidence manifests are retained" + ), + ";\n", + trade_projections_table_sql!(), + ";\n", + immutable_no_update_sql!( + "trade_projections_no_update", + "trade_projections", + "trade projections are immutable" + ), + ";\n", + immutable_no_delete_sql!( + "trade_projections_no_delete", + "trade_projections", + "trade projections are retained" + ), + ";\n", + attestation_reports_table_sql!(), + ";\n", + attestation_reports_supersession_sql!(), + ";\n", + immutable_no_update_sql!( + "attestation_reports_no_update", + "attestation_reports", + "attestation reports are immutable" + ), + ";\n", + immutable_no_delete_sql!( + "attestation_reports_no_delete", + "attestation_reports", + "attestation reports are retained" + ), + ";\n", + signed_attestation_events_table_sql!(), + ";\n", + immutable_no_update_sql!( + "signed_attestation_events_no_update", + "signed_attestation_events", + "signed attestation events are immutable" + ), + ";\n", + immutable_no_delete_sql!( + "signed_attestation_events_no_delete", + "signed_attestation_events", + "signed attestation events are retained" + ), + ";\n", + publication_outbox_table_sql!(), + ";\n", + publication_outbox_schedule_sql!(), + ";\n", + publication_outbox_guard_update_sql!(), + ";\n", + immutable_no_delete_sql!( + "publication_outbox_no_delete", + "publication_outbox", + "publication outbox rows are retained" + ), + ";\n", + publication_targets_table_sql!(), + ";\n", + publication_targets_schedule_sql!(), + ";\n", + publication_targets_guard_update_sql!(), + ";\n", + immutable_no_delete_sql!( + "publication_targets_no_delete", + "publication_targets", + "publication targets are retained" + ), + ";\n", + publication_attempts_table_sql!(), + ";\n", + immutable_no_update_sql!( + "publication_attempts_no_update", + "publication_attempts", + "publication attempts are immutable" + ), + ";\n", + immutable_no_delete_sql!( + "publication_attempts_no_delete", + "publication_attempts", + "publication attempts are retained" + ), + ";" +); + const EVIDENCE_RECONCILIATIONS_TABLE_SHA256: [u8; 32] = [ 0xaf, 0xed, 0xa3, 0xfb, 0xfb, 0x32, 0x6b, 0xd5, 0x27, 0x26, 0x42, 0x1d, 0x6c, 0x41, 0x5e, 0xff, 0xf9, 0x71, 0xf7, 0x47, 0x72, 0x5e, 0xbf, 0x8b, 0x34, 0x26, 0x8f, 0x89, 0x6e, 0xa1, 0x2d, 0x9b, @@ -773,6 +1211,103 @@ const EVIDENCE_RECONCILIATION_SOURCES_NO_DELETE_SHA256: [u8; 32] = [ 0x98, 0x0b, 0x4c, 0xdc, 0xa2, 0xde, 0xce, 0xfc, 0x06, 0x41, 0x86, 0x69, 0x2e, 0xac, 0x00, 0x39, ]; +const EVIDENCE_MANIFESTS_TABLE_SHA256: [u8; 32] = [ + 0x95, 0x41, 0x37, 0x66, 0x31, 0x3e, 0x96, 0x83, 0x81, 0xdf, 0x0d, 0x8b, 0xc5, 0xd5, 0x50, 0x8b, + 0xde, 0x34, 0x3c, 0x68, 0xd6, 0x56, 0xea, 0xea, 0xab, 0x08, 0x87, 0x10, 0x9b, 0x23, 0xc6, 0xfb, +]; +const EVIDENCE_MANIFESTS_NO_UPDATE_SHA256: [u8; 32] = [ + 0x7f, 0xc1, 0x21, 0xaf, 0x5e, 0x9d, 0xc8, 0x32, 0xae, 0xc9, 0x98, 0x6d, 0x45, 0xc7, 0x68, 0xf3, + 0xc1, 0x76, 0x14, 0xbb, 0xf6, 0xbe, 0x41, 0x11, 0x6a, 0x7e, 0xa9, 0x22, 0x90, 0x27, 0x0e, 0xe5, +]; +const EVIDENCE_MANIFESTS_NO_DELETE_SHA256: [u8; 32] = [ + 0xd9, 0x9e, 0x5f, 0x55, 0xf7, 0xf2, 0x48, 0x5e, 0xfc, 0xc3, 0xb9, 0x11, 0x88, 0xf5, 0x8d, 0x3b, + 0xa0, 0x0c, 0x3e, 0xf7, 0x82, 0x3d, 0x88, 0xe0, 0x82, 0xc2, 0x60, 0xb8, 0x0f, 0xf4, 0xd6, 0xa4, +]; +const TRADE_PROJECTIONS_TABLE_SHA256: [u8; 32] = [ + 0xac, 0x04, 0x55, 0xd5, 0x1b, 0xed, 0xfb, 0x9b, 0x59, 0xfd, 0xc9, 0xea, 0x63, 0x1b, 0x85, 0x63, + 0x7a, 0x16, 0xf1, 0xb1, 0xfe, 0xad, 0x4d, 0x4c, 0xdf, 0xba, 0xad, 0xa3, 0xef, 0x85, 0x65, 0x43, +]; +const TRADE_PROJECTIONS_NO_UPDATE_SHA256: [u8; 32] = [ + 0x8f, 0xba, 0x6b, 0x06, 0x8e, 0xb8, 0x69, 0x84, 0x14, 0x4b, 0x64, 0x14, 0xbf, 0x8c, 0x4e, 0x95, + 0x47, 0x58, 0xaf, 0x09, 0x7d, 0xbb, 0x3a, 0xfe, 0x72, 0x06, 0x21, 0x00, 0x6a, 0x43, 0x32, 0xe0, +]; +const TRADE_PROJECTIONS_NO_DELETE_SHA256: [u8; 32] = [ + 0x94, 0x8a, 0x5c, 0xed, 0x5a, 0xaa, 0x0a, 0xb4, 0x90, 0x1c, 0xe4, 0x3a, 0xdb, 0x97, 0x69, 0xbf, + 0x5a, 0x19, 0x5d, 0x35, 0x95, 0xc9, 0x39, 0x54, 0x73, 0xe9, 0x6e, 0xea, 0x9c, 0x08, 0x26, 0xb2, +]; +const ATTESTATION_REPORTS_TABLE_SHA256: [u8; 32] = [ + 0x97, 0xc4, 0x83, 0x37, 0x56, 0x92, 0x23, 0x67, 0xb2, 0xb8, 0xd2, 0x00, 0xeb, 0xc9, 0x31, 0x0d, + 0xc8, 0xb9, 0x73, 0x38, 0xf6, 0xc5, 0x9c, 0xe5, 0xe5, 0x19, 0x87, 0x64, 0x19, 0x9d, 0x44, 0xd2, +]; +const ATTESTATION_REPORTS_SUPERSESSION_SHA256: [u8; 32] = [ + 0x57, 0xd5, 0x59, 0x2e, 0x2a, 0x7a, 0xd0, 0x03, 0x06, 0x42, 0x28, 0x16, 0x31, 0x8c, 0xe1, 0x25, + 0x30, 0x1c, 0xd3, 0x73, 0xff, 0xc3, 0x47, 0xf5, 0xf8, 0x65, 0xc0, 0x63, 0x73, 0x76, 0xad, 0x8e, +]; +const ATTESTATION_REPORTS_NO_UPDATE_SHA256: [u8; 32] = [ + 0x5f, 0x27, 0x19, 0x68, 0xe4, 0xd8, 0x32, 0x84, 0xe1, 0x04, 0x09, 0x37, 0x0b, 0xdc, 0x55, 0x9d, + 0xfa, 0x8f, 0xce, 0xa9, 0x0f, 0xd9, 0xd3, 0x47, 0xd4, 0xfa, 0xc8, 0x12, 0x53, 0x77, 0x17, 0x23, +]; +const ATTESTATION_REPORTS_NO_DELETE_SHA256: [u8; 32] = [ + 0xbe, 0xaf, 0xa1, 0x1a, 0xbe, 0x57, 0x27, 0xac, 0x3e, 0x43, 0x26, 0xd0, 0x8b, 0x0d, 0x39, 0x36, + 0xb3, 0x04, 0x11, 0x37, 0x48, 0x13, 0xd3, 0x2a, 0xc8, 0x24, 0x1f, 0x56, 0xa9, 0x2c, 0x51, 0xfc, +]; +const SIGNED_ATTESTATION_EVENTS_TABLE_SHA256: [u8; 32] = [ + 0x9b, 0x83, 0x5b, 0x84, 0x97, 0x1f, 0x52, 0xba, 0xc2, 0x8f, 0xf2, 0xba, 0x04, 0x9a, 0x1b, 0x9b, + 0xfc, 0xcc, 0x7c, 0xab, 0x39, 0xd0, 0x16, 0x53, 0x06, 0xa3, 0x9b, 0x93, 0x8c, 0x51, 0x72, 0xab, +]; +const SIGNED_ATTESTATION_EVENTS_NO_UPDATE_SHA256: [u8; 32] = [ + 0x36, 0x1d, 0xbe, 0xda, 0xae, 0xd4, 0xfc, 0x4c, 0xf3, 0xe8, 0xa8, 0x60, 0xeb, 0x63, 0xc8, 0xc7, + 0x3a, 0x31, 0x79, 0x7c, 0x4f, 0x92, 0x79, 0x8f, 0x7d, 0x3a, 0x9f, 0xec, 0x7f, 0x95, 0xa8, 0x44, +]; +const SIGNED_ATTESTATION_EVENTS_NO_DELETE_SHA256: [u8; 32] = [ + 0x06, 0x4d, 0x71, 0x90, 0x60, 0x9e, 0x6a, 0x8e, 0xac, 0x6d, 0x80, 0x44, 0xe1, 0xef, 0x53, 0x76, + 0x6a, 0xea, 0x6a, 0xb3, 0x68, 0xc9, 0x48, 0xb1, 0x64, 0x24, 0x9a, 0xf2, 0xc0, 0xc3, 0xeb, 0x9c, +]; +const PUBLICATION_OUTBOX_TABLE_SHA256: [u8; 32] = [ + 0x26, 0x2a, 0x56, 0x79, 0x77, 0x73, 0xcb, 0x84, 0xb6, 0x55, 0x1c, 0x19, 0x32, 0xe1, 0x0e, 0xb1, + 0x98, 0xf2, 0x4b, 0xf2, 0xb3, 0x5f, 0x90, 0x15, 0xac, 0xc1, 0x8f, 0x27, 0xfd, 0x89, 0x4b, 0x2d, +]; +const PUBLICATION_OUTBOX_SCHEDULE_SHA256: [u8; 32] = [ + 0xc0, 0x70, 0xb5, 0xb0, 0xd0, 0x1f, 0x05, 0x34, 0xe9, 0x1e, 0xfa, 0xee, 0x6c, 0xba, 0xdd, 0xf8, + 0x5c, 0xf1, 0x85, 0x33, 0xa7, 0x04, 0x5c, 0xfb, 0x65, 0x11, 0xdc, 0x80, 0x28, 0x7b, 0x4f, 0x6e, +]; +const PUBLICATION_OUTBOX_GUARD_UPDATE_SHA256: [u8; 32] = [ + 0x3e, 0xd0, 0x80, 0x98, 0x34, 0xb2, 0x24, 0x51, 0x5b, 0x7b, 0x06, 0x8e, 0x46, 0x8e, 0xbe, 0x8a, + 0x52, 0x8b, 0xdb, 0xcf, 0xb4, 0x0e, 0x33, 0xe5, 0x19, 0xc6, 0xfd, 0xef, 0x19, 0x80, 0xcd, 0x62, +]; +const PUBLICATION_OUTBOX_NO_DELETE_SHA256: [u8; 32] = [ + 0xc5, 0x77, 0x63, 0xa0, 0x90, 0xde, 0xe0, 0x11, 0x62, 0x2a, 0xda, 0x9f, 0x32, 0x24, 0x47, 0x59, + 0x54, 0xdf, 0x60, 0xd7, 0xe3, 0xf2, 0xf3, 0x42, 0x36, 0x14, 0x8e, 0xba, 0x52, 0x84, 0x3a, 0x6e, +]; +const PUBLICATION_TARGETS_TABLE_SHA256: [u8; 32] = [ + 0x23, 0xa0, 0x78, 0x7f, 0x7d, 0x90, 0x91, 0x8b, 0x41, 0x4a, 0x22, 0x37, 0xcc, 0x70, 0xa0, 0x83, + 0x1a, 0xe0, 0xfa, 0x05, 0x2e, 0x1b, 0x9f, 0x25, 0x50, 0xe3, 0x6f, 0xda, 0xd1, 0x53, 0xab, 0x7b, +]; +const PUBLICATION_TARGETS_SCHEDULE_SHA256: [u8; 32] = [ + 0xf8, 0xc4, 0x46, 0x10, 0xcb, 0x2d, 0xe4, 0xa3, 0x54, 0xd7, 0x93, 0x64, 0x9f, 0x8b, 0xa9, 0xf1, + 0x95, 0xfd, 0xba, 0x5d, 0xfc, 0xc1, 0xf9, 0x92, 0xe9, 0x08, 0x08, 0x7e, 0x56, 0x6a, 0x14, 0xde, +]; +const PUBLICATION_TARGETS_GUARD_UPDATE_SHA256: [u8; 32] = [ + 0x57, 0x10, 0x3b, 0xa2, 0xc3, 0xd7, 0x88, 0xd3, 0x83, 0x3a, 0xba, 0xed, 0xcd, 0xad, 0x49, 0x5e, + 0xce, 0xfe, 0x6e, 0xbd, 0x63, 0x2a, 0x98, 0x14, 0x04, 0x10, 0x86, 0xb9, 0x84, 0x10, 0x72, 0x0c, +]; +const PUBLICATION_TARGETS_NO_DELETE_SHA256: [u8; 32] = [ + 0x53, 0x97, 0x8a, 0xa5, 0xca, 0x4d, 0xef, 0x0e, 0xc4, 0x75, 0xc4, 0x55, 0xf6, 0x10, 0x43, 0xf7, + 0x1b, 0x10, 0x15, 0x6b, 0x2b, 0x56, 0xe0, 0x6c, 0x50, 0x4a, 0xc7, 0xee, 0x57, 0x3e, 0x74, 0x4e, +]; +const PUBLICATION_ATTEMPTS_TABLE_SHA256: [u8; 32] = [ + 0x7d, 0xa6, 0xea, 0xbe, 0xfc, 0x07, 0xeb, 0x16, 0xde, 0x5c, 0x47, 0xc9, 0x20, 0xfd, 0x64, 0x76, + 0xa9, 0xe9, 0x8d, 0xce, 0xe9, 0x31, 0x84, 0x45, 0x8d, 0xd2, 0x98, 0xb8, 0x53, 0x52, 0x03, 0x28, +]; +const PUBLICATION_ATTEMPTS_NO_UPDATE_SHA256: [u8; 32] = [ + 0xc3, 0xe5, 0x51, 0x35, 0x06, 0xad, 0x45, 0xca, 0xea, 0xe5, 0xaa, 0x7e, 0xa2, 0x40, 0xe7, 0x0a, + 0xde, 0x7c, 0xf7, 0x7f, 0x6d, 0x9a, 0x67, 0x76, 0x92, 0x5e, 0x12, 0x06, 0xc4, 0x55, 0xbf, 0x65, +]; +const PUBLICATION_ATTEMPTS_NO_DELETE_SHA256: [u8; 32] = [ + 0x6d, 0x8a, 0x4e, 0x03, 0x67, 0x52, 0x51, 0x1a, 0x4e, 0xe7, 0xaa, 0x41, 0x0c, 0x31, 0xaa, 0xc9, + 0xd3, 0x5c, 0x42, 0x48, 0xe3, 0x78, 0xfa, 0x39, 0x7c, 0x9b, 0x90, 0xa0, 0xe3, 0x72, 0x19, 0x29, +]; + const RECONCILIATION_JOBS_TABLE_SHA256: [u8; 32] = [ 0xe7, 0x0b, 0x5c, 0xb7, 0x26, 0x91, 0x9d, 0x02, 0xef, 0xb3, 0xa6, 0x21, 0x58, 0x48, 0xce, 0x92, 0x30, 0x88, 0x17, 0x2b, 0x3f, 0xe0, 0xc1, 0x40, 0xee, 0x4a, 0xc7, 0x1b, 0xd0, 0x3c, 0x66, 0xa7, @@ -989,16 +1524,24 @@ pub fn rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogError> MigrationChecksum::from_bytes(RHI_STATE_SCHEMA_VERSION_6_MIGRATION_SHA256), ) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?; + let report_publication = MigrationDescriptor::sql( + 7, + "create_reports_and_publication_outbox", + CREATE_REPORT_PUBLICATION_MIGRATION_SQL, + MigrationChecksum::from_bytes(RHI_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256), + ) + .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?; let catalog = MigrationCatalog::new([ configuration, trade_evidence, source_checkpoints, reconciliation_jobs, reconciliation_results, + report_publication, ]) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?; if catalog.current_version() != RHI_STATE_SCHEMA_VERSION - || catalog.descriptors().len() != 5 + || catalog.descriptors().len() != 6 || catalog.digest().as_bytes() != &RHI_MIGRATION_CATALOG_SHA256 { return Err(RhiStateCatalogError::new( @@ -1042,11 +1585,17 @@ pub fn rhi_schema_catalog() -> Result<SchemaCatalog, RhiStateCatalogError> { ) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?; let version_six = SchemaVersionCatalog::new( - RHI_STATE_SCHEMA_VERSION, + 6, rhi_schema_version_six_objects()?, SchemaDigest::from_bytes(RHI_STATE_SCHEMA_VERSION_6_SHA256), ) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?; + let version_seven = SchemaVersionCatalog::new( + RHI_STATE_SCHEMA_VERSION, + rhi_schema_version_seven_objects()?, + SchemaDigest::from_bytes(RHI_STATE_SCHEMA_VERSION_7_SHA256), + ) + .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?; let catalog = SchemaCatalog::new( &migrations, [ @@ -1056,6 +1605,7 @@ pub fn rhi_schema_catalog() -> Result<SchemaCatalog, RhiStateCatalogError> { version_four, version_five, version_six, + version_seven, ], ) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?; @@ -1070,10 +1620,10 @@ pub fn validate_rhi_state_catalogs( ) -> Result<(), RhiStateCatalogError> { let versions = schema.versions(); let valid = migrations.current_version() == RHI_STATE_SCHEMA_VERSION - && migrations.descriptors().len() == 5 + && migrations.descriptors().len() == 6 && migrations.digest().as_bytes() == &RHI_MIGRATION_CATALOG_SHA256 && schema.migration_catalog_digest() == migrations.digest() - && versions.len() == 6 + && versions.len() == 7 && versions[0].version() == RHI_STATE_BASE_SCHEMA_VERSION && versions[0].object_count() == RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT && versions[0].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_1_SHA256 @@ -1089,9 +1639,12 @@ pub fn validate_rhi_state_catalogs( && versions[4].version() == 5 && versions[4].object_count() == RHI_STATE_SCHEMA_VERSION_5_OBJECT_COUNT && versions[4].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_5_SHA256 - && versions[5].version() == RHI_STATE_SCHEMA_VERSION + && versions[5].version() == 6 && versions[5].object_count() == RHI_STATE_SCHEMA_VERSION_6_OBJECT_COUNT && versions[5].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_6_SHA256 + && versions[6].version() == RHI_STATE_SCHEMA_VERSION + && versions[6].object_count() == RHI_STATE_SCHEMA_VERSION_7_OBJECT_COUNT + && versions[6].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_7_SHA256 && schema.digest().as_bytes() == &RHI_STATE_SCHEMA_CATALOG_SHA256; if valid { Ok(()) @@ -1163,6 +1716,195 @@ fn rhi_schema_version_six_objects() -> Result<Vec<SchemaObject>, RhiStateCatalog Ok(objects) } +fn rhi_schema_version_seven_objects() -> Result<Vec<SchemaObject>, RhiStateCatalogError> { + let mut objects = rhi_schema_version_six_objects()?; + objects.extend(rhi_report_publication_objects()?); + Ok(objects) +} + +fn rhi_report_publication_objects() -> Result<[SchemaObject; 24], RhiStateCatalogError> { + let object = |kind, name, table_name, sql, digest| { + SchemaObject::new( + kind, + name, + table_name, + sql, + SchemaDigest::from_bytes(digest), + ) + .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog)) + }; + Ok([ + object( + SchemaObjectKind::Table, + "evidence_manifests", + "evidence_manifests", + CREATE_EVIDENCE_MANIFESTS_TABLE_SQL, + EVIDENCE_MANIFESTS_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "evidence_manifests_no_update", + "evidence_manifests", + CREATE_EVIDENCE_MANIFESTS_NO_UPDATE_SQL, + EVIDENCE_MANIFESTS_NO_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "evidence_manifests_no_delete", + "evidence_manifests", + CREATE_EVIDENCE_MANIFESTS_NO_DELETE_SQL, + EVIDENCE_MANIFESTS_NO_DELETE_SHA256, + )?, + object( + SchemaObjectKind::Table, + "trade_projections", + "trade_projections", + CREATE_TRADE_PROJECTIONS_TABLE_SQL, + TRADE_PROJECTIONS_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "trade_projections_no_update", + "trade_projections", + CREATE_TRADE_PROJECTIONS_NO_UPDATE_SQL, + TRADE_PROJECTIONS_NO_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "trade_projections_no_delete", + "trade_projections", + CREATE_TRADE_PROJECTIONS_NO_DELETE_SQL, + TRADE_PROJECTIONS_NO_DELETE_SHA256, + )?, + object( + SchemaObjectKind::Table, + "attestation_reports", + "attestation_reports", + CREATE_ATTESTATION_REPORTS_TABLE_SQL, + ATTESTATION_REPORTS_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Index, + "attestation_reports_one_successor", + "attestation_reports", + CREATE_ATTESTATION_REPORTS_SUPERSESSION_SQL, + ATTESTATION_REPORTS_SUPERSESSION_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "attestation_reports_no_update", + "attestation_reports", + CREATE_ATTESTATION_REPORTS_NO_UPDATE_SQL, + ATTESTATION_REPORTS_NO_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "attestation_reports_no_delete", + "attestation_reports", + CREATE_ATTESTATION_REPORTS_NO_DELETE_SQL, + ATTESTATION_REPORTS_NO_DELETE_SHA256, + )?, + object( + SchemaObjectKind::Table, + "signed_attestation_events", + "signed_attestation_events", + CREATE_SIGNED_ATTESTATION_EVENTS_TABLE_SQL, + SIGNED_ATTESTATION_EVENTS_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "signed_attestation_events_no_update", + "signed_attestation_events", + CREATE_SIGNED_ATTESTATION_EVENTS_NO_UPDATE_SQL, + SIGNED_ATTESTATION_EVENTS_NO_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "signed_attestation_events_no_delete", + "signed_attestation_events", + CREATE_SIGNED_ATTESTATION_EVENTS_NO_DELETE_SQL, + SIGNED_ATTESTATION_EVENTS_NO_DELETE_SHA256, + )?, + object( + SchemaObjectKind::Table, + "publication_outbox", + "publication_outbox", + CREATE_PUBLICATION_OUTBOX_TABLE_SQL, + PUBLICATION_OUTBOX_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Index, + "publication_outbox_by_schedule", + "publication_outbox", + CREATE_PUBLICATION_OUTBOX_SCHEDULE_SQL, + PUBLICATION_OUTBOX_SCHEDULE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "publication_outbox_guard_update", + "publication_outbox", + CREATE_PUBLICATION_OUTBOX_GUARD_UPDATE_SQL, + PUBLICATION_OUTBOX_GUARD_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "publication_outbox_no_delete", + "publication_outbox", + CREATE_PUBLICATION_OUTBOX_NO_DELETE_SQL, + PUBLICATION_OUTBOX_NO_DELETE_SHA256, + )?, + object( + SchemaObjectKind::Table, + "publication_targets", + "publication_targets", + CREATE_PUBLICATION_TARGETS_TABLE_SQL, + PUBLICATION_TARGETS_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Index, + "publication_targets_by_schedule", + "publication_targets", + CREATE_PUBLICATION_TARGETS_SCHEDULE_SQL, + PUBLICATION_TARGETS_SCHEDULE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "publication_targets_guard_update", + "publication_targets", + CREATE_PUBLICATION_TARGETS_GUARD_UPDATE_SQL, + PUBLICATION_TARGETS_GUARD_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "publication_targets_no_delete", + "publication_targets", + CREATE_PUBLICATION_TARGETS_NO_DELETE_SQL, + PUBLICATION_TARGETS_NO_DELETE_SHA256, + )?, + object( + SchemaObjectKind::Table, + "publication_attempts", + "publication_attempts", + CREATE_PUBLICATION_ATTEMPTS_TABLE_SQL, + PUBLICATION_ATTEMPTS_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "publication_attempts_no_update", + "publication_attempts", + CREATE_PUBLICATION_ATTEMPTS_NO_UPDATE_SQL, + PUBLICATION_ATTEMPTS_NO_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "publication_attempts_no_delete", + "publication_attempts", + CREATE_PUBLICATION_ATTEMPTS_NO_DELETE_SQL, + PUBLICATION_ATTEMPTS_NO_DELETE_SHA256, + )?, + ]) +} + fn rhi_reconciliation_result_objects() -> Result<[SchemaObject; 6], RhiStateCatalogError> { let object = |kind, name, table_name, sql, digest| { SchemaObject::new( diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -27,7 +27,7 @@ fn source_lock_metadata_is_exact_and_nix_is_absent() { )); for field in [ "config_contract_version = 1", - "state_contract_version = 6", + "state_contract_version = 7", "admin_contract_version = 1", "status_contract_version = 1", "provider_contract_version = 1", @@ -93,7 +93,7 @@ fn source_lock_binds_the_current_cargo_lock() { assert!(!SOURCE_LOCK.contains("flake_lock_sha256")); assert!(!SOURCE_LOCK.contains("lib_revision =")); assert!(SOURCE_LOCK.ends_with( - "[contract_versions]\nconfig = 1\nstate = 6\nadmin = 1\nstatus = 1\nprovider = 1\n" + "[contract_versions]\nconfig = 1\nstate = 7\nadmin = 1\nstatus = 1\nprovider = 1\n" )); } diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -11,6 +11,9 @@ const RUNTIME_ADAPTERS: &str = include_str!("../src/runtime_adapters.rs"); 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_CONTRACT: &str = + include_str!("../contracts/services_hardening/publication_outbox.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"); @@ -51,6 +54,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/features/trade_agreement_attestation.rs"), include_str!("../src/identity_credential.rs"), include_str!("../src/identity_envelope.rs"), + include_str!("../src/publication.rs"), include_str!("../src/reconciliation_attempt.rs"), include_str!("../src/reconciliation_attestation.rs"), include_str!("../src/reconciliation_commit.rs"), @@ -125,6 +129,7 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "features", "identity_credential", "identity_envelope", + "publication", "reconciliation_attempt", "reconciliation_attestation", "reconciliation_commit", @@ -168,6 +173,12 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "validate_rhi_state_catalogs", "RhiStateCatalogError", "RhiRuntimeAdapters", + "RhiPublicationAuthority", + "RhiPublicationErrorKind", + "RhiPublicationMode", + "RhiPublicationRetryPolicy", + "RhiPublicationTarget", + "RHI_PUBLICATION_CONTRACT_VERSION", "RhiReconciliationAttemptPlan", "RhiReconciliationSourceRequest", "RhiReconciliationSourceResult", @@ -250,6 +261,44 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { assert!(!PUBLIC_API.contains("rhi::adapters::")); assert!(!PUBLIC_API.contains("rhi::features::")); assert!(!PUBLIC_API.contains("rhi::runtime_adapters::")); + assert!(!PUBLIC_API.contains("rhi::publication::")); +} + +#[test] +fn publication_authority_is_config_derived_sealed_and_effect_free() { + let contract: serde_json::Value = + serde_json::from_str(PUBLICATION_CONTRACT).expect("publication contract"); + assert_eq!(contract["schema"], "radroots.rhi.publication-outbox"); + assert_eq!(contract["schema_version"], 1); + assert_eq!(contract["state_schema_version"], 7); + assert_eq!(contract["effects"]["sqlite_query_or_mutation"], false); + assert_eq!(contract["effects"]["relay_or_network"], false); + for required in [ + "pub fn from_config(config: &RhiConfigDocumentV1)", + "RhiPublicationMode::Required", + "RhiPublicationMode::Disabled", + "target_set_digest(&targets)?", + "authority_digest(", + ] { + assert!( + PUBLICATION.contains(required), + "publication authority is missing {required}" + ); + } + for forbidden in [ + "sqlx::", + "std::fs", + "std::net", + "tokio::", + "SystemTime", + "thread_rng", + "OsRng", + ] { + assert!( + !PUBLICATION.contains(forbidden), + "publication authority gained forbidden effect {forbidden}" + ); + } } #[test] @@ -503,7 +552,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, 24); + assert_eq!(public_error_count, 25); } #[test] @@ -932,6 +981,8 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() { "Step 199 must rerun the same validator inside the final", "## Canonical signed reconciliation attestation", "[`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)", "The event body and exact kind-3441 structural tags", "without rebuilding, reserializing, or", "Coverage is exactly `Missing`, `Partial`, `ScopeSatisfied`, or `Unsupported`", @@ -949,6 +1000,9 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() { "4,096 distinct signed-event identities (event ID plus signature) and 8 MiB", "observation, and operational retry do not", "schema-v6 immutable", + "schema-v7 immutable report, signed-event,", + "The schema-v7", + "catalog is frozen by Step 198", "one canonical mutation", "every distinct valid signed", "does not advance reconciliation checkpoints or dirty generation", diff --git a/tests/services_hardening_publication_contract.rs b/tests/services_hardening_publication_contract.rs @@ -0,0 +1,185 @@ +#![forbid(unsafe_code)] + +use std::error::Error; + +use rhi::{ + RHI_PUBLICATION_CONTRACT_VERSION, RHI_PUBLICATION_MAX_ATTEMPTS, RHI_PUBLICATION_MAX_TARGETS, + RHI_STATE_SCHEMA_VERSION, RhiConfigProfile, RhiPublicationAuthority, RhiPublicationMode, + parse_rhi_config_v1, +}; +use serde_json::json; + +const CONTRACT: &str = include_str!("../contracts/services_hardening/publication_outbox.v1.json"); +const EXAMPLE: &[u8] = include_bytes!("../contracts/services_hardening/config.v1.example.toml"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const PUBLICATION_SOURCE: &str = include_str!("../src/publication.rs"); +const CATALOG_SOURCE: &str = include_str!("../src/state_catalog.rs"); +const README: &str = include_str!("../README"); + +#[test] +fn machine_contract_freezes_step_198_authority_and_schema() { + let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract"); + assert_eq!(contract["schema"], "radroots.rhi.publication-outbox"); + assert_eq!(contract["schema_version"], 1); + assert_eq!( + contract["contract_version"], + RHI_PUBLICATION_CONTRACT_VERSION + ); + assert_eq!(contract["state_schema_version"], RHI_STATE_SCHEMA_VERSION); + assert_eq!( + contract["publication_modes"], + json!(["required", "disabled"]) + ); + assert_eq!( + contract["authority"]["required"]["target_count"]["maximum"], + RHI_PUBLICATION_MAX_TARGETS + ); + assert_eq!( + contract["schema_objects"]["immutable"], + json!([ + "evidence_manifests", + "trade_projections", + "attestation_reports", + "signed_attestation_events", + "publication_attempts" + ]) + ); + assert_eq!( + contract["schema_objects"]["compare_and_swap"], + json!(["publication_outbox", "publication_targets"]) + ); + assert_eq!( + contract["schema_objects"]["target_states"], + json!([ + "pending", + "submitted", + "accepted", + "rejected", + "rate_limited", + "auth_required", + "failed", + "unknown" + ]) + ); + assert_eq!(contract["payload"]["signed_bytes_maximum"], 32_768); + assert_eq!(contract["effects"]["sqlite_query_or_mutation"], false); + assert_eq!(contract["effects"]["relay_or_network"], false); + for forbidden in [ + "implicit_publication_mode", + "disabled_mode_target_or_retry_state", + "caller_forged_target", + "non_write_relay_target", + "mutable_signed_payload", + "mutable_target_identity", + "raw_upstream_error_text", + "raw_sqlite_handle", + "relay_io", + "event_rebuild", + "event_reserialize", + "event_resign", + "unbounded_queue_or_target_inventory", + ] { + assert!( + contract["forbidden"] + .as_array() + .expect("forbidden") + .iter() + .any(|value| value == forbidden), + "missing forbidden boundary {forbidden}" + ); + } +} + +#[test] +fn canonical_example_has_literal_publication_identities() { + let config = parse_rhi_config_v1(EXAMPLE, RhiConfigProfile::Production).expect("config"); + let authority = RhiPublicationAuthority::from_config(&config).expect("authority"); + assert_eq!(authority.mode(), RhiPublicationMode::Required); + assert_eq!(authority.targets().len(), 2); + let retry = authority.retry_policy().expect("retry"); + assert_eq!(retry.maximum_attempts(), 10); + assert!(retry.maximum_attempts() <= RHI_PUBLICATION_MAX_ATTEMPTS); + assert_eq!( + lower_hex(authority.target_set_sha256()), + "fc044570890935bc41f4763b92d0e079ea0288e0a77a03fc1e299a4c830bbd76" + ); + assert_eq!( + lower_hex(authority.authority_sha256()), + "6df5c3bf1bbb5c7b57d81bd1ee600677501a05d6e3c3aaf7577d64ffcbd9566b" + ); +} + +#[test] +fn module_and_side_effect_authority_remain_private_and_deferred() { + assert!(LIB_SOURCE.contains("mod publication;")); + assert!(!LIB_SOURCE.contains("pub mod publication;")); + assert!(LIB_SOURCE.contains("RhiPublicationAuthority")); + assert!(README.contains("## Explicit publication authority and durable schema")); + assert!(README.contains( + "[`publication_outbox.v1.json`](contracts/services_hardening/publication_outbox.v1.json)" + )); + for table in [ + "evidence_manifests", + "trade_projections", + "attestation_reports", + "signed_attestation_events", + "publication_outbox", + "publication_targets", + "publication_attempts", + ] { + assert!(CATALOG_SOURCE.contains(&format!("CREATE TABLE {table}"))); + } + assert!(CATALOG_SOURCE.contains("OR OLD.state = 'accepted'")); + assert!(CATALOG_SOURCE.contains("AND next_attempt_unix_ms IS NOT NULL")); + assert!(CATALOG_SOURCE.contains("AND lease_owner IS NOT NULL")); + assert!(CATALOG_SOURCE.contains("AND lease_expires_unix_ms IS NOT NULL")); + for forbidden in [ + "sqlx::", + "radroots_transport", + "PublicationSink", + "SystemTime", + "thread_rng", + "OsRng", + "std::fs", + "std::net", + "tokio::spawn", + "spawn_blocking", + "pub fn sqlite", + "pub fn transaction", + "pub fn connection", + "pub fn into_inner", + ] { + assert!( + !PUBLICATION_SOURCE.contains(forbidden), + "premature publication authority {forbidden}" + ); + } +} + +#[test] +fn public_authority_debug_and_errors_reveal_no_targets_or_digests() { + let config = parse_rhi_config_v1(EXAMPLE, RhiConfigProfile::Production).expect("config"); + let authority = RhiPublicationAuthority::from_config(&config).expect("authority"); + let rendered = format!("{authority:?}"); + assert!(!rendered.contains("relay-primary")); + assert!(!rendered.contains(&lower_hex(authority.authority_sha256()))); + + let invalid = core::str::from_utf8(EXAMPLE) + .expect("utf8") + .replace("mode = \"required\"", "mode = \"invalid-secret\""); + let error = parse_rhi_config_v1(invalid.as_bytes(), RhiConfigProfile::Production) + .expect_err("invalid config"); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains("invalid-secret")); + assert!(Error::source(&error).is_none()); +} + +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 +} diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs @@ -17,8 +17,9 @@ use rhi::{ RHI_STATE_SCHEMA_VERSION_5_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_5_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_5_SHA256, RHI_STATE_SCHEMA_VERSION_6_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_6_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_6_SHA256, - RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog, - validate_rhi_state_catalogs, + RHI_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_7_OBJECT_COUNT, + RHI_STATE_SCHEMA_VERSION_7_SHA256, RhiStateCatalogErrorKind, rhi_migration_catalog, + rhi_schema_catalog, validate_rhi_state_catalogs, }; const CATALOG_SOURCE: &str = include_str!("../src/state_catalog.rs"); @@ -26,14 +27,14 @@ const LIB_SOURCE: &str = include_str!("../src/lib.rs"); const MANIFEST: &str = include_str!("../Cargo.toml"); #[test] -fn schema_v1_through_v6_catalogs_have_exact_literal_identities() { +fn schema_v1_through_v7_catalogs_have_exact_literal_identities() { let migrations = rhi_migration_catalog().expect("RHI migration catalog"); let schema = rhi_schema_catalog().expect("RHI schema catalog"); assert_eq!(RHI_STATE_BASE_SCHEMA_VERSION, 1); - assert_eq!(RHI_STATE_SCHEMA_VERSION, 6); - assert_eq!(migrations.descriptors().len(), 5); - assert_eq!(migrations.current_version(), 6); + assert_eq!(RHI_STATE_SCHEMA_VERSION, 7); + assert_eq!(migrations.descriptors().len(), 6); + assert_eq!(migrations.current_version(), 7); assert_eq!(migrations.descriptors()[0].target_version(), 2); assert_eq!( migrations.descriptors()[0].name().as_str(), @@ -79,12 +80,21 @@ fn schema_v1_through_v6_catalogs_have_exact_literal_identities() { migrations.descriptors()[4].checksum().as_bytes(), &RHI_STATE_SCHEMA_VERSION_6_MIGRATION_SHA256 ); + assert_eq!(migrations.descriptors()[5].target_version(), 7); + assert_eq!( + migrations.descriptors()[5].name().as_str(), + "create_reports_and_publication_outbox" + ); + assert_eq!( + migrations.descriptors()[5].checksum().as_bytes(), + &RHI_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256 + ); assert_eq!( migrations.digest().as_bytes(), &RHI_MIGRATION_CATALOG_SHA256 ); - assert_eq!(schema.versions().len(), 6); + assert_eq!(schema.versions().len(), 7); let version = schema.versions()[0]; assert_eq!(version.version(), 1); assert_eq!( @@ -151,13 +161,24 @@ fn schema_v1_through_v6_catalogs_have_exact_literal_identities() { version.digest().as_bytes(), &RHI_STATE_SCHEMA_VERSION_6_SHA256 ); + let version = schema.versions()[6]; + assert_eq!(version.version(), 7); + assert_eq!( + version.object_count(), + RHI_STATE_SCHEMA_VERSION_7_OBJECT_COUNT + ); + assert_eq!(version.object_count(), 63); + assert_eq!( + version.digest().as_bytes(), + &RHI_STATE_SCHEMA_VERSION_7_SHA256 + ); assert_eq!(schema.digest().as_bytes(), &RHI_STATE_SCHEMA_CATALOG_SHA256); assert_eq!(schema.migration_catalog_digest(), migrations.digest()); validate_rhi_state_catalogs(&migrations, &schema).expect("exact catalogs"); assert_eq!( lower_hex(&RHI_MIGRATION_CATALOG_SHA256), - "32f49f1c50f54c722d94d9343a052e67142d00747a0bb1cc1cb5cabafa30fe4c" + "bf955884e973de04b98a57986562ef3805ad82ce3da6a83ab5890a50e06bd5f4" ); assert_eq!( lower_hex(&RHI_STATE_SCHEMA_VERSION_1_SHA256), @@ -204,8 +225,16 @@ fn schema_v1_through_v6_catalogs_have_exact_literal_identities() { "5d1fa9508b5b8a0c8065fd37f11c6866d51f9294c75272041f349e95ced50fb4" ); assert_eq!( + lower_hex(&RHI_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256), + "9debf4f3caad4d018311cb169bf9282264d6abfc49e718840b9cbfad42ed357c" + ); + assert_eq!( + lower_hex(&RHI_STATE_SCHEMA_VERSION_7_SHA256), + "840aa83c689f9df99d26c5b4ef117131c270ef7522674037def79628a2b03946" + ); + assert_eq!( lower_hex(&RHI_STATE_SCHEMA_CATALOG_SHA256), - "8c1982b295d6544bbe6df679424ee6caf3476831b38de9946b115fbc5b246b03" + "ec31809d6207fd98b204e56939731197f29787bd1fef628d9b345db020711fec" ); } @@ -261,6 +290,10 @@ fn independent_validator_rejects_migration_or_schema_drift() { SchemaVersionCatalog::computed_digest(6, [version_two_object()]).expect("v6 digest"); let version_six = SchemaVersionCatalog::new(6, [version_two_object()], snapshot_digest).expect("version six"); + let snapshot_digest = + SchemaVersionCatalog::computed_digest(7, [version_two_object()]).expect("v7 digest"); + let version_seven = SchemaVersionCatalog::new(7, [version_two_object()], snapshot_digest) + .expect("version seven"); let schema = SchemaCatalog::new( &exact_migrations, [ @@ -270,6 +303,7 @@ fn independent_validator_rejects_migration_or_schema_drift() { version_four, version_five, version_six, + version_seven, ], ) .expect("drift schema catalog"); @@ -324,6 +358,10 @@ fn catalog_errors_are_stable_source_free_and_redacted() { SchemaVersionCatalog::computed_digest(6, [secret_object()]).expect("v6 digest"); let version_six = SchemaVersionCatalog::new(6, [secret_object()], version_six_digest).expect("version six"); + let version_seven_digest = + SchemaVersionCatalog::computed_digest(7, [secret_object()]).expect("v7 digest"); + let version_seven = SchemaVersionCatalog::new(7, [secret_object()], version_seven_digest) + .expect("version seven"); let schema = SchemaCatalog::new( &migrations, [ @@ -333,6 +371,7 @@ fn catalog_errors_are_stable_source_free_and_redacted() { version_four, version_five, version_six, + version_seven, ], ) .expect("schema catalog"); diff --git a/tests/services_hardening_state_host.rs b/tests/services_hardening_state_host.rs @@ -11,6 +11,7 @@ use rhi::{ initialize_rhi_state, open_rhi_state_inspection, open_rhi_state_read_write, parse_rhi_cli_v1_from, parse_rhi_config_v1, resolve_rhi_runtime_context, }; +use sqlx::{Connection, SqliteConnection, sqlite::SqliteConnectOptions}; const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); @@ -210,6 +211,100 @@ async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly( } #[tokio::test] +async fn publication_schema_rejects_null_state_holes_and_accepted_target_mutation() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "publication-schema"); + prepare_state_directory(&runtime); + let metadata = metadata(&runtime); + let (applied_at, build) = migration_evidence(); + initialize_rhi_state(&runtime, &metadata, applied_at, &build) + .await + .expect("state initialization"); + + let options = SqliteConnectOptions::new() + .filename(runtime.artifacts().state_database()) + .create_if_missing(false) + .foreign_keys(false); + let mut connection = SqliteConnection::connect_with(&options) + .await + .expect("offline fixture connection"); + + let outbox_id = [0x21_u8; 32]; + let event_id = [0x22_u8; 32]; + let event_sha256 = [0x23_u8; 32]; + let authority_sha256 = [0x24_u8; 32]; + let target_set_sha256 = [0x25_u8; 32]; + let missing_pending_schedule = sqlx::query( + r#"INSERT INTO publication_outbox ( + outbox_id, event_id, event_sha256, publication_authority_sha256, + target_set_sha256, target_count, required_target_count, + max_attempts, initial_backoff_ms, maximum_backoff_ms, + attempt_deadline_ms, state, revision, next_attempt_unix_ms, + lease_owner, lease_expires_unix_ms, created_at_unix_ms, + updated_at_unix_ms + ) VALUES (?, ?, ?, ?, ?, 1, 1, 3, 100, 1000, 5000, + 'pending', 1, NULL, NULL, NULL, 10, 10)"#, + ) + .bind(outbox_id.as_slice()) + .bind(event_id.as_slice()) + .bind(event_sha256.as_slice()) + .bind(authority_sha256.as_slice()) + .bind(target_set_sha256.as_slice()) + .execute(&mut connection) + .await; + assert!( + missing_pending_schedule.is_err(), + "pending outbox rows require a concrete next-attempt time" + ); + + sqlx::query( + r#"INSERT INTO publication_outbox ( + outbox_id, event_id, event_sha256, publication_authority_sha256, + target_set_sha256, target_count, required_target_count, + max_attempts, initial_backoff_ms, maximum_backoff_ms, + attempt_deadline_ms, state, revision, next_attempt_unix_ms, + lease_owner, lease_expires_unix_ms, created_at_unix_ms, + updated_at_unix_ms + ) VALUES (?, ?, ?, ?, ?, 1, 1, 3, 100, 1000, 5000, + 'pending', 1, 10, NULL, NULL, 10, 10)"#, + ) + .bind(outbox_id.as_slice()) + .bind(event_id.as_slice()) + .bind(event_sha256.as_slice()) + .bind(authority_sha256.as_slice()) + .bind(target_set_sha256.as_slice()) + .execute(&mut connection) + .await + .expect("valid pending outbox row"); + + sqlx::query( + r#"INSERT INTO publication_targets ( + outbox_id, target_ordinal, relay_id, required, state, revision, + attempt_count, next_attempt_unix_ms, last_attempt_id, + updated_at_unix_ms + ) VALUES (?, 0, 'relay_a', 1, 'accepted', 1, 0, NULL, NULL, 10)"#, + ) + .bind(outbox_id.as_slice()) + .execute(&mut connection) + .await + .expect("accepted target fixture"); + let accepted_mutation = sqlx::query( + r#"UPDATE publication_targets + SET revision = 2, updated_at_unix_ms = 11 + WHERE outbox_id = ? AND target_ordinal = 0"#, + ) + .bind(outbox_id.as_slice()) + .execute(&mut connection) + .await; + assert!( + accepted_mutation.is_err(), + "accepted publication targets are terminal" + ); + + connection.close().await.expect("fixture connection close"); +} + +#[tokio::test] async fn missing_state_and_mismatched_evidence_fail_before_database_creation() { let directory = tempfile::tempdir().expect("temporary root"); let primary = runtime(directory.path(), "primary"); diff --git a/tests/services_hardening_state_resilience.rs b/tests/services_hardening_state_resilience.rs @@ -347,7 +347,7 @@ async fn exact_open_rejects_unexpected_migration_history_without_repair() { service_version, service_commit, lib_revision, rust_version, target, feature_profile, config_contract_version, state_contract_version, admin_contract_version, status_contract_version, provider_contract_version - ) VALUES (7, 'unexpected_schema', ?, 1725000000, '0.1.0', ?, ?, + ) VALUES (8, 'unexpected_schema', ?, 1725000000, '0.1.0', ?, ?, 'rustc-test', 'test-target', 'service-host', 1, 7, 1, 1, 1)", ) .bind([0x44_u8; 32].as_slice()) diff --git a/tests/source_guards.rs b/tests/source_guards.rs @@ -186,6 +186,31 @@ fn rhi_wave_one_removes_prototype_runtime_and_selection_authority() { } } +#[test] +fn step_198_publication_boundary_has_no_runtime_or_storage_authority() { + let source = read_repo_file("src/publication.rs"); + let root = read_repo_file("src/lib.rs"); + + assert!(root.contains("mod publication;")); + assert!(!root.contains("pub mod publication;")); + for forbidden in [ + "sqlx::", + "radroots_transport", + "std::fs", + "std::net", + "tokio::", + "SystemTime", + "thread_rng", + "OsRng", + "PublicationSink", + ] { + assert!( + !source.contains(forbidden), + "Step 198 publication authority gained deferred behavior `{forbidden}`" + ); + } +} + fn read_repo_file(relative_path: &str) -> String { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative_path); fs::read_to_string(path.as_path())