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:
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, ¤t);
+ initialize_v10(&runtime, ¤t_metadata).await;
+
+ let writer = open_myc_state_read_write(
+ &runtime,
+ ¤t_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, \