rhi

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

commit da1a1c901fd1aa8028af33a2321dc7645aac2af0
parent 137d6e6902de01025bf2a4b404a4504dbb0dc85a
Author: triesap <tyson@radroots.org>
Date:   Mon, 24 Aug 2026 14:06:14 +0000

refactor(rhi): persist desired presence state

Diffstat:
MAGENTS.md | 9+++++++++
MREADME | 19+++++++++++++++++++
Mcontracts/api_baselines/rhi.txt | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/services_hardening/presence_desired_state.v1.json | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 12++++++++++--
Asrc/presence_desired.rs | 1065+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/state_catalog.rs | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/state_repository.rs | 6++++++
Mtests/package_boundary.rs | 59++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Atests/services_hardening_presence_desired_state.rs | 325+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_state_catalog.rs | 57++++++++++++++++++++++++++++++++++++++++++++++++---------
Mtests/services_hardening_state_host.rs | 12++++++++----
Mtests/services_hardening_state_resilience.rs | 2+-
13 files changed, 1917 insertions(+), 31 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -211,6 +211,15 @@ acknowledgement never proves delivery or failure. - Keep profile and application-handler presence as deterministic durable desired state with the same commit-before-I/O and exact-byte retry discipline. +- Derive presence desired state only from one complete admitted configuration, + bind it to the latest durable configuration before mutation, and advance its + singleton generation only when the semantic presence authority changes. + Durable desired state contains only the closed mode, document-presence bits, + bounded target counts, queue capacity, and governed digests; it never stores + relay URLs, rendered or signed events, attempts, schedules, or outcomes. +- Keep rendered and independently verified signed presence bytes, per-document + target state, attempt evidence, retry scheduling, and relay I/O with the next + ordered checkpoint. Never reconstruct exact committed bytes during retry. ## 7. Configuration, identity, state, and process boundaries diff --git a/README b/README @@ -343,6 +343,25 @@ outcomes, retry, recovery, and wave qualification. The exact machine contract is [`publication_outbox.v1.json`](contracts/services_hardening/publication_outbox.v1.json). +## Deterministic durable presence intent + +`RhiPresenceDesiredAuthority::from_config` derives the exact ordered +service-profile and application-handler intent from one complete admitted +configuration. It binds the verified service public identity, the configured +relay-ID order and requiredness, and the bounded presence queue under separate +domain-separated target-set and semantic desired-state digests. An independent +validator re-derives that authority from the same complete configuration. + +Schema v9 stores only one sealed semantic desired-state snapshot. The first +commit creates generation one, exact semantic replay performs no write, and a +semantic change advances exactly one compare-and-swap generation. Every commit +must still match the latest durable configuration binding. The table stores no +relay URL, filesystem path, rendered event, signature, authored time, delivery +attempt, or network result, and its triggers reject deletion and ungoverned +updates. Rendering, signing, target delivery state, retries, and relay I/O remain +owned by Step 205. The exact machine contract is +[`presence_desired_state.v1.json`](contracts/services_hardening/presence_desired_state.v1.json). + ## Atomic reconciliation finalization commit `RhiReconciliationAttemptRepository::commit_finalization` borrows one sealed, diff --git a/contracts/api_baselines/rhi.txt b/contracts/api_baselines/rhi.txt @@ -133,6 +133,26 @@ pub enum rhi::RhiPresenceCommandV1 pub rhi::RhiPresenceCommandV1::Desired pub rhi::RhiPresenceCommandV1::Refresh pub rhi::RhiPresenceCommandV1::Render +pub enum rhi::RhiPresenceDesiredErrorKind +pub rhi::RhiPresenceDesiredErrorKind::Binding +pub rhi::RhiPresenceDesiredErrorKind::CommitOutcomeUnknown +pub rhi::RhiPresenceDesiredErrorKind::InvalidConfiguration +pub rhi::RhiPresenceDesiredErrorKind::InvalidMode +pub rhi::RhiPresenceDesiredErrorKind::ResourceExhausted +pub rhi::RhiPresenceDesiredErrorKind::Storage +pub rhi::RhiPresenceDesiredErrorKind::TargetInventory +impl rhi::RhiPresenceDesiredErrorKind +pub const fn rhi::RhiPresenceDesiredErrorKind::code(self) -> &'static str +pub enum rhi::RhiPresenceDesiredMode +pub rhi::RhiPresenceDesiredMode::Disabled +pub rhi::RhiPresenceDesiredMode::Enabled +impl rhi::RhiPresenceDesiredMode +pub const fn rhi::RhiPresenceDesiredMode::code(self) -> &'static str +pub enum rhi::RhiPresenceDocumentKind +pub rhi::RhiPresenceDocumentKind::ApplicationHandler +pub rhi::RhiPresenceDocumentKind::ServiceProfile +impl rhi::RhiPresenceDocumentKind +pub const fn rhi::RhiPresenceDocumentKind::code(self) -> &'static str pub enum rhi::RhiPublicationAttemptEvidenceErrorKind pub rhi::RhiPublicationAttemptEvidenceErrorKind::InvalidAttemptNumber pub rhi::RhiPublicationAttemptEvidenceErrorKind::InvalidTargetOrdinal @@ -620,6 +640,9 @@ impl core::fmt::Debug for rhi::RhiDecryptedIdentity pub fn rhi::RhiDecryptedIdentity::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiDesiredPresenceRepository<'host> impl rhi::RhiDesiredPresenceRepository<'_> +pub async fn rhi::RhiDesiredPresenceRepository<'_>::commit(&self, &rhi::RhiPresenceDesiredAuthority) -> core::result::Result<rhi::RhiPresenceDesiredCommitOutcome, rhi::RhiPresenceDesiredError> +pub async fn rhi::RhiDesiredPresenceRepository<'_>::current(&self) -> core::result::Result<core::option::Option<rhi::RhiPresenceDesiredState>, rhi::RhiPresenceDesiredError> +impl rhi::RhiDesiredPresenceRepository<'_> pub const fn rhi::RhiDesiredPresenceRepository<'_>::descriptor(&self) -> rhi::RhiStateRepositoryDescriptor pub const fn rhi::RhiDesiredPresenceRepository<'_>::kind(&self) -> rhi::RhiStateRepositoryKind impl core::fmt::Debug for rhi::RhiDesiredPresenceRepository<'_> @@ -714,6 +737,50 @@ pub const fn rhi::RhiPreparedPublicationAttempt::exact_signed_event_bytes(&self) pub fn rhi::RhiPreparedPublicationAttempt::relay_id(&self) -> &str impl core::fmt::Debug for rhi::RhiPreparedPublicationAttempt pub fn rhi::RhiPreparedPublicationAttempt::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPresenceDesiredAuthority +impl rhi::RhiPresenceDesiredAuthority +pub const fn rhi::RhiPresenceDesiredAuthority::desired_sha256(&self) -> &[u8; 32] +pub fn rhi::RhiPresenceDesiredAuthority::document_kinds(&self) -> &[rhi::RhiPresenceDocumentKind] +pub fn rhi::RhiPresenceDesiredAuthority::from_config(&rhi::RhiConfigDocumentV1) -> core::result::Result<Self, rhi::RhiPresenceDesiredError> +pub const fn rhi::RhiPresenceDesiredAuthority::mode(&self) -> rhi::RhiPresenceDesiredMode +pub const fn rhi::RhiPresenceDesiredAuthority::queue_capacity(&self) -> u32 +pub const fn rhi::RhiPresenceDesiredAuthority::target_set_sha256(&self) -> &[u8; 32] +pub fn rhi::RhiPresenceDesiredAuthority::targets(&self) -> &[rhi::RhiPresenceTarget] +impl core::fmt::Debug for rhi::RhiPresenceDesiredAuthority +pub fn rhi::RhiPresenceDesiredAuthority::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPresenceDesiredCommitOutcome +impl rhi::RhiPresenceDesiredCommitOutcome +pub const fn rhi::RhiPresenceDesiredCommitOutcome::changed(self) -> bool +pub const fn rhi::RhiPresenceDesiredCommitOutcome::state(self) -> rhi::RhiPresenceDesiredState +pub struct rhi::RhiPresenceDesiredError +impl rhi::RhiPresenceDesiredError +pub const fn rhi::RhiPresenceDesiredError::code(self) -> &'static str +pub const fn rhi::RhiPresenceDesiredError::kind(self) -> rhi::RhiPresenceDesiredErrorKind +impl core::error::Error for rhi::RhiPresenceDesiredError +impl core::fmt::Debug for rhi::RhiPresenceDesiredError +pub fn rhi::RhiPresenceDesiredError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for rhi::RhiPresenceDesiredError +pub fn rhi::RhiPresenceDesiredError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPresenceDesiredState +impl rhi::RhiPresenceDesiredState +pub const fn rhi::RhiPresenceDesiredState::application_handler(self) -> bool +pub const fn rhi::RhiPresenceDesiredState::desired_sha256(&self) -> &[u8; 32] +pub const fn rhi::RhiPresenceDesiredState::generation(self) -> u64 +pub const fn rhi::RhiPresenceDesiredState::mode(self) -> rhi::RhiPresenceDesiredMode +pub const fn rhi::RhiPresenceDesiredState::profile(self) -> bool +pub const fn rhi::RhiPresenceDesiredState::queue_capacity(self) -> u32 +pub const fn rhi::RhiPresenceDesiredState::required_target_count(self) -> u8 +pub const fn rhi::RhiPresenceDesiredState::target_count(self) -> u8 +pub const fn rhi::RhiPresenceDesiredState::target_set_sha256(&self) -> &[u8; 32] +impl core::fmt::Debug for rhi::RhiPresenceDesiredState +pub fn rhi::RhiPresenceDesiredState::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiPresenceTarget +impl rhi::RhiPresenceTarget +pub const fn rhi::RhiPresenceTarget::ordinal(&self) -> u8 +pub fn rhi::RhiPresenceTarget::relay_id(&self) -> &str +pub const fn rhi::RhiPresenceTarget::required(&self) -> bool +impl core::fmt::Debug for rhi::RhiPresenceTarget +pub fn rhi::RhiPresenceTarget::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiProjectionRepository<'host> impl rhi::RhiProjectionRepository<'_> pub const fn rhi::RhiProjectionRepository<'_>::descriptor(&self) -> rhi::RhiStateRepositoryDescriptor @@ -1570,6 +1637,8 @@ pub const rhi::RHI_ENCRYPTED_IDENTITY_BACKUP_INCLUDED: bool 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_PRESENCE_DESIRED_CONTRACT_VERSION: u32 +pub const rhi::RHI_PRESENCE_DESIRED_MAX_TARGETS: usize pub const rhi::RHI_PROVIDER_CONTRACT_VERSION: u32 pub const rhi::RHI_PUBLICATION_ATTEMPT_EVIDENCE_CONTRACT_VERSION: u32 pub const rhi::RHI_PUBLICATION_ATTEMPT_NUMBER_MAXIMUM: u16 @@ -1625,6 +1694,9 @@ pub const rhi::RHI_STATE_SCHEMA_VERSION_7_SHA256: [u8; 32] pub const rhi::RHI_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256: [u8; 32] pub const rhi::RHI_STATE_SCHEMA_VERSION_8_OBJECT_COUNT: u32 pub const rhi::RHI_STATE_SCHEMA_VERSION_8_SHA256: [u8; 32] +pub const rhi::RHI_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256: [u8; 32] +pub const rhi::RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT: u32 +pub const rhi::RHI_STATE_SCHEMA_VERSION_9_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 @@ -1672,6 +1744,7 @@ pub fn rhi::rhi_schema_catalog() -> core::result::Result<radroots_service_sqlite pub const fn rhi::rhi_state_repository_descriptors() -> &'static [rhi::RhiStateRepositoryDescriptor; 18] pub async fn rhi::stage_rhi_state_restore(&rhi::RhiRuntimeContext, &rhi::RhiStateMetadata, rhi::RhiVerifiedStateBackup) -> core::result::Result<rhi::RhiStagedStateRestore, rhi::RhiStateMaintenanceError> pub fn rhi::trade_mutation_subscription_kinds() -> alloc::vec::Vec<u32> +pub fn rhi::validate_rhi_presence_desired_authority(&rhi::RhiConfigDocumentV1, &rhi::RhiPresenceDesiredAuthority) -> core::result::Result<(), rhi::RhiPresenceDesiredError> pub fn rhi::validate_rhi_state_catalogs(&radroots_service_sqlite::migration::MigrationCatalog, &radroots_service_sqlite::integrity::catalog::SchemaCatalog) -> core::result::Result<(), rhi::RhiStateCatalogError> pub fn rhi::verify_rhi_state_backup(&[u8], radroots_service_sqlite::backup::manifest::BackupManifestSha256, &std::path::Path, &rhi::RhiStateMetadata, core::num::nonzero::NonZeroU64) -> core::result::Result<rhi::RhiVerifiedStateBackup, rhi::RhiStateMaintenanceError> pub type rhi::RhiReconciliationCoverage = radroots_trade::evidence::RadrootsTradeEvidenceCoverageV1 diff --git a/contracts/services_hardening/presence_desired_state.v1.json b/contracts/services_hardening/presence_desired_state.v1.json @@ -0,0 +1,94 @@ +{ + "schema": "radroots.rhi.presence-desired-state", + "contract_version": 1, + "authority": { + "source": "complete_admitted_rhi_config_v1", + "modes": ["disabled", "enabled"], + "document_order": ["service_profile", "application_handler"], + "target_order": "configured_presence_target_relay_id_order", + "target_requiredness": "configured_relay_required_value", + "maximum_targets": 32, + "queue_capacity": { + "disabled": 0, + "enabled_minimum": 1, + "enabled_maximum": 4096 + }, + "independent_validation": "exact_rederivation_from_same_complete_config" + }, + "target_set_digest": { + "hash": "sha256", + "domain_ascii_nul": "radroots.rhi.presence_target_set.v1\\0", + "framing": [ + "target_count_u32_be", + "for_each_target_in_order:ordinal_u32_be", + "relay_id_utf8_length_u64_be", + "relay_id_exact_utf8", + "required_u8" + ] + }, + "desired_state_digest": { + "hash": "sha256", + "domain_ascii_nul": "radroots.rhi.presence_desired_state.v1\\0", + "framing": [ + "mode_u8_disabled_0_enabled_1", + "document_count_u32_be", + "ordered_document_kind_u8_profile_0_application_handler_1", + "target_count_u32_be", + "for_each_target_same_framing_as_target_set", + "queue_capacity_u32_be", + "service_public_key_utf8_length_u64_be", + "service_public_key_exact_lowerhex_utf8" + ], + "excludes": [ + "configuration_fields_unrelated_to_presence", + "rendered_event", + "signature", + "authored_time", + "entropy", + "attempt", + "relay_url", + "network_outcome" + ] + }, + "durable_state": { + "table": "presence_desired_state", + "write_class": "compare_and_swap", + "rows": "exactly_zero_or_one", + "first_generation": 1, + "maximum_generation": 9223372036854775807, + "exact_replay": "no_mutation_same_generation", + "semantic_change": "single_cas_generation_increment", + "delete": "forbidden", + "current_config_binding": "required_before_commit", + "read_bounds": "at_most_two_rows_with_bounded_blob_and_text_projection" + }, + "commit_boundary": { + "before_rendering": true, + "before_signing": true, + "before_relay_io": true, + "commit_outcome_unknown": "typed_and_not_retried_as_uncommitted_without_reread" + }, + "step_205_deferrals": [ + "rendered_typed_events", + "exact_signed_bytes", + "per_document_target_state", + "delivery_attempts", + "retry_schedule", + "relay_io", + "observed_outcomes" + ], + "reference_vector": { + "config_fixture": "contracts/services_hardening/config.v1.example.toml", + "target_set_sha256": "959f04012841ae6e9bf3e109468b4f66cfa9d966aac1df36df09e45c1e1c48f9", + "desired_state_sha256": "7235f1e386e839427625dc364df7b51ee74d39d5f170e12b25cf2c42fd7731f0" + }, + "effects": { + "sqlite": "typed_repository_commit_and_read_only", + "clock": false, + "entropy": false, + "filesystem": false, + "task_spawn": false, + "network": false, + "relay_io": false + } +} 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 presence_desired; mod publication; mod publication_attempt; mod publication_execution; @@ -69,6 +70,12 @@ pub use identity_envelope::{ RhiIdentityRole, RhiWrappingCredential, open_rhi_encrypted_identity, provision_rhi_encrypted_identity, }; +pub use presence_desired::{ + RHI_PRESENCE_DESIRED_CONTRACT_VERSION, RHI_PRESENCE_DESIRED_MAX_TARGETS, + RhiPresenceDesiredAuthority, RhiPresenceDesiredCommitOutcome, RhiPresenceDesiredError, + RhiPresenceDesiredErrorKind, RhiPresenceDesiredMode, RhiPresenceDesiredState, + RhiPresenceDocumentKind, RhiPresenceTarget, validate_rhi_presence_desired_authority, +}; pub use publication::{ RHI_PUBLICATION_CONTRACT_VERSION, RHI_PUBLICATION_MAX_ATTEMPTS, RHI_PUBLICATION_MAX_TARGETS, RhiPublicationAuthority, RhiPublicationError, RhiPublicationErrorKind, RhiPublicationMode, @@ -188,8 +195,9 @@ pub use state_catalog::{ RHI_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_7_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_7_SHA256, RHI_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_8_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_8_SHA256, - RhiStateCatalogError, RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog, - validate_rhi_state_catalogs, + RHI_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT, + RHI_STATE_SCHEMA_VERSION_9_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/presence_desired.rs b/src/presence_desired.rs @@ -0,0 +1,1065 @@ +//! Deterministic durable desired state for RHI service presence. + +use core::fmt; +use std::error::Error; + +use radroots_service_sqlite::{ + ServiceSqliteTransaction, ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind, +}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use sqlx::Row; + +use crate::{ + RhiConfigDocumentV1, RhiDesiredPresenceRepository, RhiStateHostMode, + state_metadata::normalized_config_digest, +}; + +/// Exact version of the deterministic presence desired-state contract. +pub const RHI_PRESENCE_DESIRED_CONTRACT_VERSION: u32 = 1; + +/// Maximum number of configured relay targets in one desired state. +pub const RHI_PRESENCE_DESIRED_MAX_TARGETS: usize = 32; + +const TARGET_SET_DOMAIN: &[u8] = b"radroots.rhi.presence_target_set.v1\0"; +const DESIRED_STATE_DOMAIN: &[u8] = b"radroots.rhi.presence_desired_state.v1\0"; + +const READ_CURRENT_CONFIG_SQL: &str = r#"SELECT + CASE WHEN typeof(normalized_config_sha256) = 'blob' + AND length(normalized_config_sha256) = 32 + THEN normalized_config_sha256 ELSE NULL END AS normalized_config_sha256, + length(CAST(service_public_key AS BLOB)) AS service_public_key_bytes, + substr(service_public_key, 1, 65) AS service_public_key +FROM rhi_config_bindings +ORDER BY generation DESC +LIMIT 1"#; + +const READ_DESIRED_SQL: &str = r#"SELECT singleton, generation, + enabled, profile, application_handler, + CASE WHEN typeof(target_set_sha256) = 'blob' AND length(target_set_sha256) = 32 + THEN target_set_sha256 ELSE NULL END AS target_set_sha256, + target_count, required_target_count, queue_capacity, + CASE WHEN typeof(desired_sha256) = 'blob' AND length(desired_sha256) = 32 + THEN desired_sha256 ELSE NULL END AS desired_sha256 +FROM presence_desired_state +LIMIT 2"#; + +const INSERT_DESIRED_SQL: &str = r#"INSERT INTO presence_desired_state ( + singleton, generation, enabled, profile, application_handler, + target_set_sha256, target_count, required_target_count, + queue_capacity, desired_sha256 +) VALUES (1, 1, ?, ?, ?, ?, ?, ?, ?, ?)"#; + +const UPDATE_DESIRED_SQL: &str = r#"UPDATE presence_desired_state +SET generation = generation + 1, + enabled = ?, profile = ?, application_handler = ?, + target_set_sha256 = ?, target_count = ?, required_target_count = ?, + queue_capacity = ?, desired_sha256 = ? +WHERE singleton = 1 AND generation = ? AND desired_sha256 = ?"#; + +/// Closed configured presence posture. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RhiPresenceDesiredMode { + Disabled, + Enabled, +} + +impl RhiPresenceDesiredMode { + /// Returns the exact machine-contract spelling. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::Enabled => "enabled", + } + } +} + +/// Closed ordered inventory of presence documents selected by configuration. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RhiPresenceDocumentKind { + ServiceProfile, + ApplicationHandler, +} + +impl RhiPresenceDocumentKind { + /// Returns the exact machine-contract spelling. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::ServiceProfile => "service_profile", + Self::ApplicationHandler => "application_handler", + } + } +} + +/// One immutable presence relay target derived from the admitted configuration. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct RhiPresenceTarget { + ordinal: u8, + relay_id: Box<str>, + required: bool, +} + +impl RhiPresenceTarget { + /// Returns the stable zero-based target position. + #[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 admitted relay authority. + #[must_use] + pub const fn required(&self) -> bool { + self.required + } +} + +impl fmt::Debug for RhiPresenceTarget { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiPresenceTarget") + .field("ordinal", &self.ordinal) + .field("relay_id", &"[redacted]") + .field("required", &self.required) + .finish() + } +} + +/// Sealed deterministic presence authority derived from one admitted config. +/// +/// This value contains desired document kinds and stable relay authority only. +/// It contains no rendered event, signature, delivery attempt, time, entropy, +/// connection, or retry state. +/// +/// ```compile_fail +/// use rhi::RhiPresenceDesiredAuthority; +/// +/// let _forged = RhiPresenceDesiredAuthority { mode: todo!() }; +/// ``` +#[derive(Clone, PartialEq, Eq)] +pub struct RhiPresenceDesiredAuthority { + configuration_sha256: [u8; 32], + service_public_key: Box<str>, + mode: RhiPresenceDesiredMode, + document_kinds: Box<[RhiPresenceDocumentKind]>, + targets: Box<[RhiPresenceTarget]>, + queue_capacity: u32, + target_set_sha256: [u8; 32], + desired_sha256: [u8; 32], +} + +impl RhiPresenceDesiredAuthority { + /// Derives the only presence desired-state authority from one admitted config. + pub fn from_config(config: &RhiConfigDocumentV1) -> Result<Self, RhiPresenceDesiredError> { + derive_authority(config.normalized(), config.profile()) + } + + /// Returns the explicit configured posture. + #[must_use] + pub const fn mode(&self) -> RhiPresenceDesiredMode { + self.mode + } + + /// Returns the exact ordered desired-document inventory. + #[must_use] + pub fn document_kinds(&self) -> &[RhiPresenceDocumentKind] { + &self.document_kinds + } + + /// Returns the exact ordered presence target inventory. + #[must_use] + pub fn targets(&self) -> &[RhiPresenceTarget] { + &self.targets + } + + /// Returns the configured presence work-queue capacity, or zero when disabled. + #[must_use] + pub const fn queue_capacity(&self) -> u32 { + self.queue_capacity + } + + /// Returns the domain-separated exact target-set identity. + #[must_use] + pub const fn target_set_sha256(&self) -> &[u8; 32] { + &self.target_set_sha256 + } + + /// Returns the domain-separated semantic desired-state identity. + #[must_use] + pub const fn desired_sha256(&self) -> &[u8; 32] { + &self.desired_sha256 + } +} + +impl fmt::Debug for RhiPresenceDesiredAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiPresenceDesiredAuthority") + .field("mode", &self.mode) + .field("document_count", &self.document_kinds.len()) + .field("target_count", &self.targets.len()) + .field("queue_capacity", &self.queue_capacity) + .finish_non_exhaustive() + } +} + +/// Independently re-derives and validates one desired-state authority. +pub fn validate_rhi_presence_desired_authority( + config: &RhiConfigDocumentV1, + authority: &RhiPresenceDesiredAuthority, +) -> Result<(), RhiPresenceDesiredError> { + let expected = RhiPresenceDesiredAuthority::from_config(config)?; + (expected == *authority) + .then_some(()) + .ok_or_else(|| failure(RhiPresenceDesiredErrorKind::Binding)) +} + +/// One validated durable desired-state snapshot. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiPresenceDesiredState { + generation: u64, + mode: RhiPresenceDesiredMode, + profile: bool, + application_handler: bool, + target_set_sha256: [u8; 32], + target_count: u8, + required_target_count: u8, + queue_capacity: u32, + desired_sha256: [u8; 32], +} + +impl RhiPresenceDesiredState { + /// Returns the monotonically committed desired-state generation. + #[must_use] + pub const fn generation(self) -> u64 { + self.generation + } + + /// Returns the configured desired-state posture. + #[must_use] + pub const fn mode(self) -> RhiPresenceDesiredMode { + self.mode + } + + /// Returns whether the service-profile document is desired. + #[must_use] + pub const fn profile(self) -> bool { + self.profile + } + + /// Returns whether the application-handler document is desired. + #[must_use] + pub const fn application_handler(self) -> bool { + self.application_handler + } + + /// Returns the target-set identity without exposing relay endpoints. + #[must_use] + pub const fn target_set_sha256(&self) -> &[u8; 32] { + &self.target_set_sha256 + } + + /// Returns the total configured target count. + #[must_use] + pub const fn target_count(self) -> u8 { + self.target_count + } + + /// Returns the number of configured required targets. + #[must_use] + pub const fn required_target_count(self) -> u8 { + self.required_target_count + } + + /// Returns the configured presence queue bound, or zero when disabled. + #[must_use] + pub const fn queue_capacity(self) -> u32 { + self.queue_capacity + } + + /// Returns the semantic desired-state identity. + #[must_use] + pub const fn desired_sha256(&self) -> &[u8; 32] { + &self.desired_sha256 + } +} + +impl fmt::Debug for RhiPresenceDesiredState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiPresenceDesiredState") + .field("generation", &self.generation) + .field("mode", &self.mode) + .field("profile", &self.profile) + .field("application_handler", &self.application_handler) + .field("target_count", &self.target_count) + .field("required_target_count", &self.required_target_count) + .field("queue_capacity", &self.queue_capacity) + .field("digests", &"[redacted]") + .finish() + } +} + +/// Result of one durable desired-state compare-and-swap operation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RhiPresenceDesiredCommitOutcome { + state: RhiPresenceDesiredState, + changed: bool, +} + +impl RhiPresenceDesiredCommitOutcome { + /// Returns the exact committed state. + #[must_use] + pub const fn state(self) -> RhiPresenceDesiredState { + self.state + } + + /// Returns whether this operation created a new durable generation. + #[must_use] + pub const fn changed(self) -> bool { + self.changed + } +} + +/// Stable source-free desired-state failure classes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiPresenceDesiredErrorKind { + InvalidConfiguration, + TargetInventory, + InvalidMode, + Binding, + ResourceExhausted, + Storage, + CommitOutcomeUnknown, +} + +impl RhiPresenceDesiredErrorKind { + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidConfiguration => "presence_desired_configuration_invalid", + Self::TargetInventory => "presence_desired_target_inventory_invalid", + Self::InvalidMode => "presence_desired_mode_invalid", + Self::Binding => "presence_desired_binding_invalid", + Self::ResourceExhausted => "resource_exhausted", + Self::Storage => "presence_desired_storage_failed", + Self::CommitOutcomeUnknown => "presence_desired_commit_outcome_unknown", + } + } +} + +/// Redacted source-free desired-state failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiPresenceDesiredError { + kind: RhiPresenceDesiredErrorKind, +} + +impl RhiPresenceDesiredError { + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> RhiPresenceDesiredErrorKind { + 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 RhiPresenceDesiredError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + RhiPresenceDesiredErrorKind::InvalidConfiguration => { + "RHI presence desired-state configuration is invalid" + } + RhiPresenceDesiredErrorKind::TargetInventory => { + "RHI presence desired-state target inventory is invalid" + } + RhiPresenceDesiredErrorKind::InvalidMode => { + "RHI presence desired-state operation mode is invalid" + } + RhiPresenceDesiredErrorKind::Binding => "RHI presence desired-state binding is invalid", + RhiPresenceDesiredErrorKind::ResourceExhausted => { + "RHI presence desired-state capacity is exhausted" + } + RhiPresenceDesiredErrorKind::Storage => "RHI presence desired-state storage failed", + RhiPresenceDesiredErrorKind::CommitOutcomeUnknown => { + "RHI presence desired-state commit outcome is unknown" + } + }) + } +} + +impl fmt::Debug for RhiPresenceDesiredError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiPresenceDesiredError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for RhiPresenceDesiredError {} + +impl RhiDesiredPresenceRepository<'_> { + /// Commits one exact desired state before any presence rendering or relay I/O. + pub async fn commit( + &self, + authority: &RhiPresenceDesiredAuthority, + ) -> Result<RhiPresenceDesiredCommitOutcome, RhiPresenceDesiredError> { + require_writable(self)?; + let authority = authority.clone(); + self.host() + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { commit_desired(transaction, &authority).await }) + }) + .await + .map_err(map_transaction_error) + } + + /// Reads the current validated desired-state snapshot without mutation. + pub async fn current( + &self, + ) -> Result<Option<RhiPresenceDesiredState>, RhiPresenceDesiredError> { + self.host() + .sqlite_host() + .transaction(move |transaction| { + Box::pin(async move { read_desired(transaction).await }) + }) + .await + .map_err(map_transaction_error) + } +} + +fn derive_authority( + document: &Value, + profile: crate::RhiConfigProfile, +) -> Result<RhiPresenceDesiredAuthority, RhiPresenceDesiredError> { + let configuration_sha256 = *normalized_config_digest(profile, document) + .map_err(|_| failure(RhiPresenceDesiredErrorKind::InvalidConfiguration))? + .as_bytes(); + let service_public_key = document + .pointer("/identity/service/expected_public_key") + .and_then(Value::as_str) + .filter(|value| valid_public_key(value)) + .ok_or_else(|| failure(RhiPresenceDesiredErrorKind::InvalidConfiguration))?; + let enabled = boolean(document, "/presence/enabled")?; + let profile_document = boolean(document, "/presence/profile")?; + let application_handler = boolean(document, "/presence/application_handler")?; + let configured_queue = + integer(document, "/resource_limits/queues/presence").and_then(|value| { + u32::try_from(value) + .map_err(|_| failure(RhiPresenceDesiredErrorKind::InvalidConfiguration)) + })?; + if configured_queue == 0 || configured_queue > 4_096 { + return Err(failure(RhiPresenceDesiredErrorKind::InvalidConfiguration)); + } + + let mode = if enabled { + RhiPresenceDesiredMode::Enabled + } else { + RhiPresenceDesiredMode::Disabled + }; + let mut document_kinds = Vec::with_capacity(2); + let (targets, queue_capacity) = match mode { + RhiPresenceDesiredMode::Disabled => { + if profile_document + || application_handler + || document.pointer("/presence/target_relay_ids").is_some() + { + return Err(failure(RhiPresenceDesiredErrorKind::InvalidConfiguration)); + } + (Vec::new(), 0) + } + RhiPresenceDesiredMode::Enabled => { + if profile_document { + document_kinds.push(RhiPresenceDocumentKind::ServiceProfile); + } + if application_handler { + document_kinds.push(RhiPresenceDocumentKind::ApplicationHandler); + } + if document_kinds.is_empty() { + return Err(failure(RhiPresenceDesiredErrorKind::InvalidConfiguration)); + } + ( + derive_targets(document, "/presence/target_relay_ids")?, + configured_queue, + ) + } + }; + let target_set_sha256 = target_set_digest(&targets)?; + let desired_sha256 = desired_state_digest( + mode, + &document_kinds, + &targets, + queue_capacity, + service_public_key, + )?; + Ok(RhiPresenceDesiredAuthority { + configuration_sha256, + service_public_key: service_public_key.into(), + mode, + document_kinds: document_kinds.into_boxed_slice(), + targets: targets.into_boxed_slice(), + queue_capacity, + target_set_sha256, + desired_sha256, + }) +} + +fn derive_targets( + document: &Value, + pointer: &str, +) -> Result<Vec<RhiPresenceTarget>, RhiPresenceDesiredError> { + let target_ids = document + .pointer(pointer) + .and_then(Value::as_array) + .ok_or_else(|| failure(RhiPresenceDesiredErrorKind::TargetInventory))?; + if target_ids.is_empty() || target_ids.len() > RHI_PRESENCE_DESIRED_MAX_TARGETS { + return Err(failure(RhiPresenceDesiredErrorKind::TargetInventory)); + } + let relays = document + .pointer("/relays") + .and_then(Value::as_array) + .ok_or_else(|| failure(RhiPresenceDesiredErrorKind::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(RhiPresenceDesiredErrorKind::TargetInventory))?; + if targets + .iter() + .any(|target: &RhiPresenceTarget| target.relay_id() == relay_id) + { + return Err(failure(RhiPresenceDesiredErrorKind::TargetInventory)); + } + let relay = relays + .iter() + .find(|relay| relay.pointer("/id").and_then(Value::as_str) == Some(relay_id)) + .ok_or_else(|| failure(RhiPresenceDesiredErrorKind::TargetInventory))?; + if relay.pointer("/write").and_then(Value::as_bool) != Some(true) { + return Err(failure(RhiPresenceDesiredErrorKind::TargetInventory)); + } + targets.push(RhiPresenceTarget { + ordinal: u8::try_from(ordinal) + .map_err(|_| failure(RhiPresenceDesiredErrorKind::TargetInventory))?, + relay_id: relay_id.into(), + required: relay + .pointer("/required") + .and_then(Value::as_bool) + .ok_or_else(|| failure(RhiPresenceDesiredErrorKind::TargetInventory))?, + }); + } + Ok(targets) +} + +fn target_set_digest(targets: &[RhiPresenceTarget]) -> Result<[u8; 32], RhiPresenceDesiredError> { + let mut digest = Sha256::new(); + digest.update(TARGET_SET_DOMAIN); + digest.update( + u32::try_from(targets.len()) + .map_err(|_| failure(RhiPresenceDesiredErrorKind::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(RhiPresenceDesiredErrorKind::TargetInventory))? + .to_be_bytes(), + ); + digest.update(target.relay_id.as_bytes()); + digest.update([u8::from(target.required)]); + } + Ok(digest.finalize().into()) +} + +fn desired_state_digest( + mode: RhiPresenceDesiredMode, + document_kinds: &[RhiPresenceDocumentKind], + targets: &[RhiPresenceTarget], + queue_capacity: u32, + service_public_key: &str, +) -> Result<[u8; 32], RhiPresenceDesiredError> { + let mut digest = Sha256::new(); + digest.update(DESIRED_STATE_DOMAIN); + digest.update([match mode { + RhiPresenceDesiredMode::Disabled => 0, + RhiPresenceDesiredMode::Enabled => 1, + }]); + digest.update( + u32::try_from(document_kinds.len()) + .map_err(|_| failure(RhiPresenceDesiredErrorKind::InvalidConfiguration))? + .to_be_bytes(), + ); + for kind in document_kinds { + digest.update([match kind { + RhiPresenceDocumentKind::ServiceProfile => 0, + RhiPresenceDocumentKind::ApplicationHandler => 1, + }]); + } + digest.update( + u32::try_from(targets.len()) + .map_err(|_| failure(RhiPresenceDesiredErrorKind::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(RhiPresenceDesiredErrorKind::TargetInventory))? + .to_be_bytes(), + ); + digest.update(target.relay_id.as_bytes()); + digest.update([u8::from(target.required)]); + } + digest.update(queue_capacity.to_be_bytes()); + digest.update( + u64::try_from(service_public_key.len()) + .map_err(|_| failure(RhiPresenceDesiredErrorKind::InvalidConfiguration))? + .to_be_bytes(), + ); + digest.update(service_public_key.as_bytes()); + Ok(digest.finalize().into()) +} + +fn require_writable( + repository: &RhiDesiredPresenceRepository<'_>, +) -> Result<(), RhiPresenceDesiredError> { + if repository.host().mode() == RhiStateHostMode::ReadWriteExisting { + Ok(()) + } else { + Err(failure(RhiPresenceDesiredErrorKind::InvalidMode)) + } +} + +async fn commit_desired( + transaction: &mut ServiceSqliteTransaction<'_>, + authority: &RhiPresenceDesiredAuthority, +) -> Result<RhiPresenceDesiredCommitOutcome, OperationError> { + require_current_config(transaction, authority).await?; + let current = read_desired(transaction).await?; + if let Some(current) = current { + if matches_authority(current, authority) { + return Ok(RhiPresenceDesiredCommitOutcome { + state: current, + changed: false, + }); + } + if current.generation == i64::MAX as u64 { + return Err(OperationError::ResourceExhausted); + } + let result = sqlx::query(UPDATE_DESIRED_SQL) + .bind(bool_i64(authority.mode == RhiPresenceDesiredMode::Enabled)) + .bind(bool_i64(has_document( + authority, + RhiPresenceDocumentKind::ServiceProfile, + ))) + .bind(bool_i64(has_document( + authority, + RhiPresenceDocumentKind::ApplicationHandler, + ))) + .bind(authority.target_set_sha256.as_slice()) + .bind(i64_count(authority.targets.len())?) + .bind(i64_count( + authority + .targets + .iter() + .filter(|target| target.required) + .count(), + )?) + .bind(i64::from(authority.queue_capacity)) + .bind(authority.desired_sha256.as_slice()) + .bind(i64_value(current.generation)?) + .bind(current.desired_sha256.as_slice()) + .execute(&mut *transaction) + .await + .map_err(|_| OperationError::Storage)?; + if result.rows_affected() != 1 { + return Err(OperationError::Binding); + } + } else { + let result = sqlx::query(INSERT_DESIRED_SQL) + .bind(bool_i64(authority.mode == RhiPresenceDesiredMode::Enabled)) + .bind(bool_i64(has_document( + authority, + RhiPresenceDocumentKind::ServiceProfile, + ))) + .bind(bool_i64(has_document( + authority, + RhiPresenceDocumentKind::ApplicationHandler, + ))) + .bind(authority.target_set_sha256.as_slice()) + .bind(i64_count(authority.targets.len())?) + .bind(i64_count( + authority + .targets + .iter() + .filter(|target| target.required) + .count(), + )?) + .bind(i64::from(authority.queue_capacity)) + .bind(authority.desired_sha256.as_slice()) + .execute(&mut *transaction) + .await + .map_err(|_| OperationError::Storage)?; + if result.rows_affected() != 1 { + return Err(OperationError::Binding); + } + } + let committed = read_desired(transaction) + .await? + .filter(|state| matches_authority(*state, authority)) + .ok_or(OperationError::Binding)?; + Ok(RhiPresenceDesiredCommitOutcome { + state: committed, + changed: true, + }) +} + +async fn require_current_config( + transaction: &mut ServiceSqliteTransaction<'_>, + authority: &RhiPresenceDesiredAuthority, +) -> Result<(), OperationError> { + let rows = sqlx::query(READ_CURRENT_CONFIG_SQL) + .fetch_all(&mut *transaction) + .await + .map_err(|_| OperationError::Storage)?; + if rows.len() != 1 { + return Err(OperationError::Binding); + } + let row = &rows[0]; + let configuration_sha256 = digest(row, "normalized_config_sha256")?; + let key_bytes = row + .try_get::<i64, _>("service_public_key_bytes") + .ok() + .and_then(|value| usize::try_from(value).ok()) + .filter(|value| *value == 64) + .ok_or(OperationError::Binding)?; + let service_public_key = row + .try_get::<String, _>("service_public_key") + .map_err(|_| OperationError::Binding)?; + if service_public_key.len() != key_bytes + || !valid_public_key(&service_public_key) + || configuration_sha256 != authority.configuration_sha256 + || service_public_key != authority.service_public_key.as_ref() + { + return Err(OperationError::Binding); + } + Ok(()) +} + +async fn read_desired( + transaction: &mut ServiceSqliteTransaction<'_>, +) -> Result<Option<RhiPresenceDesiredState>, OperationError> { + let rows = sqlx::query(READ_DESIRED_SQL) + .fetch_all(&mut *transaction) + .await + .map_err(|_| OperationError::Storage)?; + match rows.as_slice() { + [] => Ok(None), + [row] => decode_desired(row).map(Some), + _ => Err(OperationError::Binding), + } +} + +fn decode_desired( + row: &sqlx::sqlite::SqliteRow, +) -> Result<RhiPresenceDesiredState, OperationError> { + if row.try_get::<i64, _>("singleton").ok() != Some(1) { + return Err(OperationError::Binding); + } + let generation = positive_u64(row, "generation")?; + let enabled = boolean_i64(row, "enabled")?; + let profile = boolean_i64(row, "profile")?; + let application_handler = boolean_i64(row, "application_handler")?; + let target_set_sha256 = digest(row, "target_set_sha256")?; + let target_count = count_u8(row, "target_count", RHI_PRESENCE_DESIRED_MAX_TARGETS)?; + let required_target_count = count_u8(row, "required_target_count", usize::from(target_count))?; + let queue_capacity = row + .try_get::<i64, _>("queue_capacity") + .ok() + .and_then(|value| u32::try_from(value).ok()) + .filter(|value| *value <= 4_096) + .ok_or(OperationError::Binding)?; + let desired_sha256 = digest(row, "desired_sha256")?; + let valid = if enabled { + (profile || application_handler) && target_count > 0 && queue_capacity > 0 + } else { + !profile + && !application_handler + && target_count == 0 + && required_target_count == 0 + && queue_capacity == 0 + }; + if !valid { + return Err(OperationError::Binding); + } + Ok(RhiPresenceDesiredState { + generation, + mode: if enabled { + RhiPresenceDesiredMode::Enabled + } else { + RhiPresenceDesiredMode::Disabled + }, + profile, + application_handler, + target_set_sha256, + target_count, + required_target_count, + queue_capacity, + desired_sha256, + }) +} + +fn matches_authority( + state: RhiPresenceDesiredState, + authority: &RhiPresenceDesiredAuthority, +) -> bool { + state.mode == authority.mode + && state.profile == has_document(authority, RhiPresenceDocumentKind::ServiceProfile) + && state.application_handler + == has_document(authority, RhiPresenceDocumentKind::ApplicationHandler) + && state.target_set_sha256 == authority.target_set_sha256 + && usize::from(state.target_count) == authority.targets.len() + && usize::from(state.required_target_count) + == authority + .targets + .iter() + .filter(|target| target.required) + .count() + && state.queue_capacity == authority.queue_capacity + && state.desired_sha256 == authority.desired_sha256 +} + +fn has_document(authority: &RhiPresenceDesiredAuthority, kind: RhiPresenceDocumentKind) -> bool { + authority.document_kinds.contains(&kind) +} + +fn digest(row: &sqlx::sqlite::SqliteRow, field: &str) -> Result<[u8; 32], OperationError> { + row.try_get::<Vec<u8>, _>(field) + .map_err(|_| OperationError::Binding)? + .try_into() + .map_err(|_| OperationError::Binding) +} + +fn positive_u64(row: &sqlx::sqlite::SqliteRow, field: &str) -> Result<u64, OperationError> { + row.try_get::<i64, _>(field) + .ok() + .and_then(|value| u64::try_from(value).ok()) + .filter(|value| *value != 0) + .ok_or(OperationError::Binding) +} + +fn boolean_i64(row: &sqlx::sqlite::SqliteRow, field: &str) -> Result<bool, OperationError> { + match row.try_get::<i64, _>(field) { + Ok(0) => Ok(false), + Ok(1) => Ok(true), + Ok(_) | Err(_) => Err(OperationError::Binding), + } +} + +fn count_u8( + row: &sqlx::sqlite::SqliteRow, + field: &str, + maximum: usize, +) -> Result<u8, OperationError> { + row.try_get::<i64, _>(field) + .ok() + .and_then(|value| u8::try_from(value).ok()) + .filter(|value| usize::from(*value) <= maximum) + .ok_or(OperationError::Binding) +} + +fn bool_i64(value: bool) -> i64 { + i64::from(value) +} + +fn i64_count(value: usize) -> Result<i64, OperationError> { + i64::try_from(value).map_err(|_| OperationError::InvalidInput) +} + +fn i64_value(value: u64) -> Result<i64, OperationError> { + i64::try_from(value).map_err(|_| OperationError::ResourceExhausted) +} + +fn boolean(document: &Value, pointer: &str) -> Result<bool, RhiPresenceDesiredError> { + document + .pointer(pointer) + .and_then(Value::as_bool) + .ok_or_else(|| failure(RhiPresenceDesiredErrorKind::InvalidConfiguration)) +} + +fn integer(document: &Value, pointer: &str) -> Result<u64, RhiPresenceDesiredError> { + document + .pointer(pointer) + .and_then(Value::as_u64) + .ok_or_else(|| failure(RhiPresenceDesiredErrorKind::InvalidConfiguration)) +} + +fn valid_public_key(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + && nostr::PublicKey::from_hex(value).is_ok_and(|key| key.xonly().is_ok()) +} + +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'-') + }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum OperationError { + InvalidInput, + Binding, + ResourceExhausted, + Storage, +} + +fn map_transaction_error( + error: ServiceSqliteTransactionError<OperationError>, +) -> RhiPresenceDesiredError { + if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown { + return failure(RhiPresenceDesiredErrorKind::CommitOutcomeUnknown); + } + failure(match error.operation_error().copied() { + Some(OperationError::InvalidInput) => RhiPresenceDesiredErrorKind::InvalidConfiguration, + Some(OperationError::Binding) => RhiPresenceDesiredErrorKind::Binding, + Some(OperationError::ResourceExhausted) => RhiPresenceDesiredErrorKind::ResourceExhausted, + Some(OperationError::Storage) | None => RhiPresenceDesiredErrorKind::Storage, + }) +} + +const fn failure(kind: RhiPresenceDesiredErrorKind) -> RhiPresenceDesiredError { + RhiPresenceDesiredError { kind } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RhiConfigProfile, parse_rhi_config_v1}; + + const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); + + fn config(source: &str) -> RhiConfigDocumentV1 { + parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::RepoLocal).expect("config") + } + + #[test] + fn authority_is_deterministic_ordered_and_sealed() { + let config = config(EXAMPLE); + let first = RhiPresenceDesiredAuthority::from_config(&config).expect("authority"); + let second = RhiPresenceDesiredAuthority::from_config(&config).expect("authority"); + assert_eq!(first, second); + validate_rhi_presence_desired_authority(&config, &first).expect("independent validation"); + assert_eq!(first.mode(), RhiPresenceDesiredMode::Enabled); + assert_eq!( + first.document_kinds(), + &[ + RhiPresenceDocumentKind::ServiceProfile, + RhiPresenceDocumentKind::ApplicationHandler, + ] + ); + 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()); + assert_eq!(first.queue_capacity(), 64); + assert_eq!( + first.target_set_sha256(), + &[ + 0x95, 0x9f, 0x04, 0x01, 0x28, 0x41, 0xae, 0x6e, 0x9b, 0xf3, 0xe1, 0x09, 0x46, 0x8b, + 0x4f, 0x66, 0xcf, 0xa9, 0xd9, 0x66, 0xaa, 0xc1, 0xdf, 0x36, 0xdf, 0x09, 0xe4, 0x5c, + 0x1e, 0x1c, 0x48, 0xf9, + ] + ); + assert_eq!( + first.desired_sha256(), + &[ + 0x72, 0x35, 0xf1, 0xe3, 0x86, 0xe8, 0x39, 0x42, 0x76, 0x25, 0xdc, 0x36, 0x4d, 0xf7, + 0xb5, 0x1e, 0xe7, 0x4d, 0x39, 0xd5, 0xf1, 0x70, 0xe1, 0x2b, 0x25, 0xcf, 0x2c, 0x42, + 0xfd, 0x77, 0x31, 0xf0, + ] + ); + let rendered = format!("{first:?} {:?}", first.targets()[0]); + assert!(!rendered.contains("relay-primary")); + assert!(!rendered.contains(&"2".repeat(64))); + } + + #[test] + fn semantic_changes_change_only_the_deterministic_authority() { + let baseline = + RhiPresenceDesiredAuthority::from_config(&config(EXAMPLE)).expect("baseline"); + for changed in [ + EXAMPLE.replace("profile = true", "profile = false"), + EXAMPLE.replace( + "target_relay_ids = [\"relay-primary\", \"relay-secondary\"]", + "target_relay_ids = [\"relay-secondary\", \"relay-primary\"]", + ), + EXAMPLE.replacen("required = true", "required = false", 1), + EXAMPLE.replace("presence = 64", "presence = 63"), + EXAMPLE.replace(&"2".repeat(64), &"3".repeat(64)), + ] { + let changed = RhiPresenceDesiredAuthority::from_config(&config(&changed)) + .expect("changed authority"); + assert_ne!(changed.desired_sha256(), baseline.desired_sha256()); + } + } + + #[test] + fn disabled_authority_contains_no_document_target_or_queue_state() { + let disabled = EXAMPLE.replace( + "[presence]\nenabled = true\nprofile = true\napplication_handler = true\ntarget_relay_ids = [\"relay-primary\", \"relay-secondary\"]", + "[presence]\nenabled = false\nprofile = false\napplication_handler = false", + ); + let authority = RhiPresenceDesiredAuthority::from_config(&config(&disabled)) + .expect("disabled authority"); + assert_eq!(authority.mode(), RhiPresenceDesiredMode::Disabled); + assert!(authority.document_kinds().is_empty()); + assert!(authority.targets().is_empty()); + assert_eq!(authority.queue_capacity(), 0); + } + + #[test] + fn diagnostics_are_closed_source_free_and_redacted() { + for kind in [ + RhiPresenceDesiredErrorKind::InvalidConfiguration, + RhiPresenceDesiredErrorKind::TargetInventory, + RhiPresenceDesiredErrorKind::InvalidMode, + RhiPresenceDesiredErrorKind::Binding, + RhiPresenceDesiredErrorKind::ResourceExhausted, + RhiPresenceDesiredErrorKind::Storage, + RhiPresenceDesiredErrorKind::CommitOutcomeUnknown, + ] { + let error = failure(kind); + assert_eq!(error.kind(), kind); + assert!(!error.code().is_empty()); + assert!(Error::source(&error).is_none()); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains("wss://")); + assert!(!rendered.contains("relay-primary")); + assert!(!rendered.contains(&"2".repeat(64))); + } + } +} 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 = 8; +pub const RHI_STATE_SCHEMA_VERSION: u32 = 9; /// The shared metadata and migration-ledger objects present at schema v1. pub const RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6; @@ -38,10 +38,13 @@ pub const RHI_STATE_SCHEMA_VERSION_7_OBJECT_COUNT: u32 = 63; /// The shared objects plus fail-closed reconciliation-job shape guards. pub const RHI_STATE_SCHEMA_VERSION_8_OBJECT_COUNT: u32 = 65; +/// The shared objects plus deterministic durable presence desired state. +pub const RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT: u32 = 69; + /// SHA-256 identity of the ordered migration catalog rooted at schema v1. pub const RHI_MIGRATION_CATALOG_SHA256: [u8; 32] = [ - 0xe6, 0x5a, 0xd1, 0x15, 0x5c, 0x9d, 0xa2, 0x70, 0x32, 0x81, 0x99, 0x22, 0x68, 0x53, 0x0c, 0x87, - 0xe5, 0x24, 0x8a, 0x52, 0xf7, 0x66, 0x01, 0x1c, 0x23, 0x5e, 0x1f, 0xdb, 0x67, 0x1c, 0x77, 0x4f, + 0x7f, 0x8c, 0x03, 0xb4, 0x81, 0x84, 0x08, 0xb5, 0x86, 0x74, 0x74, 0x12, 0xc2, 0x65, 0xac, 0x72, + 0xcd, 0x4d, 0xd8, 0x8a, 0x29, 0x0d, 0x8b, 0xbe, 0xe1, 0xd5, 0xf6, 0x8a, 0xdb, 0xf7, 0xaf, 0xd0, ]; /// SHA-256 identity of the exact schema-v1 object snapshot. @@ -134,10 +137,22 @@ pub const RHI_STATE_SCHEMA_VERSION_8_SHA256: [u8; 32] = [ 0xcc, 0xac, 0x90, 0x53, 0x8e, 0x0c, 0xd4, 0x9a, 0x4e, 0xe2, 0x14, 0xcb, 0x0a, 0x39, 0xc6, 0x18, ]; +/// SHA-256 identity of the schema-v9 presence desired-state migration. +pub const RHI_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256: [u8; 32] = [ + 0x32, 0xda, 0xbe, 0x77, 0x72, 0x89, 0xe0, 0xfb, 0x6e, 0x64, 0xa4, 0xc1, 0xf8, 0x25, 0x78, 0x43, + 0xfc, 0x08, 0x01, 0x83, 0x21, 0xc3, 0xb7, 0xec, 0x5c, 0xa5, 0x84, 0xfa, 0x18, 0x62, 0xbd, 0xcc, +]; + +/// SHA-256 identity of the exact schema-v9 object snapshot. +pub const RHI_STATE_SCHEMA_VERSION_9_SHA256: [u8; 32] = [ + 0x55, 0x51, 0xe8, 0x79, 0x05, 0x44, 0xa7, 0xc7, 0x8c, 0x83, 0x76, 0xc5, 0xcc, 0xf8, 0x3d, 0xd2, + 0xa8, 0x48, 0x6d, 0x2d, 0xc0, 0x8b, 0x6a, 0x78, 0x08, 0xbb, 0x20, 0x07, 0xc9, 0x43, 0x35, 0xec, +]; + /// SHA-256 identity of the schema catalog bound to the migration catalog. pub const RHI_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [ - 0x93, 0x30, 0x35, 0x1d, 0x30, 0x0f, 0x70, 0x0f, 0x31, 0x7e, 0xd2, 0xc7, 0x2c, 0xbb, 0xb0, 0x85, - 0x22, 0xa8, 0x27, 0xb9, 0x62, 0xfe, 0x6c, 0xcb, 0x34, 0x3e, 0x3e, 0xe7, 0x1f, 0xdf, 0xd0, 0x7c, + 0x5b, 0xea, 0x3e, 0x3e, 0xc6, 0xbe, 0x3f, 0x12, 0x49, 0xad, 0x19, 0xed, 0x7b, 0x2d, 0x2e, 0x87, + 0x2c, 0x34, 0x02, 0x55, 0x79, 0xa5, 0x99, 0xf2, 0x54, 0xde, 0x8e, 0x80, 0xcb, 0xa9, 0xb3, 0xc7, ]; macro_rules! rhi_config_bindings_table_sql { @@ -750,6 +765,87 @@ const CREATE_RECONCILIATION_JOB_SHAPE_GUARDS_MIGRATION_SQL: &str = concat!( ";", ); +macro_rules! presence_desired_state_table_sql { + () => { + r#"CREATE TABLE presence_desired_state ( + singleton INTEGER NOT NULL PRIMARY KEY CHECK (singleton = 1), + generation INTEGER NOT NULL + CHECK (generation BETWEEN 1 AND 9223372036854775807), + enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)), + profile INTEGER NOT NULL CHECK (profile IN (0, 1)), + application_handler INTEGER NOT NULL CHECK (application_handler IN (0, 1)), + target_set_sha256 BLOB NOT NULL CHECK (length(target_set_sha256) = 32), + target_count INTEGER NOT NULL CHECK (target_count BETWEEN 0 AND 32), + required_target_count INTEGER NOT NULL + CHECK (required_target_count BETWEEN 0 AND target_count), + queue_capacity INTEGER NOT NULL CHECK (queue_capacity BETWEEN 0 AND 4096), + desired_sha256 BLOB NOT NULL UNIQUE CHECK (length(desired_sha256) = 32), + CHECK ( + (enabled = 0 AND profile = 0 AND application_handler = 0 + AND target_count = 0 AND required_target_count = 0 + AND queue_capacity = 0) + OR + (enabled = 1 AND (profile = 1 OR application_handler = 1) + AND target_count BETWEEN 1 AND 32 + AND queue_capacity BETWEEN 1 AND 4096) + ) +) STRICT"# + }; +} + +macro_rules! presence_desired_state_guard_insert_sql { + () => { + r#"CREATE TRIGGER presence_desired_state_guard_insert +BEFORE INSERT ON presence_desired_state +WHEN NEW.generation != 1 + OR EXISTS (SELECT 1 FROM presence_desired_state) +BEGIN + SELECT RAISE(ABORT, 'presence desired-state insertion is invalid'); +END"# + }; +} + +macro_rules! presence_desired_state_guard_update_sql { + () => { + r#"CREATE TRIGGER presence_desired_state_guard_update +BEFORE UPDATE ON presence_desired_state +WHEN NEW.singleton != OLD.singleton + OR OLD.generation >= 9223372036854775807 + OR NEW.generation != OLD.generation + 1 + OR NEW.desired_sha256 = OLD.desired_sha256 +BEGIN + SELECT RAISE(ABORT, 'presence desired-state transition is invalid'); +END"# + }; +} + +macro_rules! presence_desired_state_no_delete_sql { + () => { + r#"CREATE TRIGGER presence_desired_state_no_delete +BEFORE DELETE ON presence_desired_state +BEGIN + SELECT RAISE(ABORT, 'presence desired state is retained'); +END"# + }; +} + +const CREATE_PRESENCE_DESIRED_STATE_TABLE_SQL: &str = presence_desired_state_table_sql!(); +const CREATE_PRESENCE_DESIRED_STATE_GUARD_INSERT_SQL: &str = + presence_desired_state_guard_insert_sql!(); +const CREATE_PRESENCE_DESIRED_STATE_GUARD_UPDATE_SQL: &str = + presence_desired_state_guard_update_sql!(); +const CREATE_PRESENCE_DESIRED_STATE_NO_DELETE_SQL: &str = presence_desired_state_no_delete_sql!(); +const CREATE_PRESENCE_DESIRED_STATE_MIGRATION_SQL: &str = concat!( + presence_desired_state_table_sql!(), + ";\n", + presence_desired_state_guard_insert_sql!(), + ";\n", + presence_desired_state_guard_update_sql!(), + ";\n", + presence_desired_state_no_delete_sql!(), + ";", +); + macro_rules! evidence_reconciliations_table_sql { () => { r#"CREATE TABLE evidence_reconciliations ( @@ -1538,6 +1634,22 @@ const TRADE_DIRTY_GENERATIONS_NO_DELETE_SHA256: [u8; 32] = [ 0x36, 0x66, 0x3a, 0x1a, 0xd4, 0x65, 0x41, 0x9f, 0x23, 0x1e, 0xe6, 0xcd, 0x1e, 0xd1, 0x6d, 0xa4, 0x26, 0xac, 0x7d, 0x70, 0xde, 0xc3, 0x67, 0x75, 0x55, 0x62, 0xa8, 0x45, 0x52, 0x86, 0x54, 0x23, ]; +const PRESENCE_DESIRED_STATE_TABLE_SHA256: [u8; 32] = [ + 0x78, 0x4d, 0xfc, 0x23, 0xd5, 0x05, 0xba, 0x09, 0xb3, 0x73, 0x76, 0xf0, 0x18, 0xaa, 0x03, 0xc6, + 0x3d, 0x98, 0x77, 0x1e, 0xba, 0xd6, 0x73, 0x25, 0x4a, 0x38, 0x46, 0xe3, 0x72, 0xc0, 0x80, 0x40, +]; +const PRESENCE_DESIRED_STATE_GUARD_INSERT_SHA256: [u8; 32] = [ + 0xe5, 0x55, 0xa5, 0x87, 0xa9, 0x73, 0x2f, 0xae, 0x7c, 0x2d, 0x4e, 0xde, 0xb1, 0x88, 0x93, 0x33, + 0x4a, 0xa4, 0x23, 0x12, 0x22, 0x7a, 0x71, 0xca, 0x6c, 0x2b, 0x38, 0x1f, 0x0b, 0x56, 0xad, 0x8b, +]; +const PRESENCE_DESIRED_STATE_GUARD_UPDATE_SHA256: [u8; 32] = [ + 0xb3, 0x2a, 0xc0, 0x44, 0xd6, 0x8c, 0x40, 0xfa, 0x3e, 0xbc, 0x57, 0x8a, 0x2d, 0x59, 0xcb, 0x1b, + 0xd6, 0x87, 0xd5, 0x3d, 0xa0, 0xc7, 0xec, 0x99, 0xb5, 0x1e, 0x63, 0xc9, 0x72, 0xeb, 0x27, 0x14, +]; +const PRESENCE_DESIRED_STATE_NO_DELETE_SHA256: [u8; 32] = [ + 0xca, 0xcd, 0xcf, 0x6f, 0x3b, 0x34, 0xc0, 0x7d, 0x6c, 0xf1, 0x1a, 0xab, 0x03, 0xd5, 0x19, 0x7c, + 0xca, 0x44, 0xab, 0x2e, 0x4f, 0x77, 0x65, 0x29, 0x75, 0x02, 0x4b, 0xbf, 0x1f, 0x04, 0xdc, 0x3f, +]; /// Stable classes for invalid embedded RHI catalog definitions. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1608,8 +1720,7 @@ impl fmt::Debug for RhiStateCatalogError { impl Error for RhiStateCatalogError {} -/// Constructs the exact ordered RHI migration catalog. -pub fn rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogError> { +fn build_rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogError> { let configuration = MigrationDescriptor::sql( 2, "create_configuration_binding_history", @@ -1659,6 +1770,13 @@ pub fn rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogError> MigrationChecksum::from_bytes(RHI_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256), ) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?; + let presence_desired_state = MigrationDescriptor::sql( + 9, + "create_presence_desired_state", + CREATE_PRESENCE_DESIRED_STATE_MIGRATION_SQL, + MigrationChecksum::from_bytes(RHI_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256), + ) + .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?; let catalog = MigrationCatalog::new([ configuration, trade_evidence, @@ -1667,10 +1785,17 @@ pub fn rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogError> reconciliation_results, report_publication, reconciliation_job_shape_guards, + presence_desired_state, ]) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?; + Ok(catalog) +} + +/// Constructs the exact ordered RHI migration catalog. +pub fn rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogError> { + let catalog = build_rhi_migration_catalog()?; if catalog.current_version() != RHI_STATE_SCHEMA_VERSION - || catalog.descriptors().len() != 7 + || catalog.descriptors().len() != 8 || catalog.digest().as_bytes() != &RHI_MIGRATION_CATALOG_SHA256 { return Err(RhiStateCatalogError::new( @@ -1683,6 +1808,14 @@ pub fn rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogError> /// Constructs the exact RHI schema catalog bound to the migration catalog. pub fn rhi_schema_catalog() -> Result<SchemaCatalog, RhiStateCatalogError> { let migrations = rhi_migration_catalog()?; + let catalog = build_rhi_schema_catalog(&migrations)?; + validate_rhi_state_catalogs(&migrations, &catalog)?; + Ok(catalog) +} + +fn build_rhi_schema_catalog( + migrations: &MigrationCatalog, +) -> Result<SchemaCatalog, RhiStateCatalogError> { let version_one = SchemaVersionCatalog::new( RHI_STATE_BASE_SCHEMA_VERSION, [], @@ -1726,13 +1859,19 @@ pub fn rhi_schema_catalog() -> Result<SchemaCatalog, RhiStateCatalogError> { ) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?; let version_eight = SchemaVersionCatalog::new( - RHI_STATE_SCHEMA_VERSION, + 8, rhi_schema_version_eight_objects()?, SchemaDigest::from_bytes(RHI_STATE_SCHEMA_VERSION_8_SHA256), ) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?; + let version_nine = SchemaVersionCatalog::new( + RHI_STATE_SCHEMA_VERSION, + rhi_schema_version_nine_objects()?, + SchemaDigest::from_bytes(RHI_STATE_SCHEMA_VERSION_9_SHA256), + ) + .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?; let catalog = SchemaCatalog::new( - &migrations, + migrations, [ version_one, version_two, @@ -1742,10 +1881,10 @@ pub fn rhi_schema_catalog() -> Result<SchemaCatalog, RhiStateCatalogError> { version_six, version_seven, version_eight, + version_nine, ], ) .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?; - validate_rhi_state_catalogs(&migrations, &catalog)?; Ok(catalog) } @@ -1756,10 +1895,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() == 7 + && migrations.descriptors().len() == 8 && migrations.digest().as_bytes() == &RHI_MIGRATION_CATALOG_SHA256 && schema.migration_catalog_digest() == migrations.digest() - && versions.len() == 8 + && versions.len() == 9 && 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 @@ -1781,9 +1920,12 @@ pub fn validate_rhi_state_catalogs( && versions[6].version() == 7 && versions[6].object_count() == RHI_STATE_SCHEMA_VERSION_7_OBJECT_COUNT && versions[6].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_7_SHA256 - && versions[7].version() == RHI_STATE_SCHEMA_VERSION + && versions[7].version() == 8 && versions[7].object_count() == RHI_STATE_SCHEMA_VERSION_8_OBJECT_COUNT && versions[7].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_8_SHA256 + && versions[8].version() == RHI_STATE_SCHEMA_VERSION + && versions[8].object_count() == RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT + && versions[8].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_9_SHA256 && schema.digest().as_bytes() == &RHI_STATE_SCHEMA_CATALOG_SHA256; if valid { Ok(()) @@ -1867,6 +2009,51 @@ fn rhi_schema_version_eight_objects() -> Result<Vec<SchemaObject>, RhiStateCatal Ok(objects) } +fn rhi_schema_version_nine_objects() -> Result<Vec<SchemaObject>, RhiStateCatalogError> { + let mut objects = rhi_schema_version_eight_objects()?; + objects.extend(rhi_presence_desired_state_objects()?); + Ok(objects) +} + +fn rhi_presence_desired_state_objects() -> Result<[SchemaObject; 4], RhiStateCatalogError> { + let object = |kind, name, sql, digest| { + SchemaObject::new( + kind, + name, + "presence_desired_state", + sql, + SchemaDigest::from_bytes(digest), + ) + .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog)) + }; + Ok([ + object( + SchemaObjectKind::Table, + "presence_desired_state", + CREATE_PRESENCE_DESIRED_STATE_TABLE_SQL, + PRESENCE_DESIRED_STATE_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "presence_desired_state_guard_insert", + CREATE_PRESENCE_DESIRED_STATE_GUARD_INSERT_SQL, + PRESENCE_DESIRED_STATE_GUARD_INSERT_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "presence_desired_state_guard_update", + CREATE_PRESENCE_DESIRED_STATE_GUARD_UPDATE_SQL, + PRESENCE_DESIRED_STATE_GUARD_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "presence_desired_state_no_delete", + CREATE_PRESENCE_DESIRED_STATE_NO_DELETE_SQL, + PRESENCE_DESIRED_STATE_NO_DELETE_SHA256, + )?, + ]) +} + fn rhi_reconciliation_job_shape_guard_objects() -> Result<[SchemaObject; 2], RhiStateCatalogError> { let object = |name, sql, digest| { SchemaObject::new( diff --git a/src/state_repository.rs b/src/state_repository.rs @@ -479,6 +479,12 @@ impl<'host> RhiPublicationOutboxRepository<'host> { } } +impl<'host> RhiDesiredPresenceRepository<'host> { + pub(crate) const fn host(&self) -> &'host RhiStateHost { + self.host + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -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 PRESENCE_DESIRED: &str = include_str!("../src/presence_desired.rs"); +const PRESENCE_DESIRED_CONTRACT: &str = + include_str!("../contracts/services_hardening/presence_desired_state.v1.json"); const PUBLICATION: &str = include_str!("../src/publication.rs"); const PUBLICATION_ATTEMPT: &str = include_str!("../src/publication_attempt.rs"); const PUBLICATION_ATTEMPT_CONTRACT: &str = @@ -69,6 +72,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/presence_desired.rs"), include_str!("../src/publication.rs"), include_str!("../src/publication_attempt.rs"), include_str!("../src/publication_execution.rs"), @@ -148,6 +152,7 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "features", "identity_credential", "identity_envelope", + "presence_desired", "publication", "publication_attempt", "publication_execution", @@ -197,6 +202,12 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "RhiStateCatalogError", "RhiRuntimeAdapters", "RhiPublicationAuthority", + "RhiPresenceDesiredAuthority", + "RhiPresenceDesiredCommitOutcome", + "RhiPresenceDesiredErrorKind", + "RhiPresenceDesiredState", + "validate_rhi_presence_desired_authority", + "RHI_PRESENCE_DESIRED_CONTRACT_VERSION", "RhiPublicationErrorKind", "RhiPublicationMode", "RhiPublicationRetryPolicy", @@ -306,6 +317,9 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { assert_eq!(public_modules, ["pub mod rhi"]); assert!(PUBLIC_API.contains("pub struct rhi::NostrEventAdapter<'a>")); assert!(PUBLIC_API.contains("pub struct rhi::TradeAgreementAttestationError")); + assert!(PUBLIC_API.contains("pub struct rhi::RhiPresenceDesiredAuthority")); + assert!(PUBLIC_API.contains("pub struct rhi::RhiPresenceDesiredState")); + assert!(PUBLIC_API.contains("pub enum rhi::RhiPresenceDesiredErrorKind")); assert!(!PUBLIC_API.contains("rhi::adapters::")); assert!(!PUBLIC_API.contains("rhi::features::")); assert!(!PUBLIC_API.contains("rhi::runtime_adapters::")); @@ -316,6 +330,47 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { } #[test] +fn presence_desired_state_is_config_bound_durable_and_effect_free() { + let contract: serde_json::Value = + serde_json::from_str(PRESENCE_DESIRED_CONTRACT).expect("presence-desired-state contract"); + assert_eq!(contract["schema"], "radroots.rhi.presence-desired-state"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["authority"]["maximum_targets"], 32); + assert_eq!(contract["durable_state"]["write_class"], "compare_and_swap"); + assert_eq!(contract["durable_state"]["rows"], "exactly_zero_or_one"); + assert_eq!(contract["effects"]["network"], false); + assert_eq!(contract["effects"]["relay_io"], false); + for required in [ + "pub fn validate_rhi_presence_desired_authority(", + "require_current_config(transaction, authority).await?", + "LIMIT 1", + "LIMIT 2", + "ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown", + ] { + assert!( + PRESENCE_DESIRED.contains(required), + "presence desired-state boundary is missing {required}" + ); + } + for forbidden in [ + "SystemTime", + "OsRng", + "thread_rng", + "tokio::spawn", + "std::net", + "EventSink", + "sign_nostr_event", + ] { + assert!( + !PRESENCE_DESIRED.contains(forbidden), + "presence desired-state boundary gained forbidden authority {forbidden}" + ); + } + assert!(!ROOT.contains("pub mod presence_desired")); + assert!(!PUBLIC_API.contains("rhi::presence_desired::")); +} + +#[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"); @@ -799,7 +854,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, 29); + assert_eq!(public_error_count, 30); } #[test] @@ -1233,6 +1288,8 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() { "## Canonical signed reconciliation attestation", "[`reconciliation_attestation.v1.json`](contracts/services_hardening/reconciliation_attestation.v1.json)", "## Explicit publication authority and durable schema", + "## Deterministic durable presence intent", + "[`presence_desired_state.v1.json`](contracts/services_hardening/presence_desired_state.v1.json)", "[`publication_outbox.v1.json`](contracts/services_hardening/publication_outbox.v1.json)", "## Bounded publication attempt evidence", "[`publication_attempt_evidence.v1.json`](contracts/services_hardening/publication_attempt_evidence.v1.json)", diff --git a/tests/services_hardening_presence_desired_state.rs b/tests/services_hardening_presence_desired_state.rs @@ -0,0 +1,325 @@ +#![forbid(unsafe_code)] +#![cfg(any(target_os = "linux", target_os = "macos"))] + +use std::{fs, os::unix::fs::PermissionsExt, path::Path}; + +use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; +use radroots_storage::event::SourceGeneration; +use rhi::{ + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiConfigDocumentV1, + RhiConfigProfile, RhiPresenceDesiredAuthority, RhiPresenceDesiredErrorKind, + RhiPresenceDesiredMode, RhiStateMetadata, apply_rhi_configuration, initialize_rhi_state, + open_rhi_state_inspection, open_rhi_state_read_write_from_config, parse_rhi_cli_v1_from, + parse_rhi_config_v1, resolve_rhi_runtime_context, validate_rhi_presence_desired_authority, +}; +use sqlx::{ConnectOptions, Connection, Row, SqliteConnection, sqlite::SqliteConnectOptions}; + +const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); +const CONTRACT: &str = + include_str!("../contracts/services_hardening/presence_desired_state.v1.json"); +const SOURCE: &str = include_str!("../src/presence_desired.rs"); + +fn runtime(root: &Path) -> rhi::RhiRuntimeContext { + let invocation = parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "repo-local", + "--instance", + "primary", + "--repo-local-root", + root.to_str().expect("UTF-8 root"), + "run", + ]) + .expect("invocation"); + resolve_rhi_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + &invocation, + ) + .expect("runtime") +} + +fn config(source: &str) -> RhiConfigDocumentV1 { + parse_rhi_config_v1(source.as_bytes(), RhiConfigProfile::RepoLocal).expect("configuration") +} + +fn evidence(at: u64) -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) { + let applied_at = MigrationAppliedAtUnixSeconds::new(at).expect("migration time"); + let build = MigrationBuildIdentity::new( + env!("CARGO_PKG_VERSION"), + "1111111111111111111111111111111111111111", + "21b11e7a5120ea949f7ad0838c746873fc73aac2", + "rustc-test", + "test-target", + "service-host", + 1, + rhi::RHI_STATE_SCHEMA_VERSION, + 1, + 1, + 1, + ) + .expect("build identity"); + (applied_at, build) +} + +async fn offline_connection(runtime: &rhi::RhiRuntimeContext) -> SqliteConnection { + let options = SqliteConnectOptions::new() + .filename(runtime.artifacts().state_database()) + .create_if_missing(false) + .foreign_keys(false) + .disable_statement_logging(); + SqliteConnection::connect_with(&options) + .await + .expect("offline connection") +} + +#[tokio::test] +async fn desired_state_is_durable_exact_replay_and_semantic_change_only() { + let directory = tempfile::tempdir().expect("root"); + let runtime = runtime(directory.path()); + fs::create_dir_all(runtime.context().paths().state()).expect("state directory"); + fs::set_permissions( + runtime.context().paths().state(), + fs::Permissions::from_mode(0o700), + ) + .expect("state mode"); + let original = config(EXAMPLE); + let metadata = RhiStateMetadata::new( + &runtime, + &original, + SourceGeneration::new([0x5a; 32]).expect("source generation"), + 1_725_000_000_000, + ) + .expect("metadata"); + let (first_at, first_build) = evidence(1_725_000_000); + initialize_rhi_state(&runtime, &metadata, first_at, &first_build) + .await + .expect("initialize"); + + let original_authority = + RhiPresenceDesiredAuthority::from_config(&original).expect("authority"); + let host = open_rhi_state_read_write_from_config(&runtime, &original, first_at, &first_build) + .await + .expect("writer"); + let repository = host.repositories().desired_presence(); + assert_eq!(repository.current().await.expect("initial read"), None); + let first = repository + .commit(&original_authority) + .await + .expect("first commit"); + assert!(first.changed()); + assert_eq!(first.state().generation(), 1); + assert_eq!(first.state().mode(), RhiPresenceDesiredMode::Enabled); + assert!(first.state().profile()); + assert!(first.state().application_handler()); + assert_eq!(first.state().target_count(), 2); + assert_eq!(first.state().required_target_count(), 1); + assert_eq!(first.state().queue_capacity(), 64); + let replay = repository + .commit(&original_authority) + .await + .expect("exact replay"); + assert!(!replay.changed()); + assert_eq!(replay.state(), first.state()); + assert_eq!( + repository.current().await.expect("current"), + Some(first.state()) + ); + host.close().await.expect("writer close"); + + let inspection = open_rhi_state_inspection(&runtime, &metadata) + .await + .expect("inspection"); + let inspected = inspection + .repositories() + .desired_presence() + .current() + .await + .expect("inspection read"); + assert_eq!(inspected, Some(first.state())); + let rejected = inspection + .repositories() + .desired_presence() + .commit(&original_authority) + .await + .expect_err("inspection mutation"); + assert_eq!(rejected.kind(), RhiPresenceDesiredErrorKind::InvalidMode); + inspection.close().await.expect("inspection close"); + + let unrelated_source = EXAMPLE.replacen("level = \"info\"", "level = \"debug\"", 1); + let unrelated = config(&unrelated_source); + let unrelated_authority = + RhiPresenceDesiredAuthority::from_config(&unrelated).expect("unrelated authority"); + assert_eq!( + unrelated_authority.desired_sha256(), + original_authority.desired_sha256() + ); + assert_eq!( + validate_rhi_presence_desired_authority(&unrelated, &original_authority) + .expect_err("full configuration binding") + .kind(), + RhiPresenceDesiredErrorKind::Binding + ); + let (second_at, second_build) = evidence(1_725_000_001); + apply_rhi_configuration(&runtime, &original, &unrelated, second_at, &second_build) + .await + .expect("apply unrelated configuration"); + let host = + open_rhi_state_read_write_from_config(&runtime, &unrelated, second_at, &second_build) + .await + .expect("writer after unrelated config"); + let replay = host + .repositories() + .desired_presence() + .commit(&unrelated_authority) + .await + .expect("semantic replay"); + assert!(!replay.changed()); + assert_eq!(replay.state().generation(), 1); + host.close().await.expect("writer close"); + + let presence_source = unrelated_source.replace("profile = true", "profile = false"); + let presence_changed = config(&presence_source); + let changed_authority = + RhiPresenceDesiredAuthority::from_config(&presence_changed).expect("changed authority"); + assert_ne!( + changed_authority.desired_sha256(), + original_authority.desired_sha256() + ); + let (third_at, third_build) = evidence(1_725_000_002); + apply_rhi_configuration( + &runtime, + &unrelated, + &presence_changed, + third_at, + &third_build, + ) + .await + .expect("apply presence configuration"); + let host = + open_rhi_state_read_write_from_config(&runtime, &presence_changed, third_at, &third_build) + .await + .expect("writer after presence change"); + let stale = host + .repositories() + .desired_presence() + .commit(&original_authority) + .await + .expect_err("stale config authority"); + assert_eq!(stale.kind(), RhiPresenceDesiredErrorKind::Binding); + let changed = host + .repositories() + .desired_presence() + .commit(&changed_authority) + .await + .expect("changed desired state"); + assert!(changed.changed()); + assert_eq!(changed.state().generation(), 2); + assert!(!changed.state().profile()); + assert!(changed.state().application_handler()); + assert_eq!(changed.state().target_count(), 2); + assert_eq!( + changed.state().desired_sha256(), + changed_authority.desired_sha256() + ); + host.close().await.expect("final writer close"); + + let mut connection = offline_connection(&runtime).await; + let row = sqlx::query( + "SELECT COUNT(*) AS row_count, generation, length(desired_sha256) AS digest_bytes \ + FROM presence_desired_state", + ) + .fetch_one(&mut connection) + .await + .expect("durable desired state"); + assert_eq!(row.try_get::<i64, _>("row_count").unwrap(), 1); + assert_eq!(row.try_get::<i64, _>("generation").unwrap(), 2); + assert_eq!(row.try_get::<i64, _>("digest_bytes").unwrap(), 32); + assert!( + sqlx::query("UPDATE presence_desired_state SET generation = generation") + .execute(&mut connection) + .await + .is_err() + ); + assert!( + sqlx::query("DELETE FROM presence_desired_state") + .execute(&mut connection) + .await + .is_err() + ); + connection.close().await.expect("offline close"); + + let database = fs::read(runtime.artifacts().state_database()).expect("database bytes"); + for forbidden in [ + b"wss://relay-primary.example".as_slice(), + b"relay-primary".as_slice(), + directory.path().to_string_lossy().as_bytes(), + ] { + assert!( + !database + .windows(forbidden.len()) + .any(|window| window == forbidden) + ); + } +} + +#[test] +fn machine_contract_freezes_step_204_and_defers_step_205_effects() { + let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract"); + assert_eq!(contract["schema"], "radroots.rhi.presence-desired-state"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["authority"]["maximum_targets"], 32); + assert_eq!( + contract["reference_vector"]["target_set_sha256"], + "959f04012841ae6e9bf3e109468b4f66cfa9d966aac1df36df09e45c1e1c48f9" + ); + assert_eq!( + contract["reference_vector"]["desired_state_sha256"], + "7235f1e386e839427625dc364df7b51ee74d39d5f170e12b25cf2c42fd7731f0" + ); + assert_eq!(contract["effects"]["clock"], false); + assert_eq!(contract["effects"]["entropy"], false); + assert_eq!(contract["effects"]["network"], false); + assert_eq!(contract["effects"]["relay_io"], false); + assert_eq!(contract["step_205_deferrals"].as_array().unwrap().len(), 7); + for required in [ + "DESIRED_STATE_DOMAIN", + "TARGET_SET_DOMAIN", + "pub fn validate_rhi_presence_desired_authority(", + "pub async fn commit(", + "pub async fn current(", + "require_current_config(transaction, authority).await?", + "LIMIT 2", + "ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown", + ] { + assert!( + SOURCE.contains(required), + "missing Step204 boundary {required}" + ); + } + for forbidden in [ + "SystemTime", + "OsRng", + "thread_rng", + "tokio::spawn", + "std::net", + "NostrEventAdapter", + "sign_nostr_event", + ] { + assert!( + !SOURCE.contains(forbidden), + "unexpected Step205 effect {forbidden}" + ); + } + for kind in [ + RhiPresenceDesiredErrorKind::InvalidConfiguration, + RhiPresenceDesiredErrorKind::TargetInventory, + RhiPresenceDesiredErrorKind::InvalidMode, + RhiPresenceDesiredErrorKind::Binding, + RhiPresenceDesiredErrorKind::ResourceExhausted, + RhiPresenceDesiredErrorKind::Storage, + RhiPresenceDesiredErrorKind::CommitOutcomeUnknown, + ] { + let rendered = format!("{kind:?}"); + assert!(!rendered.contains("relay-primary")); + } +} diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs @@ -20,8 +20,9 @@ use rhi::{ RHI_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_7_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_7_SHA256, RHI_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_8_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_8_SHA256, - RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog, - validate_rhi_state_catalogs, + RHI_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT, + RHI_STATE_SCHEMA_VERSION_9_SHA256, RhiStateCatalogErrorKind, rhi_migration_catalog, + rhi_schema_catalog, validate_rhi_state_catalogs, }; const CATALOG_SOURCE: &str = include_str!("../src/state_catalog.rs"); @@ -29,14 +30,14 @@ const LIB_SOURCE: &str = include_str!("../src/lib.rs"); const MANIFEST: &str = include_str!("../Cargo.toml"); #[test] -fn schema_v1_through_v8_catalogs_have_exact_literal_identities() { +fn schema_v1_through_v9_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, 8); - assert_eq!(migrations.descriptors().len(), 7); - assert_eq!(migrations.current_version(), 8); + assert_eq!(RHI_STATE_SCHEMA_VERSION, 9); + assert_eq!(migrations.descriptors().len(), 8); + assert_eq!(migrations.current_version(), 9); assert_eq!(migrations.descriptors()[0].target_version(), 2); assert_eq!( migrations.descriptors()[0].name().as_str(), @@ -100,12 +101,21 @@ fn schema_v1_through_v8_catalogs_have_exact_literal_identities() { migrations.descriptors()[6].checksum().as_bytes(), &RHI_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256 ); + assert_eq!(migrations.descriptors()[7].target_version(), 9); + assert_eq!( + migrations.descriptors()[7].name().as_str(), + "create_presence_desired_state" + ); + assert_eq!( + migrations.descriptors()[7].checksum().as_bytes(), + &RHI_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256 + ); assert_eq!( migrations.digest().as_bytes(), &RHI_MIGRATION_CATALOG_SHA256 ); - assert_eq!(schema.versions().len(), 8); + assert_eq!(schema.versions().len(), 9); let version = schema.versions()[0]; assert_eq!(version.version(), 1); assert_eq!( @@ -194,13 +204,24 @@ fn schema_v1_through_v8_catalogs_have_exact_literal_identities() { version.digest().as_bytes(), &RHI_STATE_SCHEMA_VERSION_8_SHA256 ); + let version = schema.versions()[8]; + assert_eq!(version.version(), 9); + assert_eq!( + version.object_count(), + RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT + ); + assert_eq!(version.object_count(), 69); + assert_eq!( + version.digest().as_bytes(), + &RHI_STATE_SCHEMA_VERSION_9_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), - "e65ad1155c9da2703281992268530c87e5248a52f766011c235e1fdb671c774f" + "7f8c03b4818408b586747412c265ac72cd4dd88a290d8bbee1d5f68adbf7afd0" ); assert_eq!( lower_hex(&RHI_STATE_SCHEMA_VERSION_1_SHA256), @@ -263,8 +284,16 @@ fn schema_v1_through_v8_catalogs_have_exact_literal_identities() { "7cef559ae1e6efe158c5d1de50114ebaccac90538e0cd49a4ee214cb0a39c618" ); assert_eq!( + lower_hex(&RHI_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256), + "32dabe777289e0fb6e64a4c1f8257843fc08018321c3b7ec5ca584fa1862bdcc" + ); + assert_eq!( + lower_hex(&RHI_STATE_SCHEMA_VERSION_9_SHA256), + "5551e8790544a7c78c8376c5ccf83dd2a8486d2dc08b6a7808bb2007c94335ec" + ); + assert_eq!( lower_hex(&RHI_STATE_SCHEMA_CATALOG_SHA256), - "9330351d300f700f317ed2c72cbbb08522a827b962fe6ccb343e3ee71fdfd07c" + "5bea3e3ec6be3f1249ad19ed7b2d2e872c34025579a599f254de8e80cba9b3c7" ); } @@ -328,6 +357,10 @@ fn independent_validator_rejects_migration_or_schema_drift() { SchemaVersionCatalog::computed_digest(8, [version_two_object()]).expect("v8 digest"); let version_eight = SchemaVersionCatalog::new(8, [version_two_object()], snapshot_digest) .expect("version eight"); + let snapshot_digest = + SchemaVersionCatalog::computed_digest(9, [version_two_object()]).expect("v9 digest"); + let version_nine = SchemaVersionCatalog::new(9, [version_two_object()], snapshot_digest) + .expect("version nine"); let schema = SchemaCatalog::new( &exact_migrations, [ @@ -339,6 +372,7 @@ fn independent_validator_rejects_migration_or_schema_drift() { version_six, version_seven, version_eight, + version_nine, ], ) .expect("drift schema catalog"); @@ -401,6 +435,10 @@ fn catalog_errors_are_stable_source_free_and_redacted() { SchemaVersionCatalog::computed_digest(8, [secret_object()]).expect("v8 digest"); let version_eight = SchemaVersionCatalog::new(8, [secret_object()], version_eight_digest) .expect("version eight"); + let version_nine_digest = + SchemaVersionCatalog::computed_digest(9, [secret_object()]).expect("v9 digest"); + let version_nine = + SchemaVersionCatalog::new(9, [secret_object()], version_nine_digest).expect("version nine"); let schema = SchemaCatalog::new( &migrations, [ @@ -412,6 +450,7 @@ fn catalog_errors_are_stable_source_free_and_redacted() { version_six, version_seven, version_eight, + version_nine, ], ) .expect("schema catalog"); diff --git a/tests/services_hardening_state_host.rs b/tests/services_hardening_state_host.rs @@ -106,13 +106,17 @@ async fn downgrade_fixture_to_schema_v7(runtime: &rhi::RhiRuntimeContext) { .await .expect("migration delete guard SQL"); for statement in [ + "DROP TRIGGER presence_desired_state_guard_insert", + "DROP TRIGGER presence_desired_state_guard_update", + "DROP TRIGGER presence_desired_state_no_delete", + "DROP TABLE presence_desired_state", "DROP TRIGGER reconciliation_jobs_shape_guard_insert", "DROP TRIGGER reconciliation_jobs_shape_guard_update", "DROP TRIGGER radroots_service_metadata_guard_update", "DROP TRIGGER schema_migrations_no_update", "DROP TRIGGER schema_migrations_no_delete", "UPDATE radroots_service_metadata SET state_schema_version = 7 WHERE singleton = 1", - "DELETE FROM schema_migrations WHERE version = 8", + "DELETE FROM schema_migrations WHERE version IN (8, 9)", ] { sqlx::query(statement) .execute(&mut connection) @@ -418,7 +422,7 @@ async fn schema_v8_scans_historical_nullable_job_state_and_installs_permanent_gu let (applied_at, build) = migration_evidence(); initialize_rhi_state(&runtime, &metadata, applied_at, &build) .await - .expect("schema-v8 initialization"); + .expect("current-schema initialization"); downgrade_fixture_to_schema_v7(&runtime).await; insert_historical_reconciliation_job(&runtime, state, next_attempt, owner, expiry).await; @@ -450,7 +454,7 @@ async fn schema_v8_scans_historical_nullable_job_state_and_installs_permanent_gu let (applied_at, build) = migration_evidence(); initialize_rhi_state(&runtime, &metadata, applied_at, &build) .await - .expect("schema-v8 initialization"); + .expect("current-schema initialization"); downgrade_fixture_to_schema_v7(&runtime).await; insert_historical_reconciliation_job( &runtime, @@ -479,7 +483,7 @@ async fn schema_v8_scans_historical_nullable_job_state_and_installs_permanent_gu .fetch_one(&mut connection) .await .expect("migrated schema state"); - assert_eq!(migrated, (8, 1, 2, 0)); + assert_eq!(migrated, (9, 1, 2, 0)); let invalid_insert = sqlx::query( r#"INSERT INTO reconciliation_jobs ( 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 (9, 'unexpected_schema', ?, 1725000000, '0.1.0', ?, ?, + ) VALUES (10, 'unexpected_schema', ?, 1725000000, '0.1.0', ?, ?, 'rustc-test', 'test-target', 'service-host', 1, 7, 1, 1, 1)", ) .bind([0x44_u8; 32].as_slice())