myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 67775a6f61c962e375cb72b7686f821a0d5ab865
parent 3ed323e64ca64a8973e06623a583e1ca8f17b9e3
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 10:18:52 +0000

myc: add durable admin operation journal

- add the exact schema-v11 journal and catalog identities
- reserve bounded replay capacity and reject conflicting reuse
- preserve exact historical v10 configuration evidence
- update configuration, release, API, and package contracts

Diffstat:
MAGENTS.md | 10++++++++++
MCargo.toml | 2+-
MREADME | 14+++++++++++++-
Mcontracts/api_baselines/myc.txt | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/services_hardening/config.v1.schema.json | 2+-
Mcontracts/services_hardening/native_release.v1.json | 2+-
Mradroots.service.source-lock.v2.toml | 2+-
Msrc/admin_v1.rs | 24++++++++++++++++++++++++
Msrc/lib.rs | 17+++++++++++++++--
Asrc/state_admin.rs | 1328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/state_catalog.rs | 147++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/state_host.rs | 21+++++++++++----------
Msrc/state_repository.rs | 24+++++++++++++++++++-----
Mtests/build_policy.rs | 2+-
Mtests/package_boundary.rs | 21++++++++++++++++++++-
Mtests/services_hardening_config_contract.rs | 12++++++++++++
Mtests/services_hardening_config_lifecycle.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/services_hardening_native_release.rs | 4++--
Mtests/services_hardening_state_catalog.rs | 54+++++++++++++++++++++++++++++++++++++++++++-----------
Mtests/services_hardening_state_repository.rs | 7++++++-
20 files changed, 1870 insertions(+), 49 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -141,6 +141,16 @@ nonterminal delivery work cannot be removed or changed. Do not persist relay URLs, paths, credentials, provider envelopes, or protected values in the binding history. +- Step 159 unit 11 owns schema-v11 bounded admin idempotency. Keep operation + identifiers on the fixed ASCII grammar, bind route plus canonical request + digest, cap replay models at 8,192 bytes, prune only expired Completed rows, + reserve completion capacity at admission, and retain unresolved Prepared + evidence as outcome-unknown. The configured admin response cap must admit the + maximum model in its bounded success envelope. Never persist a + request body, path, correlation ID, credential, bundle path, or secret in the + journal. Database-only mutations must later compose their effect, audit, and + completion in one transaction; online backup records Prepared before capture + and completes only after the bundle is durable. - Treat checked-in source, tests, and prototype behavior as implementation evidence, not permission to preserve behavior that the active requirement removes. diff --git a/Cargo.toml b/Cargo.toml @@ -18,7 +18,7 @@ service = "myc" host_feature_profile = "service-host" nix_material = "deferred" config_contract_version = 1 -state_contract_version = 10 +state_contract_version = 11 admin_contract_version = 1 status_contract_version = 1 provider_contract_version = 1 diff --git a/README b/README @@ -151,7 +151,7 @@ without changing the canonical common artifact inventory. Create-new initialization reserves the shared schema-v1 metadata and migration ledger, retains exclusive writer authority, applies the exact Myc schema-v2 -through schema-v10 migrations, binds the normalized configuration, expected +through schema-v11 migrations, binds the normalized configuration, expected identity roles, and policy versions through a sealed typed repository, and explicitly closes the host before reporting success. Existing writable open can resume any exact v1 through v9 prefix; read-only inspection requires the current @@ -173,6 +173,18 @@ history stores only digests, public identities, closed contract versions, injected application evidence, and safe build identity; it stores no credentials, provider envelopes, paths, or relay URLs. +Schema v11 adds the bounded admin-operation journal. It binds each mutation's +validated operation ID to its fixed route and canonical request digest, retains +at most 128 unresolved Prepared records and 4,096 completed responses, caps a +replayed response model at 8,192 bytes, and prunes only a bounded expired +completed prefix before admission. The journal stores no request body, path, +correlation ID, credential, bundle path, or secret. Completed responses use an +explicit retention policy whose admitted range is 1 through 31,536,000,000 +milliseconds; the governed operating value is seven days. +The admin response transport admits at least 8,382 UTF-8 bytes so the maximum +retained model plus the maximum safe correlation identity always fits its +canonical success envelope. + Schema v8 adds immutable NIP-46 operation-completion evidence. The Step 147 integration checkpoint binds each durable request to its stable operation and correlation identities, terminal connect authority or exact active session, diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt @@ -26,6 +26,25 @@ pub myc::MycAdminHandlerErrorKind::Unavailable pub enum myc::MycAdminMethod pub myc::MycAdminMethod::Get pub myc::MycAdminMethod::Post +pub enum myc::MycAdminOperationAdmission +pub myc::MycAdminOperationAdmission::ExactReplay(myc::MycAdminResponseDocument) +pub myc::MycAdminOperationAdmission::Prepared(myc::MycPreparedAdminOperation) +impl core::fmt::Debug for myc::MycAdminOperationAdmission +pub fn myc::MycAdminOperationAdmission::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub enum myc::MycAdminOperationCompletion +pub myc::MycAdminOperationCompletion::Completed +pub myc::MycAdminOperationCompletion::ExactReplay +pub enum myc::MycAdminOperationErrorKind +pub myc::MycAdminOperationErrorKind::Binding +pub myc::MycAdminOperationErrorKind::CommitOutcomeUnknown +pub myc::MycAdminOperationErrorKind::InvalidInput +pub myc::MycAdminOperationErrorKind::InvalidMode +pub myc::MycAdminOperationErrorKind::OperationConflict +pub myc::MycAdminOperationErrorKind::OperationOutcomeUnknown +pub myc::MycAdminOperationErrorKind::ResourceExhausted +pub myc::MycAdminOperationErrorKind::Transaction +impl myc::MycAdminOperationErrorKind +pub const fn myc::MycAdminOperationErrorKind::code(self) -> &'static str pub enum myc::MycAdminRoute pub myc::MycAdminRoute::AuditEvents pub myc::MycAdminRoute::AuditSummary @@ -798,6 +817,24 @@ pub const fn myc::MycAdminHandlerError::new(myc::MycAdminHandlerErrorKind) -> Se impl core::error::Error for myc::MycAdminHandlerError impl core::fmt::Display for myc::MycAdminHandlerError pub fn myc::MycAdminHandlerError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycAdminOperationError +impl myc::MycAdminOperationError +pub const fn myc::MycAdminOperationError::code(self) -> &'static str +pub const fn myc::MycAdminOperationError::kind(self) -> myc::MycAdminOperationErrorKind +impl core::error::Error for myc::MycAdminOperationError +impl core::fmt::Debug for myc::MycAdminOperationError +pub fn myc::MycAdminOperationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for myc::MycAdminOperationError +pub fn myc::MycAdminOperationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycAdminOperationJournalPolicy +impl myc::MycAdminOperationJournalPolicy +pub const fn myc::MycAdminOperationJournalPolicy::completed_retention_ms(self) -> u64 +pub fn myc::MycAdminOperationJournalPolicy::new(u64) -> core::result::Result<Self, myc::MycAdminOperationError> +pub const fn myc::MycAdminOperationJournalPolicy::seven_days() -> Self +pub struct myc::MycAdminOperationTimeUnixMs(_) +impl myc::MycAdminOperationTimeUnixMs +pub const fn myc::MycAdminOperationTimeUnixMs::get(self) -> u64 +pub fn myc::MycAdminOperationTimeUnixMs::new(u64) -> core::result::Result<Self, myc::MycAdminOperationError> pub struct myc::MycAdminRequestDocument impl myc::MycAdminRequestDocument pub fn myc::MycAdminRequestDocument::correlation_id(&self) -> &str @@ -1547,6 +1584,9 @@ impl myc::MycPersistenceStatusV1 pub fn myc::MycPersistenceStatusV1::new(myc::MycPersistenceHealthV1, u32, u64, myc::MycIntegrityStateV1, myc::MycStatusReasonCodes) -> core::result::Result<Self, myc::MycStatusError> impl core::fmt::Debug for myc::MycPersistenceStatusV1 pub fn myc::MycPersistenceStatusV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycPreparedAdminOperation +impl core::fmt::Debug for myc::MycPreparedAdminOperation +pub fn myc::MycPreparedAdminOperation::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycPreparedNip46Request impl myc::MycPreparedNip46Request pub const fn myc::MycPreparedNip46Request::method(&self) -> myc::MycSignerRequestMethod @@ -1894,6 +1934,9 @@ impl myc::MycStateRepository<'_> pub async fn myc::MycStateRepository<'_>::compact_governance_evidence(&self, myc::MycConnectionTimeUnixMs, myc::MycGovernanceCompactionPolicy, myc::MycAuditCorrelationId) -> core::result::Result<myc::MycGovernanceCompactionOutcome, myc::MycStateRepositoryError> pub async fn myc::MycStateRepository<'_>::read_audit_page(&self, myc::MycAuditPageLimit, core::option::Option<u64>, core::option::Option<u64>) -> core::result::Result<myc::MycAuditPage, myc::MycStateRepositoryError> impl myc::MycStateRepository<'_> +pub async fn myc::MycStateRepository<'_>::complete_admin_operation(&self, &myc::MycPreparedAdminOperation, &myc::MycAdminResponseDocument, myc::MycAdminOperationTimeUnixMs, myc::MycAdminOperationJournalPolicy) -> core::result::Result<myc::MycAdminOperationCompletion, myc::MycAdminOperationError> +pub async fn myc::MycStateRepository<'_>::prepare_admin_operation(&self, &myc::MycAdminRequestDocument, myc::MycAdminOperationTimeUnixMs) -> core::result::Result<myc::MycAdminOperationAdmission, myc::MycAdminOperationError> +impl myc::MycStateRepository<'_> pub async fn myc::MycStateRepository<'_>::recover_delivery_state(&self, myc::MycDeliveryTimeUnixMs, myc::MycDeliveryRecoveryEntropy) -> core::result::Result<myc::MycDeliveryRecoveryReport, myc::MycStateRepositoryError> impl<'host> myc::MycStateRepository<'host> pub async fn myc::MycStateRepository<'host>::verify_binding(&self) -> core::result::Result<(), myc::MycStateRepositoryError> @@ -2029,6 +2072,14 @@ pub fn myc::MycVerifiedStateBackup::fmt(&self, &mut core::fmt::Formatter<'_>) -> pub struct myc::MycWrappingCredential(_) impl core::fmt::Debug for myc::MycWrappingCredential pub fn myc::MycWrappingCredential::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub const myc::MYC_ADMIN_OPERATION_COMPLETED_LIMIT: u16 +pub const myc::MYC_ADMIN_OPERATION_DEFAULT_RETENTION_MS: u64 +pub const myc::MYC_ADMIN_OPERATION_ID_MAX_BYTES: usize +pub const myc::MYC_ADMIN_OPERATION_MAX_RETENTION_MS: u64 +pub const myc::MYC_ADMIN_OPERATION_MIN_RETENTION_MS: u64 +pub const myc::MYC_ADMIN_OPERATION_PREPARED_LIMIT: u8 +pub const myc::MYC_ADMIN_OPERATION_RESPONSE_ENVELOPE_MAX_UTF8_BYTES: u32 +pub const myc::MYC_ADMIN_OPERATION_RESPONSE_MODEL_MAX_BYTES: usize pub const myc::MYC_AUDIT_PAGE_MAX_ITEMS: u16 pub const myc::MYC_AUDIT_RETENTION_MAX_MS: u64 pub const myc::MYC_AUTHORIZATION_CHALLENGE_URL_MAX_BYTES: usize @@ -2092,6 +2143,9 @@ pub const myc::MYC_STATE_SCHEMA_VERSION: u32 pub const myc::MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256: [u8; 32] pub const myc::MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT: u32 pub const myc::MYC_STATE_SCHEMA_VERSION_10_SHA256: [u8; 32] +pub const myc::MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256: [u8; 32] +pub const myc::MYC_STATE_SCHEMA_VERSION_11_OBJECT_COUNT: u32 +pub const myc::MYC_STATE_SCHEMA_VERSION_11_SHA256: [u8; 32] pub const myc::MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 pub const myc::MYC_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] pub const myc::MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256: [u8; 32] diff --git a/contracts/services_hardening/config.v1.schema.json b/contracts/services_hardening/config.v1.schema.json @@ -362,7 +362,7 @@ "header_count": { "type": "integer", "minimum": 1, "maximum": 64, "default": 32, "x-radroots-default-source": "radroots_service_host" }, "header_bytes": { "type": "integer", "minimum": 1, "maximum": 32768, "default": 16384, "x-radroots-default-source": "radroots_service_host" }, "request_body_utf8_bytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 65536, "x-radroots-default-source": "radroots_service_host" }, - "response_body_utf8_bytes": { "type": "integer", "minimum": 1, "maximum": 1048576, "default": 1048576, "x-radroots-default-source": "radroots_service_host" }, + "response_body_utf8_bytes": { "type": "integer", "minimum": 8382, "maximum": 1048576, "default": 1048576, "x-radroots-default-source": "radroots_service_host" }, "concurrent_connections": { "type": "integer", "minimum": 1, "maximum": 64, "default": 32, "x-radroots-default-source": "radroots_service_host" }, "request_deadline_ms": { "type": "integer", "minimum": 1, "maximum": 30000, "default": 15000, "x-radroots-default-source": "radroots_service_host" }, "idle_timeout_ms": { "type": "integer", "minimum": 1, "maximum": 60000, "default": 30000, "x-radroots-default-source": "radroots_service_host" }, diff --git a/contracts/services_hardening/native_release.v1.json b/contracts/services_hardening/native_release.v1.json @@ -32,7 +32,7 @@ }, "contract_versions": { "config": 1, - "state": 10, + "state": 11, "admin": 1, "status": 1, "provider": 1 diff --git a/radroots.service.source-lock.v2.toml b/radroots.service.source-lock.v2.toml @@ -18,7 +18,7 @@ flake_lock_sha256 = "90a03f6f0794f3f6556b1f2bf6700812d48ce8dc1cee7c4f8bc62cea69b [contract_versions] config = 1 -state = 10 +state = 11 admin = 1 status = 1 provider = 1 diff --git a/src/admin_v1.rs b/src/admin_v1.rs @@ -274,11 +274,35 @@ impl MycAdminRequestDocument { .map(|(_, value)| value.as_ref()) } + pub(crate) fn parameter_binding(&self) -> Option<(&'static str, &str)> { + self.parameter + .as_ref() + .map(|(name, value)| (*name, value.as_ref())) + } + /// Returns compact canonical JSON for the route's exact request model. #[must_use] pub fn model_bytes(&self) -> &[u8] { &self.model_bytes } + + #[cfg(test)] + pub(crate) fn mutation_for_test( + route: MycAdminRoute, + operation_id: &str, + parameter: Option<(&'static str, &str)>, + model_bytes: &[u8], + ) -> Self { + assert!(route.is_mutation()); + Self { + route, + operation_id: Some(AdminOperationId::new(operation_id).expect("test operation ID")), + correlation_id: AdminCorrelationId::new("test-correlation") + .expect("test correlation ID"), + parameter: parameter.map(|(name, value)| (name, value.into())), + model_bytes: model_bytes.into(), + } + } } impl fmt::Debug for MycAdminRequestDocument { diff --git a/src/lib.rs b/src/lib.rs @@ -27,6 +27,8 @@ mod provider_verification; mod runtime_context; mod runtime_foundation; mod runtime_supervision; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod state_admin; mod state_catalog; mod state_completion; mod state_config; @@ -148,6 +150,16 @@ pub use runtime_supervision::{ MycCriticalTaskError, MycRuntimeSupervisionError, MycRuntimeSupervisionErrorKind, MycSupervisedRuntime, MycTaskCancellation, }; +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub use state_admin::{ + MYC_ADMIN_OPERATION_COMPLETED_LIMIT, MYC_ADMIN_OPERATION_DEFAULT_RETENTION_MS, + MYC_ADMIN_OPERATION_ID_MAX_BYTES, MYC_ADMIN_OPERATION_MAX_RETENTION_MS, + MYC_ADMIN_OPERATION_MIN_RETENTION_MS, MYC_ADMIN_OPERATION_PREPARED_LIMIT, + MYC_ADMIN_OPERATION_RESPONSE_ENVELOPE_MAX_UTF8_BYTES, + MYC_ADMIN_OPERATION_RESPONSE_MODEL_MAX_BYTES, MycAdminOperationAdmission, + MycAdminOperationCompletion, MycAdminOperationError, MycAdminOperationErrorKind, + MycAdminOperationJournalPolicy, MycAdminOperationTimeUnixMs, MycPreparedAdminOperation, +}; pub use state_catalog::{ MYC_MIGRATION_CATALOG_SHA256, MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_CATALOG_SHA256, MYC_STATE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT, @@ -165,8 +177,9 @@ pub use state_catalog::{ MYC_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_9_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_9_SHA256, MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_10_SHA256, - MycStateCatalogError, MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog, - validate_myc_state_catalogs, + MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_11_OBJECT_COUNT, + MYC_STATE_SCHEMA_VERSION_11_SHA256, MycStateCatalogError, MycStateCatalogErrorKind, + myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs, }; pub use state_completion::{ MycNip46CommitAdmission, MycNip46CommitError, MycNip46CommitErrorKind, MycNip46CommitRecord, diff --git a/src/state_admin.rs b/src/state_admin.rs @@ -0,0 +1,1328 @@ +//! Bounded durable idempotency for permissioned Myc admin mutations. + +use core::fmt; +use std::error::Error; + +use radroots_service_sqlite::{ + ServiceSqliteTransaction, ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind, +}; +use sha2::{Digest, Sha256}; +use sqlx::Row; + +use crate::{ + MycAdminRequestDocument, MycAdminResponseDocument, MycAdminRoute, MycStateRepository, + state_repository::{PersistedMetadata, RepositoryOperationError, require_expected_metadata}, +}; + +/// Maximum encoded length of a durable admin operation identifier. +pub const MYC_ADMIN_OPERATION_ID_MAX_BYTES: usize = 128; +/// Maximum canonical response-model bytes retained for replay. +pub const MYC_ADMIN_OPERATION_RESPONSE_MODEL_MAX_BYTES: usize = 8_192; +/// Maximum encoded success envelope for an 8,192-byte model and 128-byte correlation ID. +pub const MYC_ADMIN_OPERATION_RESPONSE_ENVELOPE_MAX_UTF8_BYTES: u32 = 8_382; +/// Maximum retained completed operations after expiry pruning. +pub const MYC_ADMIN_OPERATION_COMPLETED_LIMIT: u16 = 4_096; +/// Maximum retained operations whose external outcome is unresolved. +pub const MYC_ADMIN_OPERATION_PREPARED_LIMIT: u8 = 128; +/// Minimum configurable completed-response retention. +pub const MYC_ADMIN_OPERATION_MIN_RETENTION_MS: u64 = 1; +/// Maximum configurable completed-response retention. +pub const MYC_ADMIN_OPERATION_MAX_RETENTION_MS: u64 = 31_536_000_000; +/// Frozen seven-day completed-response retention. +pub const MYC_ADMIN_OPERATION_DEFAULT_RETENTION_MS: u64 = 604_800_000; + +const REQUEST_DIGEST_DOMAIN: &[u8] = b"radroots.myc.admin_operation_request.v1\0"; +const PRUNE_LIMIT: i64 = 4_096; + +const PRUNE_EXPIRED_SQL: &str = r#"DELETE FROM myc_admin_operations +WHERE operation_id IN ( + SELECT operation_id FROM myc_admin_operations + WHERE state = 'completed' AND expires_at_unix_ms <= ? + ORDER BY expires_at_unix_ms, operation_id + LIMIT ? +)"#; + +const READ_OPERATION_SQL: &str = r#"SELECT + CASE WHEN typeof(route) = 'text' AND length(CAST(route AS BLOB)) BETWEEN 1 AND 128 + THEN route ELSE NULL END AS route, + CASE WHEN typeof(request_sha256) = 'blob' AND length(request_sha256) = 32 + THEN request_sha256 ELSE NULL END AS request_sha256, + CASE WHEN typeof(state) = 'text' AND length(CAST(state AS BLOB)) <= 16 + THEN state ELSE NULL END AS state, + CASE WHEN typeof(response_model) = 'blob' AND length(response_model) BETWEEN 1 AND 8192 + THEN response_model ELSE NULL END AS response_model, + typeof(response_model) AS response_model_type, + CASE WHEN typeof(response_sha256) = 'blob' AND length(response_sha256) = 32 + THEN response_sha256 ELSE NULL END AS response_sha256, + typeof(response_sha256) AS response_sha256_type, + prepared_at_unix_ms, + completed_at_unix_ms, + typeof(completed_at_unix_ms) AS completed_at_type, + expires_at_unix_ms, + typeof(expires_at_unix_ms) AS expires_at_type +FROM myc_admin_operations +WHERE operation_id = ? +LIMIT 2"#; + +const READ_COUNTS_SQL: &str = r#"SELECT + COUNT(CASE WHEN state = 'completed' THEN 1 END) AS completed_count, + COUNT(CASE WHEN state = 'prepared' THEN 1 END) AS prepared_count +FROM myc_admin_operations"#; + +const INSERT_PREPARED_SQL: &str = r#"INSERT INTO myc_admin_operations ( + operation_id, route, request_sha256, state, prepared_at_unix_ms +) VALUES (?, ?, ?, 'prepared', ?)"#; + +const COMPLETE_OPERATION_SQL: &str = r#"UPDATE myc_admin_operations +SET state = 'completed', response_model = ?, response_sha256 = ?, + completed_at_unix_ms = ?, expires_at_unix_ms = ? +WHERE operation_id = ? AND state = 'prepared'"#; + +/// Stable source-free admin-journal failure classes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycAdminOperationErrorKind { + InvalidMode, + InvalidInput, + OperationConflict, + OperationOutcomeUnknown, + ResourceExhausted, + Binding, + Transaction, + CommitOutcomeUnknown, +} + +impl MycAdminOperationErrorKind { + /// Returns the stable machine-readable code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidMode => "admin_operation_mode_invalid", + Self::InvalidInput => "admin_operation_input_invalid", + Self::OperationConflict => "operation_conflict", + Self::OperationOutcomeUnknown => "operation_outcome_unknown", + Self::ResourceExhausted => "resource_exhausted", + Self::Binding => "admin_operation_binding_invalid", + Self::Transaction => "admin_operation_transaction_failed", + Self::CommitOutcomeUnknown => "admin_operation_commit_outcome_unknown", + } + } +} + +/// Redacted admin-journal error. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycAdminOperationError { + kind: MycAdminOperationErrorKind, +} + +impl MycAdminOperationError { + const fn new(kind: MycAdminOperationErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> MycAdminOperationErrorKind { + self.kind + } + + /// Returns the stable machine-readable code. + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Display for MycAdminOperationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + MycAdminOperationErrorKind::InvalidMode => { + "Myc admin operation requires writable state" + } + MycAdminOperationErrorKind::InvalidInput => "Myc admin operation input is invalid", + MycAdminOperationErrorKind::OperationConflict => { + "Myc admin operation identity conflicts with retained evidence" + } + MycAdminOperationErrorKind::OperationOutcomeUnknown => { + "Myc admin operation outcome is unknown" + } + MycAdminOperationErrorKind::ResourceExhausted => { + "Myc admin operation capacity is exhausted" + } + MycAdminOperationErrorKind::Binding => "Myc admin operation journal binding is invalid", + MycAdminOperationErrorKind::Transaction => "Myc admin operation transaction failed", + MycAdminOperationErrorKind::CommitOutcomeUnknown => { + "Myc admin operation commit outcome is unknown" + } + }) + } +} + +impl fmt::Debug for MycAdminOperationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycAdminOperationError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for MycAdminOperationError {} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct AdminOperationIdBinding(Box<str>); + +impl AdminOperationIdBinding { + fn new(value: &str) -> Result<Self, MycAdminOperationError> { + let bytes = value.as_bytes(); + let valid = !bytes.is_empty() + && bytes.len() <= MYC_ADMIN_OPERATION_ID_MAX_BYTES + && bytes[0].is_ascii_alphanumeric() + && bytes.iter().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-') + }); + valid + .then(|| Self(value.into())) + .ok_or_else(|| MycAdminOperationError::new(MycAdminOperationErrorKind::InvalidInput)) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for AdminOperationIdBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AdminOperationIdBinding([redacted])") + } +} + +/// Injected UTC millisecond evidence representable by SQLite. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MycAdminOperationTimeUnixMs(u64); + +impl MycAdminOperationTimeUnixMs { + /// Validates one UTC millisecond instant without reading ambient time. + pub fn new(value: u64) -> Result<Self, MycAdminOperationError> { + i64::try_from(value) + .map(|_| Self(value)) + .map_err(|_| MycAdminOperationError::new(MycAdminOperationErrorKind::InvalidInput)) + } + + /// Returns the validated instant. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } + + fn sqlite_value(self) -> i64 { + i64::try_from(self.0).expect("validated admin operation time fits SQLite") + } +} + +/// Explicit bounded completed-response retention policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MycAdminOperationJournalPolicy { + completed_retention_ms: u64, +} + +impl MycAdminOperationJournalPolicy { + /// Admits the frozen inclusive retention range. + pub fn new(completed_retention_ms: u64) -> Result<Self, MycAdminOperationError> { + (MYC_ADMIN_OPERATION_MIN_RETENTION_MS..=MYC_ADMIN_OPERATION_MAX_RETENTION_MS) + .contains(&completed_retention_ms) + .then_some(Self { + completed_retention_ms, + }) + .ok_or_else(|| MycAdminOperationError::new(MycAdminOperationErrorKind::InvalidInput)) + } + + /// Returns the exact seven-day policy. + #[must_use] + pub const fn seven_days() -> Self { + Self { + completed_retention_ms: MYC_ADMIN_OPERATION_DEFAULT_RETENTION_MS, + } + } + + /// Returns the admitted retention duration. + #[must_use] + pub const fn completed_retention_ms(self) -> u64 { + self.completed_retention_ms + } +} + +/// Sealed evidence that one external or cross-resource mutation is unresolved. +pub struct MycPreparedAdminOperation { + operation_id: AdminOperationIdBinding, + route: MycAdminRoute, + request_sha256: [u8; 32], + prepared_at: MycAdminOperationTimeUnixMs, +} + +impl fmt::Debug for MycPreparedAdminOperation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycPreparedAdminOperation") + .field("route", &self.route) + .field("identity", &"[redacted]") + .finish() + } +} + +/// Result of mutation admission after bounded expiry pruning. +pub enum MycAdminOperationAdmission { + Prepared(MycPreparedAdminOperation), + ExactReplay(MycAdminResponseDocument), +} + +impl fmt::Debug for MycAdminOperationAdmission { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Prepared(_) => "MycAdminOperationAdmission::Prepared([redacted])", + Self::ExactReplay(_) => "MycAdminOperationAdmission::ExactReplay([redacted])", + }) + } +} + +/// Result of completing a previously prepared operation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycAdminOperationCompletion { + Completed, + ExactReplay, +} + +impl MycStateRepository<'_> { + /// Prunes a bounded expired prefix and admits or replays one mutation. + pub async fn prepare_admin_operation( + &self, + request: &MycAdminRequestDocument, + observed_at: MycAdminOperationTimeUnixMs, + ) -> Result<MycAdminOperationAdmission, MycAdminOperationError> { + if !self.is_writable() { + return Err(MycAdminOperationError::new( + MycAdminOperationErrorKind::InvalidMode, + )); + } + let binding = AdminRequestBinding::from_document(request)?; + let expected = PersistedMetadata::from(self.expected()); + self.host() + .transaction(move |transaction| { + Box::pin(async move { + require_expected_metadata(transaction, &expected) + .await + .map_err(AdminJournalOperationError::from)?; + prepare_operation(transaction, &binding, observed_at).await + }) + }) + .await + .map_err(map_transaction_error) + } + + /// Completes one external mutation only after its durable effect exists. + pub async fn complete_admin_operation( + &self, + prepared: &MycPreparedAdminOperation, + response: &MycAdminResponseDocument, + completed_at: MycAdminOperationTimeUnixMs, + policy: MycAdminOperationJournalPolicy, + ) -> Result<MycAdminOperationCompletion, MycAdminOperationError> { + if !self.is_writable() { + return Err(MycAdminOperationError::new( + MycAdminOperationErrorKind::InvalidMode, + )); + } + if response.route() != prepared.route + || response.canonical_bytes().is_empty() + || response.canonical_bytes().len() > MYC_ADMIN_OPERATION_RESPONSE_MODEL_MAX_BYTES + || completed_at < prepared.prepared_at + { + return Err(MycAdminOperationError::new( + MycAdminOperationErrorKind::InvalidInput, + )); + } + let expires_at = completed_at + .get() + .checked_add(policy.completed_retention_ms()) + .filter(|value| i64::try_from(*value).is_ok()) + .ok_or_else(|| MycAdminOperationError::new(MycAdminOperationErrorKind::InvalidInput))?; + let binding = PreparedBinding::from_prepared(prepared); + let response = response.canonical_bytes().to_vec().into_boxed_slice(); + let expected = PersistedMetadata::from(self.expected()); + self.host() + .transaction(move |transaction| { + Box::pin(async move { + require_expected_metadata(transaction, &expected) + .await + .map_err(AdminJournalOperationError::from)?; + complete_operation(transaction, &binding, &response, completed_at, expires_at) + .await + }) + }) + .await + .map_err(map_transaction_error) + } +} + +struct AdminRequestBinding { + operation_id: AdminOperationIdBinding, + route: MycAdminRoute, + request_sha256: [u8; 32], +} + +impl AdminRequestBinding { + fn from_document(request: &MycAdminRequestDocument) -> Result<Self, MycAdminOperationError> { + if !request.route().is_mutation() { + return Err(MycAdminOperationError::new( + MycAdminOperationErrorKind::InvalidInput, + )); + } + let operation_id = request + .operation_id() + .ok_or_else(|| MycAdminOperationError::new(MycAdminOperationErrorKind::InvalidInput))?; + Ok(Self { + operation_id: AdminOperationIdBinding::new(operation_id)?, + route: request.route(), + request_sha256: request_digest(request), + }) + } +} + +struct PreparedBinding { + operation_id: AdminOperationIdBinding, + route: MycAdminRoute, + request_sha256: [u8; 32], + prepared_at: MycAdminOperationTimeUnixMs, +} + +impl PreparedBinding { + fn from_prepared(prepared: &MycPreparedAdminOperation) -> Self { + Self { + operation_id: prepared.operation_id.clone(), + route: prepared.route, + request_sha256: prepared.request_sha256, + prepared_at: prepared.prepared_at, + } + } +} + +enum StoredOperation { + Prepared { + route: MycAdminRoute, + request_sha256: [u8; 32], + prepared_at: MycAdminOperationTimeUnixMs, + }, + Completed { + route: MycAdminRoute, + request_sha256: [u8; 32], + response: Box<[u8]>, + response_sha256: [u8; 32], + prepared_at: MycAdminOperationTimeUnixMs, + completed_at: MycAdminOperationTimeUnixMs, + expires_at: MycAdminOperationTimeUnixMs, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AdminJournalOperationError { + InvalidInput, + Conflict, + OutcomeUnknown, + ResourceExhausted, + Binding, + Storage, +} + +impl From<RepositoryOperationError> for AdminJournalOperationError { + fn from(error: RepositoryOperationError) -> Self { + match error { + RepositoryOperationError::Binding => Self::Binding, + RepositoryOperationError::Storage => Self::Storage, + } + } +} + +async fn prepare_operation( + transaction: &mut ServiceSqliteTransaction<'_>, + binding: &AdminRequestBinding, + observed_at: MycAdminOperationTimeUnixMs, +) -> Result<MycAdminOperationAdmission, AdminJournalOperationError> { + prune_expired(transaction, observed_at).await?; + if let Some(existing) = read_operation(transaction, &binding.operation_id).await? { + return match existing { + StoredOperation::Prepared { + route, + request_sha256, + .. + } if route == binding.route && request_sha256 == binding.request_sha256 => { + Err(AdminJournalOperationError::OutcomeUnknown) + } + StoredOperation::Completed { + route, + request_sha256, + response, + response_sha256, + .. + } if route == binding.route && request_sha256 == binding.request_sha256 => { + if sha256(&response) != response_sha256 { + return Err(AdminJournalOperationError::Binding); + } + MycAdminResponseDocument::from_canonical_bytes(route, &response) + .map(MycAdminOperationAdmission::ExactReplay) + .map_err(|_| AdminJournalOperationError::Binding) + } + StoredOperation::Prepared { .. } | StoredOperation::Completed { .. } => { + Err(AdminJournalOperationError::Conflict) + } + }; + } + let (completed, prepared) = read_counts(transaction).await?; + let reserved = completed + .checked_add(prepared) + .ok_or(AdminJournalOperationError::Binding)?; + if reserved >= u64::from(MYC_ADMIN_OPERATION_COMPLETED_LIMIT) + || prepared >= u64::from(MYC_ADMIN_OPERATION_PREPARED_LIMIT) + { + return Err(AdminJournalOperationError::ResourceExhausted); + } + let result = sqlx::query(INSERT_PREPARED_SQL) + .bind(binding.operation_id.as_str()) + .bind(binding.route.operation_id()) + .bind(binding.request_sha256.as_slice()) + .bind(observed_at.sqlite_value()) + .execute(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + require_one(result.rows_affected())?; + match read_operation(transaction, &binding.operation_id).await? { + Some(StoredOperation::Prepared { + route, + request_sha256, + prepared_at, + }) if route == binding.route + && request_sha256 == binding.request_sha256 + && prepared_at == observed_at => + { + Ok(MycAdminOperationAdmission::Prepared( + MycPreparedAdminOperation { + operation_id: binding.operation_id.clone(), + route, + request_sha256, + prepared_at, + }, + )) + } + Some(_) | None => Err(AdminJournalOperationError::Binding), + } +} + +async fn complete_operation( + transaction: &mut ServiceSqliteTransaction<'_>, + binding: &PreparedBinding, + response: &[u8], + completed_at: MycAdminOperationTimeUnixMs, + expires_at: u64, +) -> Result<MycAdminOperationCompletion, AdminJournalOperationError> { + let response_sha256 = sha256(response); + match read_operation(transaction, &binding.operation_id).await? { + Some(StoredOperation::Completed { + route, + request_sha256, + response: existing_response, + response_sha256: existing_sha256, + .. + }) if route == binding.route + && request_sha256 == binding.request_sha256 + && existing_response.as_ref() == response + && existing_sha256 == response_sha256 => + { + return Ok(MycAdminOperationCompletion::ExactReplay); + } + Some(StoredOperation::Completed { .. }) => { + return Err(AdminJournalOperationError::Conflict); + } + Some(StoredOperation::Prepared { + route, + request_sha256, + prepared_at, + }) if route == binding.route + && request_sha256 == binding.request_sha256 + && prepared_at == binding.prepared_at => {} + Some(StoredOperation::Prepared { .. }) => { + return Err(AdminJournalOperationError::Conflict); + } + None => return Err(AdminJournalOperationError::Binding), + } + let (completed, _) = read_counts(transaction).await?; + if completed >= u64::from(MYC_ADMIN_OPERATION_COMPLETED_LIMIT) { + return Err(AdminJournalOperationError::ResourceExhausted); + } + let result = sqlx::query(COMPLETE_OPERATION_SQL) + .bind(response) + .bind(response_sha256.as_slice()) + .bind(completed_at.sqlite_value()) + .bind(i64::try_from(expires_at).map_err(|_| AdminJournalOperationError::InvalidInput)?) + .bind(binding.operation_id.as_str()) + .execute(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + require_one(result.rows_affected())?; + match read_operation(transaction, &binding.operation_id).await? { + Some(StoredOperation::Completed { + route, + request_sha256, + response: actual_response, + response_sha256: actual_sha256, + prepared_at, + completed_at: actual_completed_at, + expires_at: actual_expires_at, + }) if route == binding.route + && request_sha256 == binding.request_sha256 + && actual_response.as_ref() == response + && actual_sha256 == response_sha256 + && prepared_at == binding.prepared_at + && actual_completed_at == completed_at + && actual_expires_at.get() == expires_at => + { + Ok(MycAdminOperationCompletion::Completed) + } + Some(_) | None => Err(AdminJournalOperationError::Binding), + } +} + +async fn prune_expired( + transaction: &mut ServiceSqliteTransaction<'_>, + observed_at: MycAdminOperationTimeUnixMs, +) -> Result<(), AdminJournalOperationError> { + sqlx::query(PRUNE_EXPIRED_SQL) + .bind(observed_at.sqlite_value()) + .bind(PRUNE_LIMIT) + .execute(&mut *transaction) + .await + .map(|_| ()) + .map_err(|_| AdminJournalOperationError::Storage) +} + +async fn read_counts( + transaction: &mut ServiceSqliteTransaction<'_>, +) -> Result<(u64, u64), AdminJournalOperationError> { + let rows = sqlx::query(READ_COUNTS_SQL) + .fetch_all(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + if rows.len() != 1 { + return Err(AdminJournalOperationError::Binding); + } + let completed = rows[0] + .try_get::<i64, _>("completed_count") + .map_err(|_| AdminJournalOperationError::Binding)?; + let prepared = rows[0] + .try_get::<i64, _>("prepared_count") + .map_err(|_| AdminJournalOperationError::Binding)?; + Ok(( + u64::try_from(completed).map_err(|_| AdminJournalOperationError::Binding)?, + u64::try_from(prepared).map_err(|_| AdminJournalOperationError::Binding)?, + )) +} + +async fn read_operation( + transaction: &mut ServiceSqliteTransaction<'_>, + operation_id: &AdminOperationIdBinding, +) -> Result<Option<StoredOperation>, AdminJournalOperationError> { + let rows = sqlx::query(READ_OPERATION_SQL) + .bind(operation_id.as_str()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| AdminJournalOperationError::Storage)?; + if rows.len() > 1 { + return Err(AdminJournalOperationError::Binding); + } + rows.first().map(decode_operation).transpose() +} + +fn decode_operation( + row: &sqlx::sqlite::SqliteRow, +) -> Result<StoredOperation, AdminJournalOperationError> { + let route = row + .try_get::<Option<&str>, _>("route") + .map_err(|_| AdminJournalOperationError::Binding)? + .and_then(parse_route) + .ok_or(AdminJournalOperationError::Binding)?; + let request_sha256 = exact_digest(row, "request_sha256")?; + let state = row + .try_get::<Option<&str>, _>("state") + .map_err(|_| AdminJournalOperationError::Binding)? + .ok_or(AdminJournalOperationError::Binding)?; + let prepared_at = time(row, "prepared_at_unix_ms")?; + match state { + "prepared" => { + require_null(row, "response_model_type")?; + require_null(row, "response_sha256_type")?; + require_null(row, "completed_at_type")?; + require_null(row, "expires_at_type")?; + Ok(StoredOperation::Prepared { + route, + request_sha256, + prepared_at, + }) + } + "completed" => { + require_type(row, "response_model_type", "blob")?; + require_type(row, "response_sha256_type", "blob")?; + require_type(row, "completed_at_type", "integer")?; + require_type(row, "expires_at_type", "integer")?; + let response = row + .try_get::<Option<Vec<u8>>, _>("response_model") + .map_err(|_| AdminJournalOperationError::Binding)? + .ok_or(AdminJournalOperationError::Binding)? + .into_boxed_slice(); + Ok(StoredOperation::Completed { + route, + request_sha256, + response, + response_sha256: exact_digest(row, "response_sha256")?, + prepared_at, + completed_at: time(row, "completed_at_unix_ms")?, + expires_at: time(row, "expires_at_unix_ms")?, + }) + } + _ => Err(AdminJournalOperationError::Binding), + } +} + +fn request_digest(request: &MycAdminRequestDocument) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(REQUEST_DIGEST_DOMAIN); + hash_field(&mut hasher, request.route().operation_id().as_bytes()); + match request.parameter_binding() { + Some((name, value)) => { + hasher.update([1]); + hash_field(&mut hasher, name.as_bytes()); + hash_field(&mut hasher, value.as_bytes()); + } + None => hasher.update([0]), + } + hash_field(&mut hasher, request.model_bytes()); + hasher.finalize().into() +} + +fn hash_field(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update( + u64::try_from(bytes.len()) + .expect("bounded field length") + .to_be_bytes(), + ); + hasher.update(bytes); +} + +fn sha256(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +fn parse_route(value: &str) -> Option<MycAdminRoute> { + MycAdminRoute::ALL + .into_iter() + .find(|route| route.is_mutation() && route.operation_id() == value) +} + +fn exact_digest( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<[u8; 32], AdminJournalOperationError> { + row.try_get::<Option<Vec<u8>>, _>(column) + .map_err(|_| AdminJournalOperationError::Binding)? + .ok_or(AdminJournalOperationError::Binding)? + .try_into() + .map_err(|_| AdminJournalOperationError::Binding) +} + +fn time( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<MycAdminOperationTimeUnixMs, AdminJournalOperationError> { + let value = row + .try_get::<i64, _>(column) + .map_err(|_| AdminJournalOperationError::Binding)?; + MycAdminOperationTimeUnixMs::new( + u64::try_from(value).map_err(|_| AdminJournalOperationError::Binding)?, + ) + .map_err(|_| AdminJournalOperationError::Binding) +} + +fn require_null( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<(), AdminJournalOperationError> { + require_type(row, column, "null") +} + +fn require_type( + row: &sqlx::sqlite::SqliteRow, + column: &str, + expected: &str, +) -> Result<(), AdminJournalOperationError> { + (row.try_get::<&str, _>(column) + .map_err(|_| AdminJournalOperationError::Binding)? + == expected) + .then_some(()) + .ok_or(AdminJournalOperationError::Binding) +} + +fn require_one(rows: u64) -> Result<(), AdminJournalOperationError> { + (rows == 1) + .then_some(()) + .ok_or(AdminJournalOperationError::Storage) +} + +fn map_transaction_error( + error: ServiceSqliteTransactionError<AdminJournalOperationError>, +) -> MycAdminOperationError { + if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown { + return MycAdminOperationError::new(MycAdminOperationErrorKind::CommitOutcomeUnknown); + } + let kind = match error.operation_error() { + Some(AdminJournalOperationError::InvalidInput) => MycAdminOperationErrorKind::InvalidInput, + Some(AdminJournalOperationError::Conflict) => MycAdminOperationErrorKind::OperationConflict, + Some(AdminJournalOperationError::OutcomeUnknown) => { + MycAdminOperationErrorKind::OperationOutcomeUnknown + } + Some(AdminJournalOperationError::ResourceExhausted) => { + MycAdminOperationErrorKind::ResourceExhausted + } + Some(AdminJournalOperationError::Binding) => MycAdminOperationErrorKind::Binding, + Some(AdminJournalOperationError::Storage) | None => MycAdminOperationErrorKind::Transaction, + }; + MycAdminOperationError::new(kind) +} + +#[cfg(test)] +mod tests { + use std::{fs, os::unix::fs::PermissionsExt, path::Path}; + + use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; + use radroots_storage::event::SourceGeneration; + + use super::*; + use crate::{ + MycConfigProfile, MycRuntimeContext, MycStateHost, MycStateMetadata, + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, initialize_myc_state, + open_myc_state_read_write, parse_myc_cli_v1_from, parse_myc_config_v1, + resolve_myc_runtime_context, + }; + + const CONFIG: &[u8] = include_bytes!("../contracts/services_hardening/config.v1.example.toml"); + + fn runtime(root: &Path) -> MycRuntimeContext { + let invocation = parse_myc_cli_v1_from([ + "myc", + "--profile", + "repo-local", + "--instance", + "primary", + "--repo-local-root", + root.to_str().expect("UTF-8 root"), + "run", + ]) + .expect("invocation"); + resolve_myc_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + &invocation, + ) + .expect("runtime") + } + + fn migration_build() -> MigrationBuildIdentity { + MigrationBuildIdentity::new( + env!("CARGO_PKG_VERSION"), + "1111111111111111111111111111111111111111", + "7d7b454b4c9ed86569671993bd03ca868b676665", + "rustc-test", + "test-target", + "service-host", + 1, + crate::MYC_STATE_SCHEMA_VERSION, + 1, + 1, + 1, + ) + .expect("build") + } + + async fn fixture() -> ( + tempfile::TempDir, + MycRuntimeContext, + MycStateMetadata, + MycStateHost, + ) { + 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 configuration = + parse_myc_config_v1(CONFIG, MycConfigProfile::RepoLocal).expect("configuration"); + let metadata = MycStateMetadata::new( + &runtime, + &configuration, + SourceGeneration::new([0x5a; 32]).expect("generation"), + 1_725_000_000_000, + ) + .expect("metadata"); + let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("time"); + let build = migration_build(); + initialize_myc_state(&runtime, &metadata, applied_at, &build) + .await + .expect("initialize"); + let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("open"); + (directory, runtime, metadata, host) + } + + fn request( + operation_id: &str, + connection_id: &str, + generation: u64, + ) -> MycAdminRequestDocument { + let model = format!( + "{{\"confirmation\":\"approve\",\"expected_generation\":{generation},\"permissions\":\"nip04_decrypt\"}}" + ); + MycAdminRequestDocument::mutation_for_test( + MycAdminRoute::ConnectionApprove, + operation_id, + Some(("connection_id", connection_id)), + model.as_bytes(), + ) + } + + fn response_document(operation_id: &str, generation: u64) -> MycAdminResponseDocument { + let model = format!( + "{{\"connection_id\":\"connection-1\",\"current_state\":\"approved\",\"generation\":{generation},\"operation_id\":\"{operation_id}\",\"previous_state\":\"pending\"}}" + ); + MycAdminResponseDocument::from_canonical_bytes( + MycAdminRoute::ConnectionApprove, + model.as_bytes(), + ) + .expect("response") + } + + #[test] + fn identifier_policy_time_and_public_diagnostics_are_bounded_and_redacted() { + for valid in [ + "a", + "A0._:-z", + &"x".repeat(MYC_ADMIN_OPERATION_ID_MAX_BYTES), + ] { + assert!(AdminOperationIdBinding::new(valid).is_ok(), "{valid}"); + } + for invalid in ["", "-first", "space value", "slash/value", "é"] { + assert!(AdminOperationIdBinding::new(invalid).is_err(), "{invalid}"); + } + assert!( + AdminOperationIdBinding::new(&"x".repeat(MYC_ADMIN_OPERATION_ID_MAX_BYTES + 1)) + .is_err() + ); + let identifier = AdminOperationIdBinding::new("protected-operation").expect("ID"); + assert_eq!( + format!("{identifier:?}"), + "AdminOperationIdBinding([redacted])" + ); + + assert!(MycAdminOperationJournalPolicy::new(0).is_err()); + assert!(MycAdminOperationJournalPolicy::new(MYC_ADMIN_OPERATION_MIN_RETENTION_MS).is_ok()); + assert!(MycAdminOperationJournalPolicy::new(MYC_ADMIN_OPERATION_MAX_RETENTION_MS).is_ok()); + assert!( + MycAdminOperationJournalPolicy::new(MYC_ADMIN_OPERATION_MAX_RETENTION_MS + 1).is_err() + ); + assert_eq!( + MycAdminOperationJournalPolicy::seven_days().completed_retention_ms(), + MYC_ADMIN_OPERATION_DEFAULT_RETENTION_MS + ); + assert!(MycAdminOperationTimeUnixMs::new(i64::MAX as u64).is_ok()); + assert!(MycAdminOperationTimeUnixMs::new(i64::MAX as u64 + 1).is_err()); + assert_eq!(PRUNE_LIMIT, i64::from(MYC_ADMIN_OPERATION_COMPLETED_LIMIT)); + + for kind in [ + MycAdminOperationErrorKind::InvalidMode, + MycAdminOperationErrorKind::InvalidInput, + MycAdminOperationErrorKind::OperationConflict, + MycAdminOperationErrorKind::OperationOutcomeUnknown, + MycAdminOperationErrorKind::ResourceExhausted, + MycAdminOperationErrorKind::Binding, + MycAdminOperationErrorKind::Transaction, + MycAdminOperationErrorKind::CommitOutcomeUnknown, + ] { + let error = MycAdminOperationError::new(kind); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains("protected-operation")); + assert!(!rendered.contains("/tmp/secret")); + assert!(Error::source(&error).is_none()); + } + } + + #[test] + fn request_digest_binds_route_parameter_and_canonical_model_without_retaining_them() { + let first = request("digest-1", "connection-1", 1); + let same = request("digest-1", "connection-1", 1); + let changed_parameter = request("digest-1", "connection-2", 1); + let changed_model = request("digest-1", "connection-1", 2); + assert_eq!(request_digest(&first), request_digest(&same)); + assert_ne!(request_digest(&first), request_digest(&changed_parameter)); + assert_ne!(request_digest(&first), request_digest(&changed_model)); + } + + #[tokio::test] + async fn prepare_complete_replay_conflict_and_expiry_are_exact() { + let (_directory, _runtime, _metadata, host) = fixture().await; + let repository = host.repository(); + let first = request("journal-1", "connection-1", 1); + let prepared = match repository + .prepare_admin_operation(&first, MycAdminOperationTimeUnixMs::new(10).unwrap()) + .await + .expect("prepare") + { + MycAdminOperationAdmission::Prepared(prepared) => prepared, + MycAdminOperationAdmission::ExactReplay(_) => panic!("unexpected replay"), + }; + let same_prepared = repository + .prepare_admin_operation(&first, MycAdminOperationTimeUnixMs::new(10).unwrap()) + .await + .expect_err("retained Prepared is ambiguous"); + assert_eq!( + same_prepared.kind(), + MycAdminOperationErrorKind::OperationOutcomeUnknown + ); + let changed = request("journal-1", "connection-2", 1); + assert_eq!( + repository + .prepare_admin_operation(&changed, MycAdminOperationTimeUnixMs::new(10).unwrap()) + .await + .expect_err("path binding conflict") + .kind(), + MycAdminOperationErrorKind::OperationConflict + ); + + let response = response_document("journal-1", 1); + assert_eq!( + repository + .complete_admin_operation( + &prepared, + &response, + MycAdminOperationTimeUnixMs::new(20).unwrap(), + MycAdminOperationJournalPolicy::new(2).unwrap(), + ) + .await + .expect("complete"), + MycAdminOperationCompletion::Completed + ); + assert_eq!( + repository + .complete_admin_operation( + &prepared, + &response, + MycAdminOperationTimeUnixMs::new(21).unwrap(), + MycAdminOperationJournalPolicy::new(2).unwrap(), + ) + .await + .expect("idempotent completion"), + MycAdminOperationCompletion::ExactReplay + ); + let different_response = response_document("journal-1", 2); + assert_eq!( + repository + .complete_admin_operation( + &prepared, + &different_response, + MycAdminOperationTimeUnixMs::new(21).unwrap(), + MycAdminOperationJournalPolicy::new(2).unwrap(), + ) + .await + .expect_err("different completion conflicts") + .kind(), + MycAdminOperationErrorKind::OperationConflict + ); + + match repository + .prepare_admin_operation(&first, MycAdminOperationTimeUnixMs::new(21).unwrap()) + .await + .expect("replay before expiry") + { + MycAdminOperationAdmission::ExactReplay(replayed) => { + assert_eq!(replayed.canonical_bytes(), response.canonical_bytes()); + } + MycAdminOperationAdmission::Prepared(_) => panic!("unexpected prepare"), + } + assert!(matches!( + repository + .prepare_admin_operation(&first, MycAdminOperationTimeUnixMs::new(22).unwrap()) + .await + .expect("exact expiry prunes before admission"), + MycAdminOperationAdmission::Prepared(_) + )); + host.close().await.expect("close"); + } + + #[tokio::test] + async fn prepared_backup_shape_is_ambiguous_and_persists_no_path_or_request_content() { + let (_directory, _runtime, _metadata, host) = fixture().await; + let request = MycAdminRequestDocument::mutation_for_test( + MycAdminRoute::StateBackup, + "backup-1", + None, + br#"{"destination":"/tmp/never-store-this"}"#, + ); + let repository = host.repository(); + assert!(matches!( + repository + .prepare_admin_operation(&request, MycAdminOperationTimeUnixMs::new(30).unwrap()) + .await + .expect("prepare backup"), + MycAdminOperationAdmission::Prepared(_) + )); + assert_eq!( + repository + .prepare_admin_operation(&request, MycAdminOperationTimeUnixMs::new(31).unwrap()) + .await + .expect_err("backup outcome remains unknown") + .kind(), + MycAdminOperationErrorKind::OperationOutcomeUnknown + ); + let row = repository + .host() + .transaction(|transaction| { + Box::pin(async move { + sqlx::query( + "SELECT route, state, response_model, request_sha256, \ + (SELECT group_concat(name, ',') FROM pragma_table_info('myc_admin_operations')) \ + AS columns FROM myc_admin_operations WHERE operation_id = 'backup-1'", + ) + .fetch_one(&mut *transaction) + .await + }) + }) + .await + .expect("inspect journal"); + assert_eq!( + row.get::<String, _>("route"), + MycAdminRoute::StateBackup.operation_id() + ); + assert_eq!(row.get::<String, _>("state"), "prepared"); + assert!(row.get::<Option<Vec<u8>>, _>("response_model").is_none()); + assert_eq!(row.get::<Vec<u8>, _>("request_sha256").len(), 32); + let columns = row.get::<String, _>("columns"); + for forbidden in [ + "path", + "body", + "correlation", + "credential", + "secret", + "bundle", + ] { + assert!(!columns.contains(forbidden), "{columns}"); + } + host.close().await.expect("close"); + } + + #[tokio::test] + async fn exact_completed_and_prepared_caps_fail_closed_after_bounded_pruning() { + let (_directory, _runtime, _metadata, host) = fixture().await; + let repository = host.repository(); + repository + .host() + .transaction(|transaction| { + Box::pin(async move { + let response = b"{}"; + let digest = sha256(response); + for index in 0..(MYC_ADMIN_OPERATION_COMPLETED_LIMIT - 1) { + sqlx::query( + "INSERT INTO myc_admin_operations (operation_id, route, \ + request_sha256, state, response_model, response_sha256, \ + prepared_at_unix_ms, completed_at_unix_ms, expires_at_unix_ms) \ + VALUES (?, ?, ?, 'completed', ?, ?, 1, 1, ?)", + ) + .bind(format!("completed-{index}")) + .bind(MycAdminRoute::ConnectionApprove.operation_id()) + .bind([0x11; 32].as_slice()) + .bind(response.as_slice()) + .bind(digest.as_slice()) + .bind(i64::MAX) + .execute(&mut *transaction) + .await?; + } + Ok::<_, sqlx::Error>(()) + }) + }) + .await + .expect("seed completed capacity"); + let reserved = match repository + .prepare_admin_operation( + &request("reserved-completion", "connection-1", 1), + MycAdminOperationTimeUnixMs::new(2).unwrap(), + ) + .await + .expect("reserve final completed slot") + { + MycAdminOperationAdmission::Prepared(prepared) => prepared, + MycAdminOperationAdmission::ExactReplay(_) => panic!("unexpected replay"), + }; + assert_eq!( + repository + .prepare_admin_operation( + &request("new-completed", "connection-1", 1), + MycAdminOperationTimeUnixMs::new(2).unwrap(), + ) + .await + .expect_err("completed cap") + .kind(), + MycAdminOperationErrorKind::ResourceExhausted + ); + assert_eq!( + repository + .complete_admin_operation( + &reserved, + &response_document("reserved-completion", 1), + MycAdminOperationTimeUnixMs::new(3).unwrap(), + MycAdminOperationJournalPolicy::seven_days(), + ) + .await + .expect("consume reserved completed slot"), + MycAdminOperationCompletion::Completed + ); + assert_eq!( + repository + .prepare_admin_operation( + &request("completed-cap", "connection-1", 1), + MycAdminOperationTimeUnixMs::new(4).unwrap(), + ) + .await + .expect_err("completed cap remains closed") + .kind(), + MycAdminOperationErrorKind::ResourceExhausted + ); + host.close().await.expect("close completed fixture"); + + let (_directory, _runtime, _metadata, host) = fixture().await; + let repository = host.repository(); + repository + .host() + .transaction(|transaction| { + Box::pin(async move { + for index in 0..MYC_ADMIN_OPERATION_PREPARED_LIMIT { + sqlx::query( + "INSERT INTO myc_admin_operations (operation_id, route, \ + request_sha256, state, prepared_at_unix_ms) \ + VALUES (?, ?, ?, 'prepared', 1)", + ) + .bind(format!("prepared-{index}")) + .bind(MycAdminRoute::StateBackup.operation_id()) + .bind([0x22; 32].as_slice()) + .execute(&mut *transaction) + .await?; + } + Ok::<_, sqlx::Error>(()) + }) + }) + .await + .expect("seed prepared capacity"); + assert_eq!( + repository + .prepare_admin_operation( + &request("new-prepared", "connection-1", 1), + MycAdminOperationTimeUnixMs::new(2).unwrap(), + ) + .await + .expect_err("prepared cap") + .kind(), + MycAdminOperationErrorKind::ResourceExhausted + ); + host.close().await.expect("close prepared fixture"); + } + + #[tokio::test] + async fn schema_admits_exact_response_model_cap_and_rejects_one_byte_over() { + let (_directory, _runtime, _metadata, host) = fixture().await; + let repository = host.repository(); + let exact = vec![b'x'; MYC_ADMIN_OPERATION_RESPONSE_MODEL_MAX_BYTES]; + let exact_digest = sha256(&exact); + repository + .host() + .transaction(|transaction| { + Box::pin(async move { + sqlx::query( + "INSERT INTO myc_admin_operations (operation_id, route, \ + request_sha256, state, response_model, response_sha256, \ + prepared_at_unix_ms, completed_at_unix_ms, expires_at_unix_ms) \ + VALUES ('response-exact', ?, ?, 'completed', ?, ?, 1, 1, 2)", + ) + .bind(MycAdminRoute::ConnectionApprove.operation_id()) + .bind([0x33; 32].as_slice()) + .bind(exact) + .bind(exact_digest.as_slice()) + .execute(&mut *transaction) + .await + .map(|_| ()) + }) + }) + .await + .expect("exact response cap"); + + let excessive = vec![b'x'; MYC_ADMIN_OPERATION_RESPONSE_MODEL_MAX_BYTES + 1]; + let excessive_digest = sha256(&excessive); + assert!( + repository + .host() + .transaction(|transaction| { + Box::pin(async move { + sqlx::query( + "INSERT INTO myc_admin_operations (operation_id, route, \ + request_sha256, state, response_model, response_sha256, \ + prepared_at_unix_ms, completed_at_unix_ms, expires_at_unix_ms) \ + VALUES ('response-excessive', ?, ?, 'completed', ?, ?, 1, 1, 2)", + ) + .bind(MycAdminRoute::ConnectionApprove.operation_id()) + .bind([0x44; 32].as_slice()) + .bind(excessive) + .bind(excessive_digest.as_slice()) + .execute(&mut *transaction) + .await + .map(|_| ()) + }) + }) + .await + .is_err() + ); + host.close().await.expect("close"); + } + + #[test] + fn schema_and_source_freeze_response_and_pruning_bounds() { + const SOURCE: &str = include_str!("state_admin.rs"); + const CATALOG: &str = include_str!("state_catalog.rs"); + let production = SOURCE + .split("#[cfg(test)]") + .next() + .expect("production source"); + assert!(SOURCE.contains("length(response_model) BETWEEN 1 AND 8192")); + assert!(SOURCE.contains("LIMIT ?")); + assert!(CATALOG.contains("length(response_model) BETWEEN 1 AND 8192")); + assert!(CATALOG.contains("CHECK (length(request_sha256) = 32)")); + assert!(!CATALOG.contains("bundle_path")); + assert!(!production.contains("correlation_id")); + assert!(parse_route(MycAdminRoute::Status.operation_id()).is_none()); + assert_eq!( + parse_route(MycAdminRoute::StateBackup.operation_id()), + Some(MycAdminRoute::StateBackup) + ); + let maximum_envelope = br#"{"contract_version":1,"ok":true,"correlation_id":""#.len() + + radroots_service_host::ADMIN_CORRELATION_ID_MAX_UTF8_BYTES + + br#"","result":"#.len() + + MYC_ADMIN_OPERATION_RESPONSE_MODEL_MAX_BYTES + + 1; + assert_eq!( + maximum_envelope, + MYC_ADMIN_OPERATION_RESPONSE_ENVELOPE_MAX_UTF8_BYTES as usize + ); + } +} diff --git a/src/state_catalog.rs b/src/state_catalog.rs @@ -12,7 +12,7 @@ use radroots_service_sqlite::{ pub const MYC_STATE_BASE_SCHEMA_VERSION: u32 = 1; /// The newest governed Myc state schema understood by this binary. -pub const MYC_STATE_SCHEMA_VERSION: u32 = 10; +pub const MYC_STATE_SCHEMA_VERSION: u32 = 11; /// The shared metadata and migration-ledger objects present at schema v1. pub const MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6; @@ -44,6 +44,9 @@ pub const MYC_STATE_SCHEMA_VERSION_9_OBJECT_COUNT: u32 = 59; /// The shared objects plus the append-only configuration-binding history. pub const MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT: u32 = 63; +/// The shared objects plus the bounded admin-operation journal. +pub const MYC_STATE_SCHEMA_VERSION_11_OBJECT_COUNT: u32 = 65; + /// SHA-256 identity of the exact schema-v1 object snapshot. pub const MYC_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] = [ 0x94, 0xdc, 0x66, 0xfb, 0xca, 0x60, 0x16, 0x79, 0x61, 0x5c, 0x05, 0x52, 0x29, 0xdc, 0x0d, 0xb6, @@ -58,14 +61,14 @@ pub const MYC_STATE_SCHEMA_VERSION_2_SHA256: [u8; 32] = [ /// SHA-256 identity of the ordered Myc migration catalog. pub const MYC_MIGRATION_CATALOG_SHA256: [u8; 32] = [ - 0x5d, 0x6e, 0x0c, 0x8b, 0x83, 0x2e, 0x66, 0x71, 0x5a, 0xbe, 0x17, 0x61, 0x33, 0xd4, 0x43, 0x3d, - 0x52, 0xe6, 0xd1, 0x81, 0x25, 0xae, 0xfd, 0xbc, 0x9d, 0x55, 0x05, 0x15, 0xfd, 0x21, 0x21, 0xf2, + 0x1a, 0xa7, 0x0a, 0x76, 0xb0, 0x47, 0x4f, 0x9b, 0xb0, 0x33, 0x00, 0xf0, 0xe8, 0x64, 0x54, 0xb2, + 0x86, 0x3b, 0x45, 0x74, 0x51, 0xed, 0xd9, 0xa2, 0xcc, 0xed, 0x97, 0xba, 0x31, 0xcb, 0x8c, 0xc1, ]; /// SHA-256 identity of the schema catalog bound to the migration catalog. pub const MYC_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [ - 0x1f, 0x88, 0xc7, 0x9f, 0x86, 0xae, 0x47, 0x24, 0x89, 0xf6, 0x2d, 0xdc, 0x96, 0x61, 0xa6, 0x81, - 0xdc, 0xfb, 0x1e, 0xd7, 0xff, 0x72, 0x3a, 0x07, 0xd9, 0x7b, 0xa7, 0x76, 0xb0, 0x36, 0xa2, 0x5a, + 0x09, 0xec, 0x0c, 0x13, 0x2f, 0xbc, 0x1a, 0x8a, 0x46, 0xad, 0x34, 0x03, 0x49, 0x78, 0x93, 0x83, + 0x4a, 0xbf, 0x73, 0x52, 0x00, 0x5e, 0xb6, 0x27, 0x2c, 0x7a, 0x45, 0x28, 0xdd, 0x5d, 0x66, 0x1a, ]; /// SHA-256 identity of the schema-v2 migration content. @@ -170,6 +173,18 @@ pub const MYC_STATE_SCHEMA_VERSION_10_SHA256: [u8; 32] = [ 0x87, 0xc3, 0x44, 0x62, 0x65, 0x8f, 0xc4, 0x9f, 0xb7, 0xb2, 0xfb, 0xc8, 0x90, 0x9b, 0xa3, 0x03, ]; +/// SHA-256 identity of the schema-v11 admin-operation journal migration. +pub const MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256: [u8; 32] = [ + 0x16, 0x38, 0x53, 0x68, 0xaa, 0x4e, 0xe4, 0x0e, 0xa7, 0x00, 0x2a, 0x0a, 0xb4, 0x26, 0x45, 0xbc, + 0x68, 0xb5, 0x46, 0xa4, 0xba, 0x6a, 0xfd, 0xfe, 0xde, 0x56, 0x5f, 0xe0, 0x26, 0x57, 0x6c, 0x96, +]; + +/// SHA-256 identity of the schema-v11 object snapshot. +pub const MYC_STATE_SCHEMA_VERSION_11_SHA256: [u8; 32] = [ + 0x0d, 0xe7, 0xfe, 0x17, 0x6e, 0xa7, 0xda, 0x60, 0x30, 0x42, 0x4a, 0xdd, 0xc2, 0x9b, 0x9a, 0x91, + 0x36, 0x3e, 0x88, 0xb0, 0x4d, 0xc7, 0x8d, 0xda, 0xb4, 0x80, 0xfd, 0x0f, 0x0a, 0xf9, 0x4e, 0x5a, +]; + /// SHA-256 identity of the Myc metadata table definition. const MYC_STATE_METADATA_TABLE_SHA256: [u8; 32] = [ 0x16, 0x17, 0x46, 0xa2, 0x26, 0x42, 0x46, 0x2f, 0x2b, 0xdb, 0x08, 0x5b, 0xae, 0xde, 0xb2, 0x3b, @@ -1826,6 +1841,72 @@ const CREATE_MYC_CONFIG_BINDINGS_MIGRATION_SQL: &str = concat!( ";", ); +macro_rules! myc_admin_operations_table_sql { + () => { + r#"CREATE TABLE myc_admin_operations ( + operation_id TEXT NOT NULL PRIMARY KEY + CHECK (length(CAST(operation_id AS BLOB)) BETWEEN 1 AND 128) + CHECK (substr(operation_id, 1, 1) GLOB '[A-Za-z0-9]') + CHECK (operation_id NOT GLOB '*[^A-Za-z0-9._:-]*'), + route TEXT NOT NULL CHECK (length(CAST(route AS BLOB)) BETWEEN 1 AND 128), + request_sha256 BLOB NOT NULL CHECK (length(request_sha256) = 32), + state TEXT NOT NULL CHECK (state IN ('prepared', 'completed')), + response_model BLOB CHECK (response_model IS NULL OR + length(response_model) BETWEEN 1 AND 8192), + response_sha256 BLOB CHECK (response_sha256 IS NULL OR + length(response_sha256) = 32), + prepared_at_unix_ms INTEGER NOT NULL + CHECK (prepared_at_unix_ms BETWEEN 0 AND 9223372036854775807), + completed_at_unix_ms INTEGER + CHECK (completed_at_unix_ms IS NULL OR + completed_at_unix_ms BETWEEN prepared_at_unix_ms AND 9223372036854775807), + expires_at_unix_ms INTEGER + CHECK (expires_at_unix_ms IS NULL OR + expires_at_unix_ms BETWEEN completed_at_unix_ms AND 9223372036854775807), + CHECK ((state = 'prepared' AND response_model IS NULL + AND response_sha256 IS NULL AND completed_at_unix_ms IS NULL + AND expires_at_unix_ms IS NULL) + OR (state = 'completed' AND response_model IS NOT NULL + AND response_sha256 IS NOT NULL AND completed_at_unix_ms IS NOT NULL + AND expires_at_unix_ms IS NOT NULL)) +) STRICT"# + }; +} + +macro_rules! myc_admin_operations_guard_update_sql { + () => { + r#"CREATE TRIGGER myc_admin_operations_guard_update +BEFORE UPDATE ON myc_admin_operations +WHEN OLD.state != 'prepared' OR NEW.state != 'completed' + OR NEW.operation_id != OLD.operation_id OR NEW.route != OLD.route + OR NEW.request_sha256 != OLD.request_sha256 + OR NEW.prepared_at_unix_ms != OLD.prepared_at_unix_ms + OR NEW.response_model IS NULL OR NEW.response_sha256 IS NULL + OR NEW.completed_at_unix_ms IS NULL OR NEW.expires_at_unix_ms IS NULL +BEGIN + SELECT RAISE(ABORT, 'admin operation transition is invalid'); +END"# + }; +} + +const CREATE_MYC_ADMIN_OPERATIONS_TABLE_SQL: &str = myc_admin_operations_table_sql!(); +const CREATE_MYC_ADMIN_OPERATIONS_GUARD_UPDATE_SQL: &str = myc_admin_operations_guard_update_sql!(); +const CREATE_MYC_ADMIN_OPERATIONS_MIGRATION_SQL: &str = concat!( + myc_admin_operations_table_sql!(), + ";\n", + myc_admin_operations_guard_update_sql!(), + ";", +); + +const MYC_ADMIN_OPERATIONS_TABLE_SHA256: [u8; 32] = [ + 0x22, 0xd6, 0xbc, 0xb4, 0x15, 0xac, 0x9a, 0xf2, 0x4e, 0x41, 0x61, 0xac, 0x2a, 0x8c, 0xae, 0xfb, + 0xfb, 0x7a, 0xf0, 0xa4, 0xfe, 0xae, 0xe9, 0xe6, 0xdf, 0x36, 0x67, 0x14, 0x24, 0x5b, 0xf3, 0xf1, +]; +const MYC_ADMIN_OPERATIONS_GUARD_UPDATE_SHA256: [u8; 32] = [ + 0xb9, 0x68, 0x2d, 0x07, 0xca, 0x59, 0x91, 0x3b, 0x66, 0x09, 0xdc, 0x73, 0x61, 0xc5, 0xe0, 0xee, + 0x22, 0x52, 0x4f, 0x51, 0x2c, 0xbc, 0xe2, 0x8a, 0x7a, 0x8f, 0xe0, 0xd5, 0x2c, 0xfd, 0x45, 0xf6, +]; + const MYC_CONFIG_BINDINGS_TABLE_SHA256: [u8; 32] = [ 0xf7, 0x7a, 0x0b, 0xc4, 0x4a, 0xb0, 0xf9, 0x18, 0x09, 0xed, 0x6a, 0xb1, 0xaa, 0x0c, 0x9e, 0xd8, 0x80, 0x4c, 0xac, 0x8f, 0x12, 0x15, 0xf1, 0x15, 0xd5, 0x7e, 0x1b, 0x42, 0xe4, 0x20, 0xf2, 0x60, @@ -2198,6 +2279,13 @@ pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError> MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256), ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; + let admin_operations = MigrationDescriptor::sql( + 11, + "create_admin_operation_journal", + CREATE_MYC_ADMIN_OPERATIONS_MIGRATION_SQL, + MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; let catalog = MigrationCatalog::new([ metadata, requests, @@ -2208,10 +2296,11 @@ pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError> completion, response, configuration, + admin_operations, ]) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; if catalog.current_version() != MYC_STATE_SCHEMA_VERSION - || catalog.descriptors().len() != 9 + || catalog.descriptors().len() != 10 || catalog.digest().as_bytes() != &MYC_MIGRATION_CATALOG_SHA256 { return Err(MycStateCatalogError::new( @@ -2284,6 +2373,12 @@ pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> { SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_10_SHA256), ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; + let version_eleven = SchemaVersionCatalog::new( + 11, + myc_state_admin_operation_objects()?, + SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_11_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; let catalog = SchemaCatalog::new( &migrations, [ @@ -2297,6 +2392,7 @@ pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> { version_eight, version_nine, version_ten, + version_eleven, ], ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; @@ -2893,6 +2989,35 @@ fn myc_state_config_binding_objects() -> Result<Vec<SchemaObject>, MycStateCatal Ok(objects) } +fn myc_state_admin_operation_objects() -> Result<Vec<SchemaObject>, MycStateCatalogError> { + let mut objects = myc_state_config_binding_objects()?; + let object = |kind, name, sql, digest| { + SchemaObject::new( + kind, + name, + "myc_admin_operations", + sql, + SchemaDigest::from_bytes(digest), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog)) + }; + objects.extend([ + object( + SchemaObjectKind::Table, + "myc_admin_operations", + CREATE_MYC_ADMIN_OPERATIONS_TABLE_SQL, + MYC_ADMIN_OPERATIONS_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "myc_admin_operations_guard_update", + CREATE_MYC_ADMIN_OPERATIONS_GUARD_UPDATE_SQL, + MYC_ADMIN_OPERATIONS_GUARD_UPDATE_SHA256, + )?, + ]); + Ok(objects) +} + /// Independently validates exact catalog versions, counts, and digests. pub fn validate_myc_state_catalogs( migrations: &MigrationCatalog, @@ -2901,7 +3026,7 @@ pub fn validate_myc_state_catalogs( let versions = schema.versions(); let descriptors = migrations.descriptors(); let valid = migrations.current_version() == MYC_STATE_SCHEMA_VERSION - && descriptors.len() == 9 + && descriptors.len() == 10 && descriptors[0].target_version() == 2 && descriptors[0].name().as_str() == "create_myc_state_metadata" && descriptors[0].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256 @@ -2929,9 +3054,12 @@ pub fn validate_myc_state_catalogs( && descriptors[8].target_version() == 10 && descriptors[8].name().as_str() == "create_configuration_binding_history" && descriptors[8].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256 + && descriptors[9].target_version() == 11 + && descriptors[9].name().as_str() == "create_admin_operation_journal" + && descriptors[9].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256 && migrations.digest().as_bytes() == &MYC_MIGRATION_CATALOG_SHA256 && schema.migration_catalog_digest() == migrations.digest() - && versions.len() == 10 + && versions.len() == 11 && versions[0].version() == MYC_STATE_BASE_SCHEMA_VERSION && versions[0].object_count() == MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT && versions[0].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_1_SHA256 @@ -2962,6 +3090,9 @@ pub fn validate_myc_state_catalogs( && versions[9].version() == 10 && versions[9].object_count() == MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT && versions[9].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_10_SHA256 + && versions[10].version() == 11 + && versions[10].object_count() == MYC_STATE_SCHEMA_VERSION_11_OBJECT_COUNT + && versions[10].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_11_SHA256 && schema.digest().as_bytes() == &MYC_STATE_SCHEMA_CATALOG_SHA256; if valid { Ok(()) diff --git a/src/state_host.rs b/src/state_host.rs @@ -381,22 +381,23 @@ fn require_migration_build( fn exact_initialization_outcome(outcome: MigrationApplicationOutcome) -> bool { outcome.initial_version() == MYC_STATE_BASE_SCHEMA_VERSION && outcome.final_version() == MYC_STATE_SCHEMA_VERSION - && outcome.applied_count() == 9 + && outcome.applied_count() == 10 } fn exact_existing_outcome(outcome: MigrationApplicationOutcome) -> bool { outcome.final_version() == MYC_STATE_SCHEMA_VERSION && matches!( (outcome.initial_version(), outcome.applied_count()), - (MYC_STATE_BASE_SCHEMA_VERSION, 9) - | (2, 8) - | (3, 7) - | (4, 6) - | (5, 5) - | (6, 4) - | (7, 3) - | (8, 2) - | (9, 1) + (MYC_STATE_BASE_SCHEMA_VERSION, 10) + | (2, 9) + | (3, 8) + | (4, 7) + | (5, 6) + | (6, 5) + | (7, 4) + | (8, 3) + | (9, 2) + | (10, 1) | (MYC_STATE_SCHEMA_VERSION, 0) ) } diff --git a/src/state_repository.rs b/src/state_repository.rs @@ -103,13 +103,13 @@ const INSERT_INITIAL_CONFIG_BINDING_SQL: &str = r#"INSERT INTO myc_config_bindin ) SELECT 1, metadata.normalized_config_sha256, metadata.transport_public_key, metadata.user_public_key, metadata.discovery_public_key, - metadata.config_contract_version, 10, + metadata.config_contract_version, ?, metadata.operator_contract_version, metadata.status_contract_version, migration.applied_at_unix_s, migration.service_version, migration.service_commit, migration.lib_revision, migration.rust_version, migration.target, migration.feature_profile, migration.provider_contract_version FROM myc_state_metadata AS metadata -JOIN schema_migrations AS migration ON migration.version = 10 +JOIN schema_migrations AS migration ON migration.version = ? WHERE metadata.singleton = 1"#; /// Stable failure classes for typed Myc state-repository operations. @@ -251,7 +251,7 @@ impl<'host> MycStateRepository<'host> { insert_initial_config_binding(transaction).await?; } match read_latest_config_binding(transaction).await? { - Some(actual) if actual == expected => Ok(()), + Some(actual) if actual.matches_current_configuration(&expected) => Ok(()), Some(_) | None => Err(RepositoryOperationError::Binding), } }) @@ -304,7 +304,19 @@ impl PersistedMetadata { fn same_contracts(&self, other: &Self) -> bool { self.config_contract_version == other.config_contract_version - && matches!(self.state_contract_version, 9 | 10) + && (9..=MYC_STATE_SCHEMA_VERSION).contains(&self.state_contract_version) + && other.state_contract_version == MYC_STATE_SCHEMA_VERSION + && self.operator_contract_version == other.operator_contract_version + && self.status_contract_version == other.status_contract_version + } + + pub(crate) fn matches_current_configuration(&self, other: &Self) -> bool { + self.normalized_config_sha256 == other.normalized_config_sha256 + && self.transport_public_key == other.transport_public_key + && self.user_public_key == other.user_public_key + && self.discovery_public_key == other.discovery_public_key + && self.config_contract_version == other.config_contract_version + && (10..=MYC_STATE_SCHEMA_VERSION).contains(&self.state_contract_version) && other.state_contract_version == MYC_STATE_SCHEMA_VERSION && self.operator_contract_version == other.operator_contract_version && self.status_contract_version == other.status_contract_version @@ -339,7 +351,7 @@ pub(crate) async fn require_expected_metadata( expected: &PersistedMetadata, ) -> Result<(), RepositoryOperationError> { match read_latest_config_binding(transaction).await? { - Some(actual) if actual == *expected => Ok(()), + Some(actual) if actual.matches_current_configuration(expected) => Ok(()), Some(_) | None => Err(RepositoryOperationError::Binding), } } @@ -465,6 +477,8 @@ async fn insert_initial_config_binding( transaction: &mut ServiceSqliteTransaction<'_>, ) -> Result<(), RepositoryOperationError> { let result = sqlx::query(INSERT_INITIAL_CONFIG_BINDING_SQL) + .bind(i64::from(MYC_STATE_SCHEMA_VERSION)) + .bind(i64::from(MYC_STATE_SCHEMA_VERSION)) .execute(&mut *transaction) .await .map_err(|_| RepositoryOperationError::Storage)?; diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -89,6 +89,6 @@ fn source_lock_binds_the_current_cargo_lock() { "source_archive_sha256 = \"b425371c134be96cce46b37f7035d6212f1efe8cff50bef366631ba5632991b0\"" )); assert!(SOURCE_LOCK.ends_with( - "[contract_versions]\nconfig = 1\nstate = 10\nadmin = 1\nstatus = 1\nprovider = 1\n" + "[contract_versions]\nconfig = 1\nstate = 11\nadmin = 1\nstatus = 1\nprovider = 1\n" )); } diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -70,6 +70,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/runtime_supervision.rs"), include_str!("../src/status_v1.rs"), include_str!("../src/state_catalog.rs"), + include_str!("../src/state_admin.rs"), include_str!("../src/state_completion.rs"), include_str!("../src/state_config.rs"), include_str!("../src/state_connection.rs"), @@ -117,6 +118,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "runtime_supervision", "status_v1", "state_catalog", + "state_admin", "state_completion", "state_config", "state_connection", @@ -145,6 +147,11 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "Schema v10 adds an append-only configuration-binding history capped at exactly\n1,024 generations", "Exact replay returns the retained generation without another\nappend or revocation", "Future startup\nmust present the latest normalized config and public-identity binding", + "Schema v11 adds the bounded admin-operation journal", + "at most 128 unresolved Prepared records and 4,096 completed responses", + "caps a\nreplayed response model at 8,192 bytes", + "admits at least 8,382 UTF-8 bytes", + "The journal stores no request body, path,\ncorrelation ID, credential, bundle path, or secret", ] { assert!(README.contains(required), "README is missing `{required}`"); } @@ -216,6 +223,16 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "pub struct myc::MycNip46WorkError", "pub struct myc::MycStateHost", "pub struct myc::MycStateRepository", + "pub struct myc::MycPreparedAdminOperation", + "pub enum myc::MycAdminOperationAdmission", + "pub enum myc::MycAdminOperationCompletion", + "pub struct myc::MycAdminOperationJournalPolicy", + "pub struct myc::MycAdminOperationTimeUnixMs", + "pub struct myc::MycAdminOperationError", + "pub enum myc::MycAdminOperationErrorKind", + "pub const myc::MYC_ADMIN_OPERATION_RESPONSE_ENVELOPE_MAX_UTF8_BYTES: u32", + "pub async fn myc::MycStateRepository<'_>::prepare_admin_operation", + "pub async fn myc::MycStateRepository<'_>::complete_admin_operation", "pub const myc::MYC_CONFIG_BINDING_MAX_GENERATIONS: u16", "pub struct myc::MycConfigApplyOutcome", "pub struct myc::MycConfigApplyError", @@ -287,6 +304,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "runtime_supervision", "status_v1", "state_catalog", + "state_admin", "state_completion", "state_config", "state_connection", @@ -985,9 +1003,10 @@ fn public_errors_remain_crate_owned_redacted_and_source_free() { .lines() .filter(|line| line.starts_with("pub struct myc::") && line.ends_with("Error")) .count(); - assert_eq!(public_error_count, 33); + assert_eq!(public_error_count, 34); assert!(PUBLIC_API.contains("pub struct myc::MycDoctorError")); assert!(PUBLIC_API.contains("pub struct myc::MycConfigApplyError")); + assert!(PUBLIC_API.contains("pub struct myc::MycAdminOperationError")); assert!(!PUBLIC_API.contains("pub struct myc::MycRuntimeFoundation {")); assert!(!PUBLIC_API.contains("pub struct myc::MycStateHost {")); } diff --git a/tests/services_hardening_config_contract.rs b/tests/services_hardening_config_contract.rs @@ -383,6 +383,10 @@ fn lib_derived_limits_and_defaults_are_literal_frozen() { schema["$defs"]["operations_limits"]["properties"]["header_bytes"]["minimum"], 8_192 ); + assert_eq!( + schema["$defs"]["admin_limits"]["properties"]["response_body_utf8_bytes"]["minimum"], + 8_382 + ); let exact = [ ("/$defs/operations_limits/properties/header_count", 64, 32), ( @@ -650,6 +654,14 @@ fn bounds_relationships_and_conditional_authority_fail_closed() { assert_rejected(&excessive, Profile::Production); } + let mut exact_admin_response = value.clone(); + exact_admin_response["resource_limits"]["admin"]["response_body_utf8_bytes"] = json!(8_382); + assert!(semantic_valid(&exact_admin_response, Profile::Production)); + let mut undersized_admin_response = value.clone(); + undersized_admin_response["resource_limits"]["admin"]["response_body_utf8_bytes"] = + json!(8_381); + assert_rejected(&undersized_admin_response, Profile::Production); + let mut overlap = value.clone(); overlap["policy"]["denied_clients"] = overlap["policy"]["trusted_clients"].clone(); assert_rejected(&overlap, Profile::Production); diff --git a/tests/services_hardening_config_lifecycle.rs b/tests/services_hardening_config_lifecycle.rs @@ -195,6 +195,105 @@ async fn initialize_v9(runtime: &myc::MycRuntimeContext, metadata: &MycStateMeta host.close().await.expect("v9 close"); } +async fn initialize_v10(runtime: &myc::MycRuntimeContext, metadata: &MycStateMetadata) { + let full_migrations = myc::myc_migration_catalog().expect("full migrations"); + let migrations = MigrationCatalog::new(full_migrations.descriptors()[..9].iter().cloned()) + .expect("v10 migrations"); + let full_schema = myc::myc_schema_catalog().expect("full schema"); + let schema = SchemaCatalog::new(&migrations, full_schema.versions()[..10].iter().copied()) + .expect("v10 schema"); + let paths = ServiceSqlitePaths::from_runtime_context(runtime.context()).expect("paths"); + let initial = metadata.initial_database_metadata(); + let identity = ServiceDatabaseIdentity::new( + &paths, + initial.source_generation(), + NonZeroU32::new(10).unwrap(), + initial.application_id(), + ); + let authority = initialize_database( + &paths, + OpenMode::Initialize, + initial, + &schema, + |path: PathBuf| async move { + let connection = sqlx::SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false) + .disable_statement_logging(), + ) + .await + .map_err(|_| TestInitializationError)?; + connection + .close() + .await + .map_err(|_| TestInitializationError) + }, + ) + .await + .expect("v10 initialize"); + let (host, outcome) = ServiceSqliteHost::open_initialized( + &paths, + &identity, + &migrations, + &schema, + ServiceSqliteConnectionOptions::reviewed(), + authority, + MigrationAppliedAtUnixSeconds::new(1_725_000_000).unwrap(), + &build_for_schema(10), + &[], + ) + .await + .expect("v10 migrations"); + assert_eq!(outcome.final_version(), 10); + assert_eq!(outcome.applied_count(), 9); + + let digest = *metadata.configuration_digest().as_bytes(); + let identities = metadata.expected_identities(); + let transport: Box<str> = identities.transport().as_hex().into(); + let user: Box<str> = identities.user().as_hex().into(); + let discovery: Option<Box<str>> = identities.discovery().map(|value| value.as_hex().into()); + let versions = metadata.policy_versions(); + host.transaction(move |transaction| { + Box::pin(async move { + sqlx::query( + "INSERT INTO myc_state_metadata (singleton, normalized_config_sha256, \ + transport_public_key, user_public_key, discovery_public_key, \ + config_contract_version, state_contract_version, operator_contract_version, \ + status_contract_version) VALUES (1, ?, ?, ?, ?, ?, 10, ?, ?)", + ) + .bind(digest.as_slice()) + .bind(transport.as_ref()) + .bind(user.as_ref()) + .bind(discovery.as_deref()) + .bind(i64::from(versions.configuration())) + .bind(i64::from(versions.operator())) + .bind(i64::from(versions.status())) + .execute(&mut *transaction) + .await?; + sqlx::query( + "INSERT INTO myc_config_bindings (generation, normalized_config_sha256, \ + transport_public_key, user_public_key, discovery_public_key, \ + config_contract_version, state_contract_version, operator_contract_version, \ + status_contract_version, applied_at_unix_s, service_version, service_commit, \ + lib_revision, rust_version, target, feature_profile, provider_contract_version) \ + SELECT 1, normalized_config_sha256, transport_public_key, user_public_key, \ + discovery_public_key, config_contract_version, 10, operator_contract_version, \ + status_contract_version, 1725000000, '0.1.0', \ + '1111111111111111111111111111111111111111', \ + '7d7b454b4c9ed86569671993bd03ca868b676665', 'rustc-test', 'test-target', \ + 'service-host', 1 FROM myc_state_metadata WHERE singleton = 1", + ) + .execute(&mut *transaction) + .await + .map(|_| ()) + }) + }) + .await + .expect("v10 Myc binding"); + host.close().await.expect("v10 close"); +} + async fn initialize(runtime: &myc::MycRuntimeContext, metadata: &MycStateMetadata) { initialize_myc_state( runtime, @@ -377,7 +476,7 @@ async fn offline_apply_appends_one_generation_and_rebinds_future_startup() { } #[tokio::test] -async fn v9_upgrade_seeds_one_v10_binding_without_rewriting_birth_evidence() { +async fn v9_upgrade_seeds_one_current_binding_without_rewriting_birth_evidence() { let directory = tempfile::tempdir().expect("root"); let runtime = runtime(directory.path()); prepare(&runtime); @@ -392,7 +491,7 @@ async fn v9_upgrade_seeds_one_v10_binding_without_rewriting_birth_evidence() { &build(), ) .await - .expect("upgrade to v10"); + .expect("upgrade to current schema"); writer.close().await.expect("close upgraded writer"); let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime)) @@ -413,7 +512,10 @@ async fn v9_upgrade_seeds_one_v10_binding_without_rewriting_birth_evidence() { .await .expect("seed binding"); assert_eq!(binding.get::<i64, _>("generation"), 1); - assert_eq!(binding.get::<i64, _>("state_contract_version"), 10); + assert_eq!( + binding.get::<i64, _>("state_contract_version"), + i64::from(myc::MYC_STATE_SCHEMA_VERSION) + ); assert_eq!(binding.get::<i64, _>("applied_at_unix_s"), 1_725_000_010); assert_eq!( binding.get::<Vec<u8>, _>("normalized_config_sha256"), @@ -423,6 +525,70 @@ async fn v9_upgrade_seeds_one_v10_binding_without_rewriting_birth_evidence() { } #[tokio::test] +async fn v10_binding_remains_valid_historical_evidence_after_v11_migration() { + let directory = tempfile::tempdir().expect("root"); + let runtime = runtime(directory.path()); + prepare(&runtime); + let current = configuration(CONFIG); + let current_metadata = metadata(&runtime, &current); + initialize_v10(&runtime, &current_metadata).await; + + let writer = open_myc_state_read_write( + &runtime, + &current_metadata, + MigrationAppliedAtUnixSeconds::new(1_725_000_011).unwrap(), + &build(), + ) + .await + .expect("v10 binding survives schema-only migration"); + writer + .repository() + .verify_binding() + .await + .expect("historical binding verifies"); + writer.close().await.expect("close upgraded writer"); + + let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime)) + .await + .expect("inspect upgrade"); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT state_schema_version FROM radroots_service_metadata WHERE singleton = 1", + ) + .fetch_one(&mut connection) + .await + .expect("shared schema version"), + 11 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT state_contract_version FROM myc_state_metadata WHERE singleton = 1", + ) + .fetch_one(&mut connection) + .await + .expect("birth state version"), + 10 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT state_contract_version FROM myc_config_bindings WHERE generation = 1", + ) + .fetch_one(&mut connection) + .await + .expect("historical config state version"), + 10 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM myc_config_bindings") + .fetch_one(&mut connection) + .await + .expect("binding count"), + 1 + ); + connection.close().await.expect("close inspection"); +} + +#[tokio::test] async fn relay_change_is_blocked_by_nonterminal_work_but_safe_addition_is_admitted() { let directory = tempfile::tempdir().expect("root"); let runtime = runtime(directory.path()); diff --git a/tests/services_hardening_native_release.rs b/tests/services_hardening_native_release.rs @@ -53,7 +53,7 @@ fn native_release_contract_and_manifest_metadata_are_exact() { }, "contract_versions": { "config": 1, - "state": 10, + "state": 11, "admin": 1, "status": 1, "provider": 1 @@ -114,7 +114,7 @@ fn native_release_contract_and_manifest_metadata_are_exact() { host_feature_profile = "service-host" nix_material = "deferred" config_contract_version = 1 - state_contract_version = 10 + state_contract_version = 11 admin_contract_version = 1 status_contract_version = 1 provider_contract_version = 1 diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs @@ -19,8 +19,9 @@ use myc::{ MYC_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_9_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_9_SHA256, MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_10_SHA256, - MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog, - validate_myc_state_catalogs, + MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_11_OBJECT_COUNT, + MYC_STATE_SCHEMA_VERSION_11_SHA256, MycStateCatalogErrorKind, myc_migration_catalog, + myc_schema_catalog, validate_myc_state_catalogs, }; use radroots_service_sqlite::{ MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaDigest, @@ -32,13 +33,13 @@ const LIB_SOURCE: &str = include_str!("../src/lib.rs"); const MANIFEST: &str = include_str!("../Cargo.toml"); #[test] -fn schema_v1_through_v10_and_all_migrations_have_exact_literal_identities() { +fn schema_v1_through_v11_and_all_migrations_have_exact_literal_identities() { let migrations = myc_migration_catalog().expect("Myc migration catalog"); let schema = myc_schema_catalog().expect("Myc schema catalog"); assert_eq!(MYC_STATE_BASE_SCHEMA_VERSION, 1); - assert_eq!(MYC_STATE_SCHEMA_VERSION, 10); - assert_eq!(migrations.descriptors().len(), 9); + assert_eq!(MYC_STATE_SCHEMA_VERSION, 11); + assert_eq!(migrations.descriptors().len(), 10); let metadata = &migrations.descriptors()[0]; assert_eq!(metadata.target_version(), 2); assert_eq!(metadata.name().as_str(), "create_myc_state_metadata"); @@ -114,13 +115,23 @@ fn schema_v1_through_v10_and_all_migrations_have_exact_literal_identities() { configuration.checksum().as_bytes(), &MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256 ); - assert_eq!(migrations.current_version(), 10); + let admin_operations = &migrations.descriptors()[9]; + assert_eq!(admin_operations.target_version(), 11); + assert_eq!( + admin_operations.name().as_str(), + "create_admin_operation_journal" + ); + assert_eq!( + admin_operations.checksum().as_bytes(), + &MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256 + ); + assert_eq!(migrations.current_version(), 11); assert_eq!( migrations.digest().as_bytes(), &MYC_MIGRATION_CATALOG_SHA256 ); - assert_eq!(schema.versions().len(), 10); + assert_eq!(schema.versions().len(), 11); assert_eq!(schema.versions()[0].version(), 1); assert_eq!( schema.versions()[0].object_count(), @@ -220,6 +231,16 @@ fn schema_v1_through_v10_and_all_migrations_have_exact_literal_identities() { schema.versions()[9].digest().as_bytes(), &MYC_STATE_SCHEMA_VERSION_10_SHA256 ); + assert_eq!(schema.versions()[10].version(), 11); + assert_eq!( + schema.versions()[10].object_count(), + MYC_STATE_SCHEMA_VERSION_11_OBJECT_COUNT + ); + assert_eq!(schema.versions()[10].object_count(), 65); + assert_eq!( + schema.versions()[10].digest().as_bytes(), + &MYC_STATE_SCHEMA_VERSION_11_SHA256 + ); assert_eq!(schema.digest().as_bytes(), &MYC_STATE_SCHEMA_CATALOG_SHA256); assert_eq!(schema.migration_catalog_digest(), migrations.digest()); validate_myc_state_catalogs(&migrations, &schema).expect("exact catalogs"); @@ -230,7 +251,7 @@ fn schema_v1_through_v10_and_all_migrations_have_exact_literal_identities() { ); assert_eq!( hex::encode(MYC_MIGRATION_CATALOG_SHA256), - "5d6e0c8b832e66715abe176133d4433d52e6d18125aefdbc9d550515fd2121f2" + "1aa70a76b0474f9bb03300f0e86454b2863b457451edd9a2cced97ba31cb8cc1" ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_VERSION_1_SHA256), @@ -242,7 +263,7 @@ fn schema_v1_through_v10_and_all_migrations_have_exact_literal_identities() { ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_CATALOG_SHA256), - "1f88c79f86ae472489f62ddc9661a681dcfb1ed7ff723a07d97ba776b036a25a" + "09ec0c132fbc1a8a46ad3403497893834abf7352005eb6272c7a4528dd5d661a" ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256), @@ -308,6 +329,14 @@ fn schema_v1_through_v10_and_all_migrations_have_exact_literal_identities() { hex::encode(MYC_STATE_SCHEMA_VERSION_10_SHA256), "b77ed23afa39ff45dda250faa1ebfc0587c34462658fc49fb7b2fbc8909ba303" ); + assert_eq!( + hex::encode(MYC_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256), + "16385368aa4ee40ea7002a0ab42645bc68b546a4ba6afdfede565fe026576c96" + ); + assert_eq!( + hex::encode(MYC_STATE_SCHEMA_VERSION_11_SHA256), + "0de7fe176ea7da6030424addc29b9a91363e88b04dc78ddab480fd0f0af94e5a" + ); } #[test] @@ -369,10 +398,13 @@ fn independent_validator_rejects_migration_or_schema_drift() { let v9 = SchemaVersionCatalog::new(9, [object.clone()], v9_digest).expect("schema v9"); let v10_digest = SchemaVersionCatalog::computed_digest(10, [object.clone()]).expect("schema-v10 digest"); - let v10 = SchemaVersionCatalog::new(10, [object], v10_digest).expect("schema v10"); + let v10 = SchemaVersionCatalog::new(10, [object.clone()], v10_digest).expect("schema v10"); + let v11_digest = + SchemaVersionCatalog::computed_digest(11, [object.clone()]).expect("schema-v11 digest"); + let v11 = SchemaVersionCatalog::new(11, [object], v11_digest).expect("schema v11"); let schema = SchemaCatalog::new( &expected_migrations, - [v1, v2, v3, v4, v5, v6, v7, v8, v9, v10], + [v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11], ) .expect("drift schema catalog"); assert_eq!( diff --git a/tests/services_hardening_state_repository.rs b/tests/services_hardening_state_repository.rs @@ -120,7 +120,7 @@ async fn initialization_migrates_and_binds_exact_metadata_before_inspection() { .fetch_all(&mut connection) .await .expect("migration rows"); - assert_eq!(migrations.len(), 9); + assert_eq!(migrations.len(), 10); assert_eq!(migrations[0].get::<i64, _>(0), 2); assert_eq!( migrations[0].get::<String, _>(1), @@ -166,6 +166,11 @@ async fn initialization_migrates_and_binds_exact_metadata_before_inspection() { migrations[8].get::<String, _>(1), "create_configuration_binding_history" ); + assert_eq!(migrations[9].get::<i64, _>(0), 11); + assert_eq!( + migrations[9].get::<String, _>(1), + "create_admin_operation_journal" + ); let binding = sqlx::query( "SELECT normalized_config_sha256, transport_public_key, user_public_key, \ discovery_public_key, config_contract_version, state_contract_version, \