commit 3ed323e64ca64a8973e06623a583e1ca8f17b9e3
parent c0771042391d582f8318fcc44078376ef609d3e9
Author: triesap <tyson@radroots.org>
Date: Sun, 23 Aug 2026 09:22:56 +0000
state: add offline configuration lifecycle
- add the immutable schema-v10 configuration binding ledger
- make offline apply atomic, bounded, and replay-safe
- revoke stale connection authority on governed policy changes
- bind release metadata, source lock, tests, docs, and public API
Diffstat:
18 files changed, 1949 insertions(+), 69 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -132,6 +132,15 @@
task. Keep connection-count keys and identity roles closed, preserve the last
valid snapshot on any failed publication, admit only the fixed twelve status
reasons, and keep detailed status on the permissioned Unix-admin boundary.
+- Step 159 unit 10 owns schema-v10 offline configuration lifecycle. Keep the
+ configuration-binding ledger append-only and capped at 1,024 generations;
+ seed one post-migration generation without rewriting the immutable birth
+ record. Startup must match the latest config/public-identity binding. Identity
+ changes revoke live connection/challenge authority, permission narrowing
+ revokes affected sessions only, and an existing relay referenced by
+ nonterminal delivery work cannot be removed or changed. Do not persist relay
+ URLs, paths, credentials, provider envelopes, or protected values in the
+ binding history.
- 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 = 9
+state_contract_version = 10
admin_contract_version = 1
status_contract_version = 1
provider_contract_version = 1
diff --git a/README b/README
@@ -151,11 +151,27 @@ 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-v9 migrations, binds the normalized configuration, expected
+through schema-v10 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 v8 prefix; read-only inspection requires the current
-catalog and exact immutable Myc binding.
+resume any exact v1 through v9 prefix; read-only inspection requires the current
+catalog and exact latest Myc binding.
+
+Schema v10 adds an append-only configuration-binding history capped at exactly
+1,024 generations. Generation 1 is seeded only after the v10 migration commits,
+including for an upgraded v9 database, while the immutable original birth
+record remains unchanged. `apply_configuration` is admitted only through an
+exclusive writable host and independently validates the retained current and
+candidate documents before one atomic append. A changed identity expires live
+sessions and pending challenges; permission narrowing expires only sessions
+whose retained grants are removed. Removing or changing a relay that is still
+referenced by nonterminal delivery work fails closed, while safe relay additions
+remain admissible. Exact replay returns the retained generation without another
+append or revocation, including after an ambiguous caller result. Future startup
+must present the latest normalized config and public-identity binding. The
+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 v8 adds immutable NIP-46 operation-completion evidence. The Step 147
integration checkpoint binds each durable request to its stable operation and
diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt
@@ -137,6 +137,16 @@ pub myc::MycCommandV1::Identity(myc::MycIdentityCommandV1)
pub myc::MycCommandV1::Run
pub myc::MycCommandV1::State(myc::MycStateCommandV1)
pub myc::MycCommandV1::Status
+pub enum myc::MycConfigApplyErrorKind
+pub myc::MycConfigApplyErrorKind::Binding
+pub myc::MycConfigApplyErrorKind::CommitOutcomeUnknown
+pub myc::MycConfigApplyErrorKind::InvalidInput
+pub myc::MycConfigApplyErrorKind::InvalidMode
+pub myc::MycConfigApplyErrorKind::PolicyConflict
+pub myc::MycConfigApplyErrorKind::ResourceExhausted
+pub myc::MycConfigApplyErrorKind::Transaction
+impl myc::MycConfigApplyErrorKind
+pub const fn myc::MycConfigApplyErrorKind::code(self) -> &'static str
pub enum myc::MycConfigCommandV1
pub myc::MycConfigCommandV1::Init
pub myc::MycConfigCommandV1::Schema
@@ -920,6 +930,22 @@ impl core::fmt::Debug for myc::MycCliV1Error
pub fn myc::MycCliV1Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::fmt::Display for myc::MycCliV1Error
pub fn myc::MycCliV1Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycConfigApplyError
+impl myc::MycConfigApplyError
+pub const fn myc::MycConfigApplyError::code(self) -> &'static str
+pub const fn myc::MycConfigApplyError::kind(self) -> myc::MycConfigApplyErrorKind
+impl core::error::Error for myc::MycConfigApplyError
+impl core::fmt::Debug for myc::MycConfigApplyError
+pub fn myc::MycConfigApplyError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+impl core::fmt::Display for myc::MycConfigApplyError
+pub fn myc::MycConfigApplyError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycConfigApplyOutcome
+impl myc::MycConfigApplyOutcome
+pub const fn myc::MycConfigApplyOutcome::generation(self) -> u16
+pub const fn myc::MycConfigApplyOutcome::revoked_challenge_count(self) -> u64
+pub const fn myc::MycConfigApplyOutcome::revoked_connection_count(self) -> u64
+impl core::fmt::Debug for myc::MycConfigApplyOutcome
+pub fn myc::MycConfigApplyOutcome::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct myc::MycConfigDocumentV1
impl myc::MycConfigDocumentV1
pub const fn myc::MycConfigDocumentV1::effective(&self) -> &myc::MycEffectiveConfigV1
@@ -1847,6 +1873,8 @@ pub async fn myc::MycStateRepository<'_>::read_connection_decision(&self, myc::M
impl myc::MycStateRepository<'_>
pub async fn myc::MycStateRepository<'_>::admit_signer_request(&self, &myc::MycSignerRequest) -> core::result::Result<myc::MycSignerRequestAdmission, myc::MycStateRepositoryError>
impl myc::MycStateRepository<'_>
+pub async fn myc::MycStateRepository<'_>::apply_configuration(&self, &myc::MycConfigDocumentV1, &myc::MycConfigDocumentV1, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<myc::MycConfigApplyOutcome, myc::MycConfigApplyError>
+impl myc::MycStateRepository<'_>
pub async fn myc::MycStateRepository<'_>::claim_delivery_target(&self, myc::MycDeliveryJobId, &myc::MycDeliveryRelayId, myc::MycDeliveryAttemptNonce, myc::MycDeliveryTimeUnixMs) -> core::result::Result<myc::MycDeliveryClaim, myc::MycStateRepositoryError>
pub async fn myc::MycStateRepository<'_>::mark_delivery_attempt_submitted(&self, myc::MycDeliveryJobId, &myc::MycDeliveryRelayId, myc::MycDeliveryAttemptId, myc::MycDeliveryTimeUnixMs) -> core::result::Result<myc::MycDeliveryAttemptRecord, myc::MycStateRepositoryError>
pub async fn myc::MycStateRepository<'_>::read_delivery_attempts(&self, myc::MycDeliveryJobId, &myc::MycDeliveryRelayId) -> core::result::Result<alloc::boxed::Box<[myc::MycDeliveryAttemptRecord]>, myc::MycStateRepositoryError>
@@ -2005,6 +2033,7 @@ 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
pub const myc::MYC_COMPACTION_MAX_ROWS: u16
+pub const myc::MYC_CONFIG_BINDING_MAX_GENERATIONS: u16
pub const myc::MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES: usize
pub const myc::MYC_CONFIG_SCHEMA: &str
pub const myc::MYC_CONFIG_SCHEMA_VERSION: u32
@@ -2060,6 +2089,9 @@ pub const myc::MYC_STATE_APPLICATION_ID: u32
pub const myc::MYC_STATE_BASE_SCHEMA_VERSION: u32
pub const myc::MYC_STATE_SCHEMA_CATALOG_SHA256: [u8; 32]
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_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/native_release.v1.json b/contracts/services_hardening/native_release.v1.json
@@ -32,7 +32,7 @@
},
"contract_versions": {
"config": 1,
- "state": 9,
+ "state": 10,
"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 = 9
+state = 10
admin = 1
status = 1
provider = 1
diff --git a/src/lib.rs b/src/lib.rs
@@ -29,6 +29,7 @@ mod runtime_foundation;
mod runtime_supervision;
mod state_catalog;
mod state_completion;
+mod state_config;
mod state_connection;
mod state_delivery;
mod state_discovery;
@@ -162,13 +163,19 @@ pub use state_catalog::{
MYC_STATE_SCHEMA_VERSION_7_SHA256, MYC_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256,
MYC_STATE_SCHEMA_VERSION_8_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_8_SHA256,
MYC_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_9_OBJECT_COUNT,
- MYC_STATE_SCHEMA_VERSION_9_SHA256, MycStateCatalogError, MycStateCatalogErrorKind,
- myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs,
+ 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,
};
pub use state_completion::{
MycNip46CommitAdmission, MycNip46CommitError, MycNip46CommitErrorKind, MycNip46CommitRecord,
MycNip46CommitRequest, MycNip46SessionEffect,
};
+pub use state_config::{
+ MYC_CONFIG_BINDING_MAX_GENERATIONS, MycConfigApplyError, MycConfigApplyErrorKind,
+ MycConfigApplyOutcome,
+};
pub use state_connection::{
MYC_AUTHORIZATION_CHALLENGE_URL_MAX_BYTES, MYC_CONNECTION_PERMISSION_MAX_COUNT,
MycAuthorizationChallengeAdmission, MycAuthorizationChallengeAuthorization,
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 = 9;
+pub const MYC_STATE_SCHEMA_VERSION: u32 = 10;
/// The shared metadata and migration-ledger objects present at schema v1.
pub const MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6;
@@ -41,6 +41,9 @@ pub const MYC_STATE_SCHEMA_VERSION_8_OBJECT_COUNT: u32 = 56;
/// The shared objects plus immutable exact NIP-46 response authority.
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;
+
/// 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,
@@ -55,14 +58,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] = [
- 0x86, 0x04, 0xc5, 0x1e, 0xa6, 0xf1, 0x6f, 0x0a, 0xa3, 0x7a, 0x63, 0xee, 0xe0, 0x9c, 0x60, 0x0a,
- 0x0b, 0x70, 0x31, 0xef, 0xbc, 0x8e, 0x5e, 0x0e, 0x41, 0xb5, 0xf0, 0xf5, 0x3c, 0x48, 0xe7, 0x0b,
+ 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,
];
/// SHA-256 identity of the schema catalog bound to the migration catalog.
pub const MYC_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [
- 0x63, 0x2c, 0x8d, 0x52, 0x16, 0xd2, 0xa5, 0x41, 0xfd, 0x28, 0xae, 0x3a, 0x2c, 0xae, 0x80, 0x69,
- 0xc5, 0x8b, 0xef, 0x11, 0xfe, 0x81, 0xd2, 0xf0, 0x43, 0x99, 0xa7, 0x7c, 0xf8, 0x35, 0x9e, 0xa7,
+ 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,
];
/// SHA-256 identity of the schema-v2 migration content.
@@ -155,6 +158,18 @@ pub const MYC_STATE_SCHEMA_VERSION_9_SHA256: [u8; 32] = [
0xc5, 0xf5, 0xef, 0xc4, 0xb6, 0xed, 0xd0, 0xac, 0x7f, 0x73, 0xca, 0xd8, 0x3a, 0x99, 0x1f, 0xf7,
];
+/// SHA-256 identity of the schema-v10 configuration-binding migration.
+pub const MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256: [u8; 32] = [
+ 0x28, 0x42, 0x3e, 0xbb, 0x59, 0xf4, 0xb2, 0x62, 0x23, 0x30, 0x7b, 0x74, 0xa7, 0xe1, 0x29, 0x05,
+ 0xca, 0x48, 0x18, 0xc0, 0x1a, 0x65, 0x52, 0x03, 0xee, 0x8d, 0x63, 0xc9, 0x11, 0xf4, 0x44, 0x89,
+];
+
+/// SHA-256 identity of the schema-v10 object snapshot.
+pub const MYC_STATE_SCHEMA_VERSION_10_SHA256: [u8; 32] = [
+ 0xb7, 0x7e, 0xd2, 0x3a, 0xfa, 0x39, 0xff, 0x45, 0xdd, 0xa2, 0x50, 0xfa, 0xa1, 0xeb, 0xfc, 0x05,
+ 0x87, 0xc3, 0x44, 0x62, 0x65, 0x8f, 0xc4, 0x9f, 0xb7, 0xb2, 0xfb, 0xc8, 0x90, 0x9b, 0xa3, 0x03,
+];
+
/// 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,
@@ -1716,6 +1731,118 @@ const CREATE_NIP46_ATOMIC_RESPONSE_MIGRATION_SQL: &str = concat!(
nip46_signed_responses_no_delete_sql!(),
);
+macro_rules! myc_config_bindings_table_sql {
+ () => {
+ r#"CREATE TABLE myc_config_bindings (
+ generation INTEGER NOT NULL PRIMARY KEY CHECK (generation BETWEEN 1 AND 1024),
+ normalized_config_sha256 BLOB NOT NULL CHECK (length(normalized_config_sha256) = 32),
+ transport_public_key TEXT NOT NULL
+ CHECK (length(CAST(transport_public_key AS BLOB)) = 64)
+ CHECK (transport_public_key NOT GLOB '*[^0-9a-f]*'),
+ user_public_key TEXT NOT NULL
+ CHECK (length(CAST(user_public_key AS BLOB)) = 64)
+ CHECK (user_public_key NOT GLOB '*[^0-9a-f]*'),
+ discovery_public_key TEXT
+ CHECK (discovery_public_key IS NULL OR
+ (length(CAST(discovery_public_key AS BLOB)) = 64
+ AND discovery_public_key NOT GLOB '*[^0-9a-f]*')),
+ config_contract_version INTEGER NOT NULL
+ CHECK (config_contract_version BETWEEN 1 AND 4294967295),
+ state_contract_version INTEGER NOT NULL
+ CHECK (state_contract_version BETWEEN 1 AND 4294967295),
+ operator_contract_version INTEGER NOT NULL
+ CHECK (operator_contract_version BETWEEN 1 AND 4294967295),
+ status_contract_version INTEGER NOT NULL
+ CHECK (status_contract_version BETWEEN 1 AND 4294967295),
+ applied_at_unix_s INTEGER NOT NULL
+ CHECK (applied_at_unix_s BETWEEN 0 AND 9223372036854775807),
+ service_version TEXT NOT NULL
+ CHECK (length(CAST(service_version AS BLOB)) BETWEEN 1 AND 128),
+ service_commit TEXT NOT NULL
+ CHECK (length(CAST(service_commit AS BLOB)) = 40),
+ lib_revision TEXT NOT NULL
+ CHECK (length(CAST(lib_revision AS BLOB)) = 40),
+ rust_version TEXT NOT NULL
+ CHECK (length(CAST(rust_version AS BLOB)) BETWEEN 1 AND 128),
+ target TEXT NOT NULL CHECK (length(CAST(target AS BLOB)) BETWEEN 1 AND 128),
+ feature_profile TEXT NOT NULL
+ CHECK (length(CAST(feature_profile AS BLOB)) BETWEEN 1 AND 128),
+ provider_contract_version INTEGER NOT NULL
+ CHECK (provider_contract_version BETWEEN 1 AND 4294967295)
+) STRICT"#
+ };
+}
+
+macro_rules! myc_config_bindings_guard_insert_sql {
+ () => {
+ r#"CREATE TRIGGER myc_config_bindings_guard_insert
+BEFORE INSERT ON myc_config_bindings
+WHEN NEW.generation != COALESCE(
+ (SELECT MAX(generation) + 1 FROM myc_config_bindings), 1
+ )
+ OR (SELECT COUNT(*) FROM myc_config_bindings) >= 1024
+ OR NEW.applied_at_unix_s < COALESCE(
+ (SELECT MAX(applied_at_unix_s) FROM myc_config_bindings), 0
+ )
+BEGIN
+ SELECT RAISE(ABORT, 'configuration binding sequence is invalid');
+END"#
+ };
+}
+
+macro_rules! myc_config_bindings_no_update_sql {
+ () => {
+ r#"CREATE TRIGGER myc_config_bindings_no_update
+BEFORE UPDATE ON myc_config_bindings
+BEGIN
+ SELECT RAISE(ABORT, 'configuration binding history is immutable');
+END"#
+ };
+}
+
+macro_rules! myc_config_bindings_no_delete_sql {
+ () => {
+ r#"CREATE TRIGGER myc_config_bindings_no_delete
+BEFORE DELETE ON myc_config_bindings
+BEGIN
+ SELECT RAISE(ABORT, 'configuration binding history is retained');
+END"#
+ };
+}
+
+const CREATE_MYC_CONFIG_BINDINGS_TABLE_SQL: &str = myc_config_bindings_table_sql!();
+const CREATE_MYC_CONFIG_BINDINGS_GUARD_INSERT_SQL: &str = myc_config_bindings_guard_insert_sql!();
+const CREATE_MYC_CONFIG_BINDINGS_NO_UPDATE_SQL: &str = myc_config_bindings_no_update_sql!();
+const CREATE_MYC_CONFIG_BINDINGS_NO_DELETE_SQL: &str = myc_config_bindings_no_delete_sql!();
+
+const CREATE_MYC_CONFIG_BINDINGS_MIGRATION_SQL: &str = concat!(
+ myc_config_bindings_table_sql!(),
+ ";\n",
+ myc_config_bindings_guard_insert_sql!(),
+ ";\n",
+ myc_config_bindings_no_update_sql!(),
+ ";\n",
+ myc_config_bindings_no_delete_sql!(),
+ ";",
+);
+
+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,
+];
+const MYC_CONFIG_BINDINGS_GUARD_INSERT_SHA256: [u8; 32] = [
+ 0x28, 0x4c, 0xc3, 0xa6, 0xe6, 0xb2, 0x42, 0x2c, 0x71, 0x42, 0xb9, 0x43, 0x2f, 0x67, 0x29, 0x20,
+ 0x4d, 0xa7, 0x03, 0xde, 0x21, 0xec, 0x8e, 0xbc, 0xe1, 0xc4, 0xda, 0x24, 0x60, 0x43, 0x5d, 0xce,
+];
+const MYC_CONFIG_BINDINGS_NO_UPDATE_SHA256: [u8; 32] = [
+ 0xba, 0xcc, 0x52, 0x41, 0x3e, 0x4b, 0xc2, 0x68, 0x01, 0xbc, 0xfd, 0xa0, 0x8b, 0xda, 0xdb, 0x1f,
+ 0x24, 0xd0, 0x6d, 0x86, 0xa1, 0x79, 0x72, 0xd2, 0x93, 0x6b, 0xcf, 0xfb, 0xdd, 0xc7, 0xa0, 0xe5,
+];
+const MYC_CONFIG_BINDINGS_NO_DELETE_SHA256: [u8; 32] = [
+ 0xb5, 0x2b, 0x12, 0xde, 0xec, 0xc3, 0x2b, 0xe8, 0x5e, 0x48, 0xa9, 0x91, 0xc1, 0x4b, 0xff, 0xc0,
+ 0x0d, 0xe9, 0xfc, 0x24, 0x44, 0x62, 0x83, 0xf3, 0x7c, 0x05, 0xa3, 0x52, 0xdf, 0xfa, 0xcf, 0x5c,
+];
+
const CONNECTIONS_TABLE_SHA256: [u8; 32] = [
0x72, 0xd5, 0xd8, 0xba, 0x24, 0x68, 0x9c, 0x93, 0x34, 0xb3, 0x8f, 0xbf, 0x64, 0x21, 0xe1, 0x65,
0xfd, 0xc3, 0x80, 0x46, 0xf1, 0x3f, 0x56, 0x49, 0x3a, 0xef, 0xd7, 0x42, 0xc4, 0xe6, 0x49, 0x85,
@@ -2064,6 +2191,13 @@ pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError>
MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256),
)
.map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?;
+ let configuration = MigrationDescriptor::sql(
+ 10,
+ "create_configuration_binding_history",
+ CREATE_MYC_CONFIG_BINDINGS_MIGRATION_SQL,
+ MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256),
+ )
+ .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?;
let catalog = MigrationCatalog::new([
metadata,
requests,
@@ -2073,10 +2207,11 @@ pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError>
discovery,
completion,
response,
+ configuration,
])
.map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?;
if catalog.current_version() != MYC_STATE_SCHEMA_VERSION
- || catalog.descriptors().len() != 8
+ || catalog.descriptors().len() != 9
|| catalog.digest().as_bytes() != &MYC_MIGRATION_CATALOG_SHA256
{
return Err(MycStateCatalogError::new(
@@ -2143,6 +2278,12 @@ pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> {
SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_9_SHA256),
)
.map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?;
+ let version_ten = SchemaVersionCatalog::new(
+ 10,
+ myc_state_config_binding_objects()?,
+ SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_10_SHA256),
+ )
+ .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?;
let catalog = SchemaCatalog::new(
&migrations,
[
@@ -2155,6 +2296,7 @@ pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> {
version_seven,
version_eight,
version_nine,
+ version_ten,
],
)
.map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?;
@@ -2710,6 +2852,47 @@ fn myc_state_response_objects() -> Result<Vec<SchemaObject>, MycStateCatalogErro
Ok(objects)
}
+fn myc_state_config_binding_objects() -> Result<Vec<SchemaObject>, MycStateCatalogError> {
+ let mut objects = myc_state_response_objects()?;
+ let object = |kind, name, sql, digest| {
+ SchemaObject::new(
+ kind,
+ name,
+ "myc_config_bindings",
+ sql,
+ SchemaDigest::from_bytes(digest),
+ )
+ .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))
+ };
+ objects.extend([
+ object(
+ SchemaObjectKind::Table,
+ "myc_config_bindings",
+ CREATE_MYC_CONFIG_BINDINGS_TABLE_SQL,
+ MYC_CONFIG_BINDINGS_TABLE_SHA256,
+ )?,
+ object(
+ SchemaObjectKind::Trigger,
+ "myc_config_bindings_guard_insert",
+ CREATE_MYC_CONFIG_BINDINGS_GUARD_INSERT_SQL,
+ MYC_CONFIG_BINDINGS_GUARD_INSERT_SHA256,
+ )?,
+ object(
+ SchemaObjectKind::Trigger,
+ "myc_config_bindings_no_update",
+ CREATE_MYC_CONFIG_BINDINGS_NO_UPDATE_SQL,
+ MYC_CONFIG_BINDINGS_NO_UPDATE_SHA256,
+ )?,
+ object(
+ SchemaObjectKind::Trigger,
+ "myc_config_bindings_no_delete",
+ CREATE_MYC_CONFIG_BINDINGS_NO_DELETE_SQL,
+ MYC_CONFIG_BINDINGS_NO_DELETE_SHA256,
+ )?,
+ ]);
+ Ok(objects)
+}
+
/// Independently validates exact catalog versions, counts, and digests.
pub fn validate_myc_state_catalogs(
migrations: &MigrationCatalog,
@@ -2718,7 +2901,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() == 8
+ && descriptors.len() == 9
&& 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
@@ -2743,9 +2926,12 @@ pub fn validate_myc_state_catalogs(
&& descriptors[7].target_version() == 9
&& descriptors[7].name().as_str() == "create_nip46_atomic_response"
&& descriptors[7].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256
+ && 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
&& migrations.digest().as_bytes() == &MYC_MIGRATION_CATALOG_SHA256
&& schema.migration_catalog_digest() == migrations.digest()
- && versions.len() == 9
+ && versions.len() == 10
&& 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
@@ -2773,6 +2959,9 @@ pub fn validate_myc_state_catalogs(
&& versions[8].version() == 9
&& versions[8].object_count() == MYC_STATE_SCHEMA_VERSION_9_OBJECT_COUNT
&& versions[8].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_9_SHA256
+ && 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
&& schema.digest().as_bytes() == &MYC_STATE_SCHEMA_CATALOG_SHA256;
if valid {
Ok(())
diff --git a/src/state_config.rs b/src/state_config.rs
@@ -0,0 +1,608 @@
+//! Offline append-only configuration-binding lifecycle.
+
+use core::fmt;
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ error::Error,
+};
+
+use radroots_service_sqlite::{
+ MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, ServiceSqliteTransaction,
+ ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind,
+};
+use serde_json::Value;
+use sqlx::{QueryBuilder, Row, Sqlite};
+
+use crate::{
+ MYC_PROVIDER_CONTRACT_VERSION, MycConfigDocumentV1, MycStateRepository,
+ state_repository::{
+ PersistedMetadata, RepositoryOperationError, read_latest_config_binding,
+ require_expected_metadata,
+ },
+};
+
+/// Maximum number of immutable configuration generations retained by one instance.
+pub const MYC_CONFIG_BINDING_MAX_GENERATIONS: u16 = 1024;
+
+const READ_LATEST_HEADER_SQL: &str = r#"SELECT generation, applied_at_unix_s
+FROM myc_config_bindings
+ORDER BY generation DESC
+LIMIT 1"#;
+
+const RELAY_HAS_NONTERMINAL_JOB_SQL: &str = r#"SELECT EXISTS (
+ SELECT 1
+ FROM delivery_targets AS target
+ JOIN delivery_jobs AS job ON job.job_id = target.job_id
+ WHERE target.relay_id = ? AND job.status IN ('pending', 'active')
+ LIMIT 1
+) AS is_blocked"#;
+
+const REVOKE_ACTIVE_CONNECTIONS_SQL: &str = r#"UPDATE connections
+SET status = 'expired', updated_at_unix_ms = MAX(updated_at_unix_ms, ?),
+ authorized_until_unix_ms = NULL
+WHERE status = 'active'"#;
+
+const DENY_PENDING_CONNECTIONS_SQL: &str = r#"UPDATE connections
+SET status = 'denied', updated_at_unix_ms = MAX(updated_at_unix_ms, ?),
+ authorized_until_unix_ms = NULL
+WHERE status = 'pending'"#;
+
+const EXPIRE_ALL_PENDING_CHALLENGES_SQL: &str = r#"UPDATE connection_auth_challenges
+SET state = 'expired',
+ resolved_at_unix_ms = MAX(issued_at_unix_ms, ?)
+WHERE state = 'pending'"#;
+
+const INSERT_CONFIG_BINDING_SQL: &str = r#"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
+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#;
+
+/// Stable offline configuration-application failure classes.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MycConfigApplyErrorKind {
+ InvalidMode,
+ InvalidInput,
+ Binding,
+ PolicyConflict,
+ ResourceExhausted,
+ Transaction,
+ CommitOutcomeUnknown,
+}
+
+impl MycConfigApplyErrorKind {
+ /// Returns the stable machine-readable failure code.
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ match self {
+ Self::InvalidMode => "config_apply_mode_invalid",
+ Self::InvalidInput => "config_apply_input_invalid",
+ Self::Binding => "config_apply_binding_invalid",
+ Self::PolicyConflict => "config_apply_policy_conflict",
+ Self::ResourceExhausted => "resource_exhausted",
+ Self::Transaction => "config_apply_transaction_failed",
+ Self::CommitOutcomeUnknown => "config_apply_commit_outcome_unknown",
+ }
+ }
+}
+
+/// Source-free offline configuration-application failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct MycConfigApplyError {
+ kind: MycConfigApplyErrorKind,
+}
+
+impl MycConfigApplyError {
+ const fn new(kind: MycConfigApplyErrorKind) -> Self {
+ Self { kind }
+ }
+
+ /// Returns the stable failure class.
+ #[must_use]
+ pub const fn kind(self) -> MycConfigApplyErrorKind {
+ self.kind
+ }
+
+ /// Returns the stable machine-readable failure code.
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ self.kind.code()
+ }
+}
+
+impl fmt::Display for MycConfigApplyError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self.kind {
+ MycConfigApplyErrorKind::InvalidMode => {
+ "Myc configuration apply requires an offline writable state host"
+ }
+ MycConfigApplyErrorKind::InvalidInput => "Myc configuration apply evidence is invalid",
+ MycConfigApplyErrorKind::Binding => "Myc configuration history binding is invalid",
+ MycConfigApplyErrorKind::PolicyConflict => {
+ "Myc configuration change conflicts with retained work"
+ }
+ MycConfigApplyErrorKind::ResourceExhausted => {
+ "Myc configuration history capacity is exhausted"
+ }
+ MycConfigApplyErrorKind::Transaction => "Myc configuration apply transaction failed",
+ MycConfigApplyErrorKind::CommitOutcomeUnknown => {
+ "Myc configuration apply commit outcome is unknown"
+ }
+ })
+ }
+}
+
+impl fmt::Debug for MycConfigApplyError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycConfigApplyError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl Error for MycConfigApplyError {}
+
+/// Committed immutable configuration-generation evidence.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct MycConfigApplyOutcome {
+ generation: u16,
+ revoked_connections: u64,
+ revoked_challenges: u64,
+}
+
+impl MycConfigApplyOutcome {
+ /// Returns the committed consecutive configuration generation.
+ #[must_use]
+ pub const fn generation(self) -> u16 {
+ self.generation
+ }
+
+ /// Returns the number of connection records revoked by the apply.
+ #[must_use]
+ pub const fn revoked_connection_count(self) -> u64 {
+ self.revoked_connections
+ }
+
+ /// Returns the number of pending challenges revoked by the apply.
+ #[must_use]
+ pub const fn revoked_challenge_count(self) -> u64 {
+ self.revoked_challenges
+ }
+}
+
+impl fmt::Debug for MycConfigApplyOutcome {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycConfigApplyOutcome")
+ .field("generation", &self.generation)
+ .field("revoked_connections", &self.revoked_connections)
+ .field("revoked_challenges", &self.revoked_challenges)
+ .finish()
+ }
+}
+
+impl MycStateRepository<'_> {
+ /// Atomically applies one complete candidate configuration while offline.
+ ///
+ /// The current document must match the latest durable binding. The candidate
+ /// is already structurally and semantically admitted by its sealed type.
+ /// Unsafe relay changes are rejected while nonterminal jobs retain the relay.
+ pub async fn apply_configuration(
+ &self,
+ current: &MycConfigDocumentV1,
+ candidate: &MycConfigDocumentV1,
+ applied_at: MigrationAppliedAtUnixSeconds,
+ build: &MigrationBuildIdentity,
+ ) -> Result<MycConfigApplyOutcome, MycConfigApplyError> {
+ if !self.is_writable() {
+ return Err(MycConfigApplyError::new(
+ MycConfigApplyErrorKind::InvalidMode,
+ ));
+ }
+ if current.profile() != candidate.profile()
+ || candidate.provider_contract().bindings().is_empty()
+ || !valid_build(candidate, build)
+ {
+ return Err(MycConfigApplyError::new(
+ MycConfigApplyErrorKind::InvalidInput,
+ ));
+ }
+ let current_binding = PersistedMetadata::from_configuration(current)
+ .map_err(|_| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?;
+ if current_binding != PersistedMetadata::from(self.expected()) {
+ return Err(MycConfigApplyError::new(MycConfigApplyErrorKind::Binding));
+ }
+ let candidate_binding = PersistedMetadata::from_configuration(candidate)
+ .map_err(|_| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?;
+ let exact_replay = candidate_binding == current_binding;
+ let changed_relays = changed_existing_relays(current, candidate)?;
+ let candidate_permissions = permission_ceiling(candidate)?;
+ let permissions_narrowed = permission_ceiling(current)?
+ .iter()
+ .any(|permission| !candidate_permissions.contains(permission));
+ let identities_changed = identities_changed(¤t_binding, &candidate_binding);
+ let applied_at_unix_s = applied_at.get();
+ let applied_at_unix_ms =
+ i64::try_from(applied_at_unix_s.saturating_mul(1000)).unwrap_or(i64::MAX);
+ let build = build.clone();
+
+ self.host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ require_expected_metadata(transaction, ¤t_binding).await?;
+ let (generation, latest_applied_at) = read_latest_header(transaction).await?;
+ if exact_replay {
+ return Ok(MycConfigApplyOutcome {
+ generation,
+ revoked_connections: 0,
+ revoked_challenges: 0,
+ });
+ }
+ if generation >= MYC_CONFIG_BINDING_MAX_GENERATIONS {
+ return Err(ConfigOperationError::ResourceExhausted);
+ }
+ if applied_at_unix_s < latest_applied_at {
+ return Err(ConfigOperationError::InvalidInput);
+ }
+ for relay_id in &changed_relays {
+ if relay_has_nonterminal_job(transaction, relay_id).await? {
+ return Err(ConfigOperationError::PolicyConflict);
+ }
+ }
+ let (revoked_connections, revoked_challenges) = if identities_changed {
+ revoke_for_identity_change(transaction, applied_at_unix_ms).await?
+ } else if permissions_narrowed {
+ revoke_for_permission_narrowing(
+ transaction,
+ applied_at_unix_ms,
+ &candidate_permissions,
+ )
+ .await?
+ } else {
+ (0, 0)
+ };
+ let next_generation = generation + 1;
+ insert_binding(
+ transaction,
+ next_generation,
+ &candidate_binding,
+ applied_at_unix_s,
+ &build,
+ )
+ .await?;
+ match read_latest_config_binding(transaction).await? {
+ Some(actual) if actual == candidate_binding => {}
+ Some(_) | None => return Err(ConfigOperationError::Binding),
+ }
+ let (actual_generation, actual_applied_at) =
+ read_latest_header(transaction).await?;
+ if actual_generation != next_generation
+ || actual_applied_at != applied_at_unix_s
+ {
+ return Err(ConfigOperationError::Binding);
+ }
+ Ok(MycConfigApplyOutcome {
+ generation: next_generation,
+ revoked_connections,
+ revoked_challenges,
+ })
+ })
+ })
+ .await
+ .map_err(map_apply_transaction_error)
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum ConfigOperationError {
+ InvalidInput,
+ Binding,
+ PolicyConflict,
+ ResourceExhausted,
+ Storage,
+}
+
+impl From<RepositoryOperationError> for ConfigOperationError {
+ fn from(error: RepositoryOperationError) -> Self {
+ match error {
+ RepositoryOperationError::Binding => Self::Binding,
+ RepositoryOperationError::Storage => Self::Storage,
+ }
+ }
+}
+
+fn valid_build(configuration: &MycConfigDocumentV1, build: &MigrationBuildIdentity) -> bool {
+ build.config_contract_version() == configuration.schema_version()
+ && build.state_contract_version() == crate::MYC_STATE_SCHEMA_VERSION
+ && build.admin_contract_version() == crate::MYC_OPERATOR_CONTRACT_VERSION
+ && build.status_contract_version() == crate::MYC_SIGNER_STATUS_CONTRACT_VERSION
+ && build.provider_contract_version() == MYC_PROVIDER_CONTRACT_VERSION
+}
+
+fn identities_changed(current: &PersistedMetadata, candidate: &PersistedMetadata) -> bool {
+ current.transport_public_key != candidate.transport_public_key
+ || current.user_public_key != candidate.user_public_key
+ || current.discovery_public_key != candidate.discovery_public_key
+}
+
+#[derive(PartialEq, Eq)]
+struct RelayBinding<'a> {
+ url: &'a str,
+ read: bool,
+ write: bool,
+ required: bool,
+ authentication: &'a str,
+}
+
+fn relay_bindings(
+ configuration: &MycConfigDocumentV1,
+) -> Result<BTreeMap<&str, RelayBinding<'_>>, MycConfigApplyError> {
+ configuration
+ .normalized()
+ .pointer("/relays")
+ .and_then(Value::as_array)
+ .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?
+ .iter()
+ .map(|relay| {
+ let id = relay
+ .pointer("/id")
+ .and_then(Value::as_str)
+ .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?;
+ let value = RelayBinding {
+ url: relay
+ .pointer("/url")
+ .and_then(Value::as_str)
+ .ok_or_else(|| {
+ MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput)
+ })?,
+ read: relay
+ .pointer("/read")
+ .and_then(Value::as_bool)
+ .ok_or_else(|| {
+ MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput)
+ })?,
+ write: relay
+ .pointer("/write")
+ .and_then(Value::as_bool)
+ .ok_or_else(|| {
+ MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput)
+ })?,
+ required: relay
+ .pointer("/required")
+ .and_then(Value::as_bool)
+ .ok_or_else(|| {
+ MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput)
+ })?,
+ authentication: relay
+ .pointer("/authentication")
+ .and_then(Value::as_str)
+ .ok_or_else(|| {
+ MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput)
+ })?,
+ };
+ Ok((id, value))
+ })
+ .collect()
+}
+
+fn changed_existing_relays(
+ current: &MycConfigDocumentV1,
+ candidate: &MycConfigDocumentV1,
+) -> Result<Vec<Box<str>>, MycConfigApplyError> {
+ let current = relay_bindings(current)?;
+ let candidate = relay_bindings(candidate)?;
+ Ok(current
+ .into_iter()
+ .filter(|(id, binding)| candidate.get(id).is_none_or(|next| next != binding))
+ .map(|(id, _)| id.into())
+ .collect())
+}
+
+fn permission_ceiling(
+ configuration: &MycConfigDocumentV1,
+) -> Result<BTreeSet<Box<str>>, MycConfigApplyError> {
+ configuration
+ .normalized()
+ .pointer("/policy/permission_ceiling")
+ .and_then(Value::as_array)
+ .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?
+ .iter()
+ .map(|permission| {
+ permission
+ .as_str()
+ .map(Into::into)
+ .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))
+ })
+ .collect()
+}
+
+async fn read_latest_header(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+) -> Result<(u16, u64), ConfigOperationError> {
+ let rows = sqlx::query(READ_LATEST_HEADER_SQL)
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| ConfigOperationError::Storage)?;
+ let [row] = rows.as_slice() else {
+ return Err(ConfigOperationError::Binding);
+ };
+ let generation = row
+ .try_get::<i64, _>("generation")
+ .ok()
+ .and_then(|value| u16::try_from(value).ok())
+ .filter(|value| (1..=MYC_CONFIG_BINDING_MAX_GENERATIONS).contains(value))
+ .ok_or(ConfigOperationError::Binding)?;
+ let applied_at = row
+ .try_get::<i64, _>("applied_at_unix_s")
+ .ok()
+ .and_then(|value| u64::try_from(value).ok())
+ .ok_or(ConfigOperationError::Binding)?;
+ Ok((generation, applied_at))
+}
+
+async fn relay_has_nonterminal_job(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ relay_id: &str,
+) -> Result<bool, ConfigOperationError> {
+ let row = sqlx::query(RELAY_HAS_NONTERMINAL_JOB_SQL)
+ .bind(relay_id)
+ .fetch_one(&mut *transaction)
+ .await
+ .map_err(|_| ConfigOperationError::Storage)?;
+ match row.try_get::<i64, _>("is_blocked") {
+ Ok(0) => Ok(false),
+ Ok(1) => Ok(true),
+ _ => Err(ConfigOperationError::Binding),
+ }
+}
+
+async fn revoke_for_identity_change(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ observed_at_unix_ms: i64,
+) -> Result<(u64, u64), ConfigOperationError> {
+ let active = sqlx::query(REVOKE_ACTIVE_CONNECTIONS_SQL)
+ .bind(observed_at_unix_ms)
+ .execute(&mut *transaction)
+ .await
+ .map_err(|_| ConfigOperationError::Storage)?
+ .rows_affected();
+ let pending = sqlx::query(DENY_PENDING_CONNECTIONS_SQL)
+ .bind(observed_at_unix_ms)
+ .execute(&mut *transaction)
+ .await
+ .map_err(|_| ConfigOperationError::Storage)?
+ .rows_affected();
+ let challenges = sqlx::query(EXPIRE_ALL_PENDING_CHALLENGES_SQL)
+ .bind(observed_at_unix_ms)
+ .execute(&mut *transaction)
+ .await
+ .map_err(|_| ConfigOperationError::Storage)?
+ .rows_affected();
+ Ok((active.saturating_add(pending), challenges))
+}
+
+async fn revoke_for_permission_narrowing(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ observed_at_unix_ms: i64,
+ permissions: &BTreeSet<Box<str>>,
+) -> Result<(u64, u64), ConfigOperationError> {
+ let connections =
+ update_affected_connections(transaction, observed_at_unix_ms, permissions).await?;
+ let challenges =
+ update_affected_challenges(transaction, observed_at_unix_ms, permissions).await?;
+ Ok((connections, challenges))
+}
+
+async fn update_affected_connections(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ observed_at_unix_ms: i64,
+ permissions: &BTreeSet<Box<str>>,
+) -> Result<u64, ConfigOperationError> {
+ let mut query = QueryBuilder::<Sqlite>::new(
+ "UPDATE connections SET status = 'expired', updated_at_unix_ms = \
+ MAX(updated_at_unix_ms, ",
+ );
+ query.push_bind(observed_at_unix_ms).push(
+ "), authorized_until_unix_ms = NULL WHERE status = 'active' AND EXISTS (\
+ SELECT 1 FROM connection_permissions AS permission \
+ WHERE permission.connection_id = connections.connection_id \
+ AND permission.permission_scope = 'granted'",
+ );
+ push_not_in(&mut query, permissions);
+ query.push(")");
+ query
+ .build()
+ .execute(&mut *transaction)
+ .await
+ .map(|result| result.rows_affected())
+ .map_err(|_| ConfigOperationError::Storage)
+}
+
+async fn update_affected_challenges(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ observed_at_unix_ms: i64,
+ permissions: &BTreeSet<Box<str>>,
+) -> Result<u64, ConfigOperationError> {
+ let mut query = QueryBuilder::<Sqlite>::new(
+ "UPDATE connection_auth_challenges SET state = 'expired', \
+ resolved_at_unix_ms = MAX(issued_at_unix_ms, ",
+ );
+ query.push_bind(observed_at_unix_ms).push(
+ ") WHERE state = 'pending' AND EXISTS (\
+ SELECT 1 FROM connection_permissions AS permission \
+ WHERE permission.connection_id = connection_auth_challenges.connection_id \
+ AND permission.permission_scope = 'requested'",
+ );
+ push_not_in(&mut query, permissions);
+ query.push(")");
+ query
+ .build()
+ .execute(&mut *transaction)
+ .await
+ .map(|result| result.rows_affected())
+ .map_err(|_| ConfigOperationError::Storage)
+}
+
+fn push_not_in(query: &mut QueryBuilder<Sqlite>, permissions: &BTreeSet<Box<str>>) {
+ if permissions.is_empty() {
+ return;
+ }
+ query.push(" AND permission.permission_code NOT IN (");
+ let mut separated = query.separated(", ");
+ for permission in permissions {
+ separated.push_bind(permission.as_ref());
+ }
+ separated.push_unseparated(")");
+}
+
+async fn insert_binding(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ generation: u16,
+ binding: &PersistedMetadata,
+ applied_at_unix_s: u64,
+ build: &MigrationBuildIdentity,
+) -> Result<(), ConfigOperationError> {
+ let result = sqlx::query(INSERT_CONFIG_BINDING_SQL)
+ .bind(i64::from(generation))
+ .bind(binding.normalized_config_sha256.as_slice())
+ .bind(binding.transport_public_key.as_ref())
+ .bind(binding.user_public_key.as_ref())
+ .bind(binding.discovery_public_key.as_deref())
+ .bind(i64::from(binding.config_contract_version))
+ .bind(i64::from(binding.state_contract_version))
+ .bind(i64::from(binding.operator_contract_version))
+ .bind(i64::from(binding.status_contract_version))
+ .bind(i64::try_from(applied_at_unix_s).map_err(|_| ConfigOperationError::InvalidInput)?)
+ .bind(build.service_version())
+ .bind(build.service_commit())
+ .bind(build.lib_revision())
+ .bind(build.rust_version())
+ .bind(build.target())
+ .bind(build.feature_profile())
+ .bind(i64::from(build.provider_contract_version()))
+ .execute(&mut *transaction)
+ .await
+ .map_err(|_| ConfigOperationError::Storage)?;
+ (result.rows_affected() == 1)
+ .then_some(())
+ .ok_or(ConfigOperationError::Storage)
+}
+
+fn map_apply_transaction_error(
+ error: ServiceSqliteTransactionError<ConfigOperationError>,
+) -> MycConfigApplyError {
+ if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown {
+ return MycConfigApplyError::new(MycConfigApplyErrorKind::CommitOutcomeUnknown);
+ }
+ let kind = match error.operation_error() {
+ Some(ConfigOperationError::InvalidInput) => MycConfigApplyErrorKind::InvalidInput,
+ Some(ConfigOperationError::Binding) => MycConfigApplyErrorKind::Binding,
+ Some(ConfigOperationError::PolicyConflict) => MycConfigApplyErrorKind::PolicyConflict,
+ Some(ConfigOperationError::ResourceExhausted) => MycConfigApplyErrorKind::ResourceExhausted,
+ Some(ConfigOperationError::Storage) | None => MycConfigApplyErrorKind::Transaction,
+ };
+ MycConfigApplyError::new(kind)
+}
diff --git a/src/state_host.rs b/src/state_host.rs
@@ -151,7 +151,11 @@ impl MycStateHost {
/// Returns sealed typed repository access bound to this host and metadata.
#[must_use]
pub const fn repository(&self) -> MycStateRepository<'_> {
- MycStateRepository::new(&self.host, &self.metadata)
+ MycStateRepository::new(
+ &self.host,
+ &self.metadata,
+ matches!(self.mode, MycStateHostMode::ReadWriteExisting),
+ )
}
/// Captures one governed point-in-time backup from a writable Myc host.
@@ -377,21 +381,22 @@ 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() == 8
+ && outcome.applied_count() == 9
}
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, 8)
- | (2, 7)
- | (3, 6)
- | (4, 5)
- | (5, 4)
- | (6, 3)
- | (7, 2)
- | (8, 1)
+ (MYC_STATE_BASE_SCHEMA_VERSION, 9)
+ | (2, 8)
+ | (3, 7)
+ | (4, 6)
+ | (5, 5)
+ | (6, 4)
+ | (7, 3)
+ | (8, 2)
+ | (9, 1)
| (MYC_STATE_SCHEMA_VERSION, 0)
)
}
diff --git a/src/state_metadata.rs b/src/state_metadata.rs
@@ -451,7 +451,7 @@ fn require_profile_binding(
.ok_or_else(|| MycStateMetadataError::new(MycStateMetadataErrorKind::Profile))
}
-fn normalized_config_digest(
+pub(crate) fn normalized_config_digest(
profile: MycConfigProfile,
normalized: &serde_json::Value,
) -> Result<MycNormalizedConfigDigest, MycStateMetadataError> {
@@ -582,7 +582,7 @@ fn delivery_policies(
.map_err(|_| invalid())
}
-fn expected_identities(
+pub(crate) fn expected_identities(
normalized: &serde_json::Value,
) -> Result<MycExpectedIdentities, MycStateMetadataError> {
let identity = |pointer: &str| {
diff --git a/src/state_repository.rs b/src/state_repository.rs
@@ -9,7 +9,10 @@ use radroots_service_sqlite::{
};
use sqlx::Row;
-use crate::MycStateMetadata;
+use crate::{
+ MYC_STATE_SCHEMA_VERSION, MycConfigDocumentV1, MycStateMetadata,
+ state_metadata::{expected_identities, normalized_config_digest},
+};
const READ_METADATA_SQL: &str = r#"SELECT
singleton,
@@ -57,6 +60,58 @@ const INSERT_METADATA_SQL: &str = r#"INSERT INTO myc_state_metadata (
status_contract_version
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)"#;
+const READ_LATEST_CONFIG_BINDING_SQL: &str = r#"SELECT
+ CASE
+ WHEN typeof(normalized_config_sha256) = 'blob'
+ AND length(normalized_config_sha256) = 32
+ THEN normalized_config_sha256
+ ELSE NULL
+ END AS normalized_config_sha256,
+ CASE
+ WHEN typeof(transport_public_key) = 'text'
+ AND length(CAST(transport_public_key AS BLOB)) = 64
+ THEN transport_public_key
+ ELSE NULL
+ END AS transport_public_key,
+ CASE
+ WHEN typeof(user_public_key) = 'text'
+ AND length(CAST(user_public_key AS BLOB)) = 64
+ THEN user_public_key
+ ELSE NULL
+ END AS user_public_key,
+ typeof(discovery_public_key) AS discovery_public_key_type,
+ CASE
+ WHEN typeof(discovery_public_key) = 'text'
+ AND length(CAST(discovery_public_key AS BLOB)) = 64
+ THEN discovery_public_key
+ ELSE NULL
+ END AS discovery_public_key,
+ config_contract_version,
+ state_contract_version,
+ operator_contract_version,
+ status_contract_version
+FROM myc_config_bindings
+ORDER BY generation DESC
+LIMIT 1"#;
+
+const INSERT_INITIAL_CONFIG_BINDING_SQL: &str = r#"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, metadata.normalized_config_sha256, metadata.transport_public_key,
+ metadata.user_public_key, metadata.discovery_public_key,
+ metadata.config_contract_version, 10,
+ 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
+WHERE metadata.singleton = 1"#;
+
/// Stable failure classes for typed Myc state-repository operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MycStateRepositoryErrorKind {
@@ -136,14 +191,20 @@ impl Error for MycStateRepositoryError {}
pub struct MycStateRepository<'host> {
host: &'host ServiceSqliteHost,
expected: &'host MycStateMetadata,
+ writable: bool,
}
impl<'host> MycStateRepository<'host> {
pub(crate) const fn new(
host: &'host ServiceSqliteHost,
expected: &'host MycStateMetadata,
+ writable: bool,
) -> Self {
- Self { host, expected }
+ Self {
+ host,
+ expected,
+ writable,
+ }
}
pub(crate) const fn host(&self) -> &'host ServiceSqliteHost {
@@ -154,6 +215,10 @@ impl<'host> MycStateRepository<'host> {
self.expected
}
+ pub(crate) const fn is_writable(&self) -> bool {
+ self.writable
+ }
+
/// Re-verifies the immutable Myc binding through the sealed transaction executor.
pub async fn verify_binding(&self) -> Result<(), MycStateRepositoryError> {
self.transact(false).await
@@ -168,18 +233,26 @@ impl<'host> MycStateRepository<'host> {
self.host
.transaction(move |transaction| {
Box::pin(async move {
- let actual = read_metadata(transaction).await?;
- match actual {
- Some(actual) if actual == expected => Ok(()),
- Some(_) => Err(RepositoryOperationError::Binding),
+ let birth = read_metadata(transaction).await?;
+ match birth {
+ Some(actual) if actual.same_contracts(&expected) => {}
+ Some(_) => return Err(RepositoryOperationError::Binding),
None if initialize_missing => {
insert_metadata(transaction, &expected).await?;
match read_metadata(transaction).await? {
- Some(actual) if actual == expected => Ok(()),
- Some(_) | None => Err(RepositoryOperationError::Binding),
+ Some(actual) if actual == expected => {}
+ Some(_) | None => return Err(RepositoryOperationError::Binding),
}
}
- None => Err(RepositoryOperationError::Binding),
+ None => return Err(RepositoryOperationError::Binding),
+ }
+ let latest = read_latest_config_binding(transaction).await?;
+ if latest.is_none() && initialize_missing {
+ insert_initial_config_binding(transaction).await?;
+ }
+ match read_latest_config_binding(transaction).await? {
+ Some(actual) if actual == expected => Ok(()),
+ Some(_) | None => Err(RepositoryOperationError::Binding),
}
})
})
@@ -199,14 +272,43 @@ impl fmt::Debug for MycStateRepository<'_> {
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct PersistedMetadata {
- normalized_config_sha256: [u8; 32],
- transport_public_key: Box<str>,
- user_public_key: Box<str>,
- discovery_public_key: Option<Box<str>>,
- config_contract_version: u32,
- state_contract_version: u32,
- operator_contract_version: u32,
- status_contract_version: u32,
+ pub(crate) normalized_config_sha256: [u8; 32],
+ pub(crate) transport_public_key: Box<str>,
+ pub(crate) user_public_key: Box<str>,
+ pub(crate) discovery_public_key: Option<Box<str>>,
+ pub(crate) config_contract_version: u32,
+ pub(crate) state_contract_version: u32,
+ pub(crate) operator_contract_version: u32,
+ pub(crate) status_contract_version: u32,
+}
+
+impl PersistedMetadata {
+ pub(crate) fn from_configuration(
+ configuration: &MycConfigDocumentV1,
+ ) -> Result<Self, RepositoryOperationError> {
+ let identities = expected_identities(configuration.normalized())
+ .map_err(|_| RepositoryOperationError::Binding)?;
+ let digest = normalized_config_digest(configuration.profile(), configuration.normalized())
+ .map_err(|_| RepositoryOperationError::Binding)?;
+ Ok(Self {
+ normalized_config_sha256: *digest.as_bytes(),
+ transport_public_key: identities.transport().as_hex().into(),
+ user_public_key: identities.user().as_hex().into(),
+ discovery_public_key: identities.discovery().map(|value| value.as_hex().into()),
+ config_contract_version: configuration.schema_version(),
+ state_contract_version: MYC_STATE_SCHEMA_VERSION,
+ operator_contract_version: crate::MYC_OPERATOR_CONTRACT_VERSION,
+ status_contract_version: crate::MYC_SIGNER_STATUS_CONTRACT_VERSION,
+ })
+ }
+
+ fn same_contracts(&self, other: &Self) -> bool {
+ self.config_contract_version == other.config_contract_version
+ && matches!(self.state_contract_version, 9 | 10)
+ && other.state_contract_version == MYC_STATE_SCHEMA_VERSION
+ && self.operator_contract_version == other.operator_contract_version
+ && self.status_contract_version == other.status_contract_version
+ }
}
impl From<&MycStateMetadata> for PersistedMetadata {
@@ -236,12 +338,25 @@ pub(crate) async fn require_expected_metadata(
transaction: &mut ServiceSqliteTransaction<'_>,
expected: &PersistedMetadata,
) -> Result<(), RepositoryOperationError> {
- match read_metadata(transaction).await? {
+ match read_latest_config_binding(transaction).await? {
Some(actual) if actual == *expected => Ok(()),
Some(_) | None => Err(RepositoryOperationError::Binding),
}
}
+pub(crate) async fn read_latest_config_binding(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+) -> Result<Option<PersistedMetadata>, RepositoryOperationError> {
+ let rows = sqlx::query(READ_LATEST_CONFIG_BINDING_SQL)
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| RepositoryOperationError::Storage)?;
+ if rows.len() > 1 {
+ return Err(RepositoryOperationError::Binding);
+ }
+ rows.first().map(decode_metadata_row).transpose()
+}
+
async fn read_metadata(
transaction: &mut ServiceSqliteTransaction<'_>,
) -> Result<Option<PersistedMetadata>, RepositoryOperationError> {
@@ -258,6 +373,15 @@ async fn read_metadata(
let singleton = row
.try_get::<i64, _>("singleton")
.map_err(|_| RepositoryOperationError::Binding)?;
+ let actual = decode_metadata_row(row)?;
+ (singleton == 1)
+ .then_some(Some(actual))
+ .ok_or(RepositoryOperationError::Binding)
+}
+
+fn decode_metadata_row(
+ row: &sqlx::sqlite::SqliteRow,
+) -> Result<PersistedMetadata, RepositoryOperationError> {
let normalized = row
.try_get::<Option<Vec<u8>>, _>("normalized_config_sha256")
.map_err(|_| RepositoryOperationError::Binding)?
@@ -275,7 +399,7 @@ async fn read_metadata(
"text" => Some(bounded_public_key(row, "discovery_public_key")?),
_ => return Err(RepositoryOperationError::Binding),
};
- let actual = PersistedMetadata {
+ Ok(PersistedMetadata {
normalized_config_sha256,
transport_public_key,
user_public_key,
@@ -284,10 +408,7 @@ async fn read_metadata(
state_contract_version: bounded_version(row, "state_contract_version")?,
operator_contract_version: bounded_version(row, "operator_contract_version")?,
status_contract_version: bounded_version(row, "status_contract_version")?,
- };
- (singleton == 1)
- .then_some(Some(actual))
- .ok_or(RepositoryOperationError::Binding)
+ })
}
fn bounded_public_key(
@@ -340,6 +461,18 @@ async fn insert_metadata(
.ok_or(RepositoryOperationError::Storage)
}
+async fn insert_initial_config_binding(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+) -> Result<(), RepositoryOperationError> {
+ let result = sqlx::query(INSERT_INITIAL_CONFIG_BINDING_SQL)
+ .execute(&mut *transaction)
+ .await
+ .map_err(|_| RepositoryOperationError::Storage)?;
+ (result.rows_affected() == 1)
+ .then_some(())
+ .ok_or(RepositoryOperationError::Storage)
+}
+
fn map_transaction_error(
error: ServiceSqliteTransactionError<RepositoryOperationError>,
) -> MycStateRepositoryError {
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 = 9\nadmin = 1\nstatus = 1\nprovider = 1\n"
+ "[contract_versions]\nconfig = 1\nstate = 10\nadmin = 1\nstatus = 1\nprovider = 1\n"
));
}
diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs
@@ -71,6 +71,7 @@ const SOURCES: &[&str] = &[
include_str!("../src/status_v1.rs"),
include_str!("../src/state_catalog.rs"),
include_str!("../src/state_completion.rs"),
+ include_str!("../src/state_config.rs"),
include_str!("../src/state_connection.rs"),
include_str!("../src/state_delivery.rs"),
include_str!("../src/state_discovery.rs"),
@@ -117,6 +118,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() {
"status_v1",
"state_catalog",
"state_completion",
+ "state_config",
"state_connection",
"state_delivery",
"state_discovery",
@@ -140,6 +142,9 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() {
"```compile_fail",
"[Myc API baseline](contracts/api_baselines/myc.txt)",
"Status publication and cached snapshots can be obtained only",
+ "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",
] {
assert!(README.contains(required), "README is missing `{required}`");
}
@@ -211,6 +216,11 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
"pub struct myc::MycNip46WorkError",
"pub struct myc::MycStateHost",
"pub struct myc::MycStateRepository",
+ "pub const myc::MYC_CONFIG_BINDING_MAX_GENERATIONS: u16",
+ "pub struct myc::MycConfigApplyOutcome",
+ "pub struct myc::MycConfigApplyError",
+ "pub enum myc::MycConfigApplyErrorKind",
+ "pub async fn myc::MycStateRepository<'_>::apply_configuration",
"pub struct myc::MycNip46CommitRequest",
"pub struct myc::MycNip46CommitRecord",
"pub enum myc::MycNip46CommitAdmission",
@@ -278,6 +288,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
"status_v1",
"state_catalog",
"state_completion",
+ "state_config",
"state_connection",
"state_delivery",
"state_discovery",
@@ -974,8 +985,9 @@ 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, 32);
+ assert_eq!(public_error_count, 33);
assert!(PUBLIC_API.contains("pub struct myc::MycDoctorError"));
+ assert!(PUBLIC_API.contains("pub struct myc::MycConfigApplyError"));
assert!(!PUBLIC_API.contains("pub struct myc::MycRuntimeFoundation {"));
assert!(!PUBLIC_API.contains("pub struct myc::MycStateHost {"));
}
diff --git a/tests/services_hardening_config_lifecycle.rs b/tests/services_hardening_config_lifecycle.rs
@@ -0,0 +1,826 @@
+#![forbid(unsafe_code)]
+#![cfg(any(target_os = "linux", target_os = "macos"))]
+
+use std::{
+ error::Error,
+ fs,
+ num::NonZeroU32,
+ os::unix::fs::PermissionsExt,
+ path::{Path, PathBuf},
+};
+
+use myc::{
+ MycConfigApplyErrorKind, MycConfigProfile, MycStateHostErrorKind, MycStateMetadata,
+ RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, initialize_myc_state,
+ open_myc_state_inspection, open_myc_state_read_write, parse_myc_cli_v1_from,
+ parse_myc_config_v1, resolve_myc_runtime_context,
+};
+use radroots_service_sqlite::{
+ MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, MigrationCatalog, OpenMode,
+ SchemaCatalog, ServiceDatabaseIdentity, ServiceSqliteConnectionOptions, ServiceSqliteHost,
+ ServiceSqlitePaths, initialize_database,
+};
+use radroots_storage::event::SourceGeneration;
+use sqlx::{ConnectOptions, Connection, Row, sqlite::SqliteConnectOptions};
+
+const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
+const CONFIG_SOURCE: &str = include_str!("../src/state_config.rs");
+const LIB_SOURCE: &str = include_str!("../src/lib.rs");
+
+fn runtime(root: &Path) -> myc::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 prepare(runtime: &myc::MycRuntimeContext) {
+ 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");
+}
+
+fn configuration(source: &str) -> myc::MycConfigDocumentV1 {
+ parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal).expect("configuration")
+}
+
+fn metadata(
+ runtime: &myc::MycRuntimeContext,
+ configuration: &myc::MycConfigDocumentV1,
+) -> MycStateMetadata {
+ MycStateMetadata::new(
+ runtime,
+ configuration,
+ SourceGeneration::new([0x5a; 32]).expect("generation"),
+ 1_725_000_000_000,
+ )
+ .expect("metadata")
+}
+
+fn build() -> MigrationBuildIdentity {
+ build_for_schema(myc::MYC_STATE_SCHEMA_VERSION)
+}
+
+fn build_for_schema(state_schema_version: u32) -> MigrationBuildIdentity {
+ build_for_contracts(state_schema_version, 1)
+}
+
+fn build_for_contracts(
+ state_schema_version: u32,
+ provider_contract_version: u32,
+) -> MigrationBuildIdentity {
+ MigrationBuildIdentity::new(
+ env!("CARGO_PKG_VERSION"),
+ "1111111111111111111111111111111111111111",
+ "7d7b454b4c9ed86569671993bd03ca868b676665",
+ "rustc-test",
+ "test-target",
+ "service-host",
+ 1,
+ state_schema_version,
+ 1,
+ 1,
+ provider_contract_version,
+ )
+ .expect("build")
+}
+
+#[derive(Debug)]
+struct TestInitializationError;
+
+impl std::fmt::Display for TestInitializationError {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter.write_str("test catalog initialization failed")
+ }
+}
+
+impl Error for TestInitializationError {}
+
+async fn initialize_v9(runtime: &myc::MycRuntimeContext, metadata: &MycStateMetadata) {
+ let full_migrations = myc::myc_migration_catalog().expect("full migrations");
+ let migrations = MigrationCatalog::new(full_migrations.descriptors()[..8].iter().cloned())
+ .expect("v9 migrations");
+ let full_schema = myc::myc_schema_catalog().expect("full schema");
+ let schema = SchemaCatalog::new(&migrations, full_schema.versions()[..9].iter().copied())
+ .expect("v9 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(9).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("v9 initialize");
+ let (host, outcome) = ServiceSqliteHost::open_initialized(
+ &paths,
+ &identity,
+ &migrations,
+ &schema,
+ ServiceSqliteConnectionOptions::reviewed(),
+ authority,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_000).unwrap(),
+ &build_for_schema(9),
+ &[],
+ )
+ .await
+ .expect("v9 migrations");
+ assert_eq!(outcome.final_version(), 9);
+ assert_eq!(outcome.applied_count(), 8);
+
+ 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, ?, ?, ?, ?, ?, 9, ?, ?)",
+ )
+ .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
+ .map(|_| ())
+ })
+ })
+ .await
+ .expect("v9 Myc birth binding");
+ host.close().await.expect("v9 close");
+}
+
+async fn initialize(runtime: &myc::MycRuntimeContext, metadata: &MycStateMetadata) {
+ initialize_myc_state(
+ runtime,
+ metadata,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_000).unwrap(),
+ &build(),
+ )
+ .await
+ .expect("initialize");
+}
+
+fn options(runtime: &myc::MycRuntimeContext) -> SqliteConnectOptions {
+ SqliteConnectOptions::new()
+ .filename(runtime.artifacts().state_database())
+ .create_if_missing(false)
+ .disable_statement_logging()
+}
+
+#[tokio::test]
+async fn offline_apply_appends_one_generation_and_rebinds_future_startup() {
+ let directory = tempfile::tempdir().expect("root");
+ let runtime = runtime(directory.path());
+ prepare(&runtime);
+ let current = configuration(CONFIG);
+ let current_metadata = metadata(&runtime, ¤t);
+ initialize(&runtime, ¤t_metadata).await;
+
+ let candidate_source = CONFIG.replace("level = \"info\"", "level = \"warn\"");
+ let candidate = configuration(&candidate_source);
+ let writer = open_myc_state_read_write(
+ &runtime,
+ ¤t_metadata,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(),
+ &build(),
+ )
+ .await
+ .expect("writer");
+ let second_writer = open_myc_state_read_write(
+ &runtime,
+ ¤t_metadata,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(),
+ &build(),
+ )
+ .await
+ .expect_err("exclusive writer authority must reject a concurrent daemon");
+ assert_eq!(second_writer.kind(), MycStateHostErrorKind::ReadWriteOpen);
+
+ let mismatched_build = build_for_contracts(myc::MYC_STATE_SCHEMA_VERSION, 2);
+ let mismatch = writer
+ .repository()
+ .apply_configuration(
+ ¤t,
+ &candidate,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
+ &mismatched_build,
+ )
+ .await
+ .expect_err("provider contract mismatch");
+ assert_eq!(mismatch.kind(), MycConfigApplyErrorKind::InvalidInput);
+
+ let outcome = writer
+ .repository()
+ .apply_configuration(
+ ¤t,
+ &candidate,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
+ &build(),
+ )
+ .await
+ .expect("apply");
+ assert_eq!(outcome.generation(), 2);
+ assert_eq!(outcome.revoked_connection_count(), 0);
+ assert_eq!(outcome.revoked_challenge_count(), 0);
+ assert_eq!(
+ format!("{outcome:?}"),
+ "MycConfigApplyOutcome { generation: 2, revoked_connections: 0, revoked_challenges: 0 }"
+ );
+ writer.close().await.expect("close");
+
+ let old = open_myc_state_read_write(
+ &runtime,
+ ¤t_metadata,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_003).unwrap(),
+ &build(),
+ )
+ .await
+ .expect_err("old configuration must no longer bind");
+ assert_eq!(old.kind(), MycStateHostErrorKind::Repository);
+
+ let candidate_metadata = metadata(&runtime, &candidate);
+ let writer = open_myc_state_read_write(
+ &runtime,
+ &candidate_metadata,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_003).unwrap(),
+ &build(),
+ )
+ .await
+ .expect("candidate startup");
+ let replay = writer
+ .repository()
+ .apply_configuration(
+ &candidate,
+ &candidate,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_004).unwrap(),
+ &build(),
+ )
+ .await
+ .expect("exact replay is an idempotent no-op");
+ assert_eq!(replay.generation(), 2);
+ assert_eq!(replay.revoked_connection_count(), 0);
+ assert_eq!(replay.revoked_challenge_count(), 0);
+ writer.close().await.expect("close candidate");
+
+ let inspection = open_myc_state_inspection(&runtime, &candidate_metadata)
+ .await
+ .expect("inspection");
+ let mode = inspection
+ .repository()
+ .apply_configuration(
+ &candidate,
+ ¤t,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_004).unwrap(),
+ &build(),
+ )
+ .await
+ .expect_err("inspection cannot apply");
+ assert_eq!(mode.kind(), MycConfigApplyErrorKind::InvalidMode);
+ inspection.close().await.expect("inspection close");
+
+ let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
+ .await
+ .expect("inspect database");
+ let rows = sqlx::query(
+ "SELECT generation, normalized_config_sha256 FROM myc_config_bindings ORDER BY generation",
+ )
+ .fetch_all(&mut connection)
+ .await
+ .expect("binding history");
+ assert_eq!(rows.len(), 2);
+ assert_eq!(rows[0].get::<i64, _>(0), 1);
+ assert_eq!(rows[1].get::<i64, _>(0), 2);
+ assert_eq!(
+ rows[0].get::<Vec<u8>, _>(1),
+ current_metadata.configuration_digest().as_bytes()
+ );
+ assert_eq!(
+ rows[1].get::<Vec<u8>, _>(1),
+ candidate_metadata.configuration_digest().as_bytes()
+ );
+ let birth = sqlx::query_scalar::<_, Vec<u8>>(
+ "SELECT normalized_config_sha256 FROM myc_state_metadata WHERE singleton = 1",
+ )
+ .fetch_one(&mut connection)
+ .await
+ .expect("birth binding");
+ assert_eq!(birth, current_metadata.configuration_digest().as_bytes());
+ let persisted = sqlx::query(
+ "SELECT service_version, service_commit, lib_revision, rust_version, target, \
+ feature_profile FROM myc_config_bindings ORDER BY generation",
+ )
+ .fetch_all(&mut connection)
+ .await
+ .expect("safe binding evidence");
+ assert_eq!(persisted.len(), 2);
+ let database_bytes = fs::read(runtime.artifacts().state_database()).expect("database bytes");
+ for forbidden in [
+ "wss://relay-primary.example.test/",
+ "encrypted_file",
+ "transport.key",
+ directory.path().to_str().expect("UTF-8 temporary path"),
+ ] {
+ assert!(
+ !database_bytes
+ .windows(forbidden.len())
+ .any(|window| window == forbidden.as_bytes()),
+ "configuration history persisted forbidden source material"
+ );
+ }
+ connection.close().await.expect("close database");
+}
+
+#[tokio::test]
+async fn v9_upgrade_seeds_one_v10_binding_without_rewriting_birth_evidence() {
+ let directory = tempfile::tempdir().expect("root");
+ let runtime = runtime(directory.path());
+ prepare(&runtime);
+ let current = configuration(CONFIG);
+ let current_metadata = metadata(&runtime, ¤t);
+ initialize_v9(&runtime, ¤t_metadata).await;
+
+ let writer = open_myc_state_read_write(
+ &runtime,
+ ¤t_metadata,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_010).unwrap(),
+ &build(),
+ )
+ .await
+ .expect("upgrade to v10");
+ writer.close().await.expect("close upgraded writer");
+
+ let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
+ .await
+ .expect("inspect upgrade");
+ let birth_version = sqlx::query_scalar::<_, i64>(
+ "SELECT state_contract_version FROM myc_state_metadata WHERE singleton = 1",
+ )
+ .fetch_one(&mut connection)
+ .await
+ .expect("birth version");
+ assert_eq!(birth_version, 9);
+ let binding = sqlx::query(
+ "SELECT generation, state_contract_version, applied_at_unix_s, \
+ normalized_config_sha256 FROM myc_config_bindings",
+ )
+ .fetch_one(&mut connection)
+ .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, _>("applied_at_unix_s"), 1_725_000_010);
+ assert_eq!(
+ binding.get::<Vec<u8>, _>("normalized_config_sha256"),
+ current_metadata.configuration_digest().as_bytes()
+ );
+ 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());
+ prepare(&runtime);
+ let current = configuration(CONFIG);
+ let current_metadata = metadata(&runtime, ¤t);
+ initialize(&runtime, ¤t_metadata).await;
+
+ let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
+ .await
+ .expect("database");
+ sqlx::query(
+ "INSERT INTO nip46_requests (operation_id, correlation_id, operation_nonce, \
+ request_identity_sha256, client_public_key, request_id, first_event_id, \
+ method, request_sha256, received_at_unix_ms) VALUES (?, ?, ?, ?, ?, \
+ 'config-lifecycle-job', ?, 'ping', ?, 1)",
+ )
+ .bind([0x12_u8; 32].as_slice())
+ .bind([0x21_u8; 32].as_slice())
+ .bind([0x22_u8; 32].as_slice())
+ .bind([0x23_u8; 32].as_slice())
+ .bind("7777777777777777777777777777777777777777777777777777777777777777")
+ .bind([0x24_u8; 32].as_slice())
+ .bind([0x25_u8; 32].as_slice())
+ .execute(&mut connection)
+ .await
+ .expect("request source");
+ sqlx::query(
+ "INSERT INTO delivery_jobs (job_id, source_kind, source_id, artifact_sha256, \
+ policy_mode, required_acknowledgements, max_attempts, initial_backoff_ms, \
+ maximum_backoff_ms, attempt_deadline_ms, status, created_at_unix_ms, \
+ updated_at_unix_ms, finalized_at_unix_ms) VALUES (?, 'signer_response', ?, ?, \
+ 'all_required', 1, 2, 1, 2, 2, 'pending', 1, 1, NULL)",
+ )
+ .bind([0x11_u8; 32].as_slice())
+ .bind([0x12_u8; 32].as_slice())
+ .bind([0x13_u8; 32].as_slice())
+ .execute(&mut connection)
+ .await
+ .expect("job");
+ sqlx::query(
+ "INSERT INTO delivery_targets (job_id, target_index, relay_id, required, \
+ attempt_count, status, active_attempt_id, next_attempt_at_unix_ms, \
+ updated_at_unix_ms) VALUES (?, 0, 'primary', 1, 0, 'pending', NULL, NULL, 1)",
+ )
+ .bind([0x11_u8; 32].as_slice())
+ .execute(&mut connection)
+ .await
+ .expect("target");
+ connection.close().await.expect("close database");
+
+ let writer = open_myc_state_read_write(
+ &runtime,
+ ¤t_metadata,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(),
+ &build(),
+ )
+ .await
+ .expect("writer");
+ let changed = configuration(&CONFIG.replace(
+ "wss://relay-primary.example.test/",
+ "wss://relay-primary-next.example.test/",
+ ));
+ let conflict = writer
+ .repository()
+ .apply_configuration(
+ ¤t,
+ &changed,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
+ &build(),
+ )
+ .await
+ .expect_err("retained job blocks relay mutation");
+ assert_eq!(conflict.kind(), MycConfigApplyErrorKind::PolicyConflict);
+
+ let authentication_changed = configuration(&CONFIG.replacen(
+ "authentication = \"required\"",
+ "authentication = \"disabled\"",
+ 1,
+ ));
+ let conflict = writer
+ .repository()
+ .apply_configuration(
+ ¤t,
+ &authentication_changed,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
+ &build(),
+ )
+ .await
+ .expect_err("retained job blocks relay authentication mutation");
+ assert_eq!(conflict.kind(), MycConfigApplyErrorKind::PolicyConflict);
+
+ let addition_source = CONFIG.replace(
+ "[transport]\n",
+ "[[relays]]\nid = \"tertiary\"\nurl = \"wss://relay-tertiary.example.test/\"\nread = true\nwrite = true\nrequired = false\nauthentication = \"required\"\n\n[transport]\n",
+ );
+ let addition = configuration(&addition_source);
+ let outcome = writer
+ .repository()
+ .apply_configuration(
+ ¤t,
+ &addition,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_003).unwrap(),
+ &build(),
+ )
+ .await
+ .expect("safe relay addition");
+ assert_eq!(outcome.generation(), 2);
+ writer.close().await.expect("close");
+}
+
+#[tokio::test]
+async fn identity_change_atomically_revokes_live_connections_and_pending_challenges() {
+ let directory = tempfile::tempdir().expect("root");
+ let runtime = runtime(directory.path());
+ prepare(&runtime);
+ let current = configuration(CONFIG);
+ let current_metadata = metadata(&runtime, ¤t);
+ initialize(&runtime, ¤t_metadata).await;
+
+ let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
+ .await
+ .expect("database");
+ for (id, status) in [(0x31_u8, "active"), (0x32_u8, "pending")] {
+ sqlx::query(
+ "INSERT INTO connections (connection_id, connection_nonce, client_public_key, \
+ requested_permissions_sha256, policy_generation, status, created_at_unix_ms, \
+ updated_at_unix_ms, authorized_until_unix_ms) VALUES (?, ?, ?, ?, 1, ?, 1, 1, ?)",
+ )
+ .bind([id; 32].as_slice())
+ .bind([id.saturating_add(16); 32].as_slice())
+ .bind("7777777777777777777777777777777777777777777777777777777777777777")
+ .bind([0x41_u8; 32].as_slice())
+ .bind(status)
+ .bind((status == "active").then_some(10_000_i64))
+ .execute(&mut connection)
+ .await
+ .expect("connection");
+ }
+ sqlx::query(
+ "INSERT INTO nip46_requests (operation_id, correlation_id, operation_nonce, \
+ request_identity_sha256, client_public_key, request_id, first_event_id, method, \
+ request_sha256, received_at_unix_ms) VALUES (?, ?, ?, ?, ?, 'identity-change', ?, \
+ 'connect', ?, 1)",
+ )
+ .bind([0x33_u8; 32].as_slice())
+ .bind([0x34_u8; 32].as_slice())
+ .bind([0x35_u8; 32].as_slice())
+ .bind([0x36_u8; 32].as_slice())
+ .bind("7777777777777777777777777777777777777777777777777777777777777777")
+ .bind([0x37_u8; 32].as_slice())
+ .bind([0x38_u8; 32].as_slice())
+ .execute(&mut connection)
+ .await
+ .expect("request");
+ sqlx::query(
+ "INSERT INTO connection_auth_challenges (challenge_id, challenge_nonce, \
+ connection_id, operation_id, policy_generation, challenge_url, state, \
+ issued_at_unix_ms, expires_at_unix_ms, resolved_at_unix_ms) \
+ VALUES (?, ?, ?, ?, 1, 'https://myc.example.test/challenge', 'pending', 1, 10000, NULL)",
+ )
+ .bind([0x39_u8; 32].as_slice())
+ .bind([0x3a_u8; 32].as_slice())
+ .bind([0x31_u8; 32].as_slice())
+ .bind([0x33_u8; 32].as_slice())
+ .execute(&mut connection)
+ .await
+ .expect("challenge");
+ connection.close().await.expect("close database");
+
+ let candidate = configuration(&CONFIG.replace(
+ "expected_public_key = \"2222222222222222222222222222222222222222222222222222222222222222\"",
+ "expected_public_key = \"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\"",
+ ));
+ let writer = open_myc_state_read_write(
+ &runtime,
+ ¤t_metadata,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(),
+ &build(),
+ )
+ .await
+ .expect("writer");
+ let outcome = writer
+ .repository()
+ .apply_configuration(
+ ¤t,
+ &candidate,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
+ &build(),
+ )
+ .await
+ .expect("identity apply");
+ assert_eq!(outcome.revoked_connection_count(), 2);
+ assert_eq!(outcome.revoked_challenge_count(), 1);
+ writer.close().await.expect("close writer");
+
+ let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
+ .await
+ .expect("inspect");
+ let statuses =
+ sqlx::query_scalar::<_, String>("SELECT status FROM connections ORDER BY connection_id")
+ .fetch_all(&mut connection)
+ .await
+ .expect("connection statuses");
+ assert_eq!(statuses, ["expired", "denied"]);
+ let state = sqlx::query_scalar::<_, String>(
+ "SELECT state FROM connection_auth_challenges WHERE challenge_id = ?",
+ )
+ .bind([0x39_u8; 32].as_slice())
+ .fetch_one(&mut connection)
+ .await
+ .expect("challenge state");
+ assert_eq!(state, "expired");
+ connection.close().await.expect("close inspect");
+}
+
+#[tokio::test]
+async fn permission_narrowing_revokes_only_connections_with_removed_grants() {
+ let directory = tempfile::tempdir().expect("root");
+ let runtime = runtime(directory.path());
+ prepare(&runtime);
+ let current = configuration(CONFIG);
+ let current_metadata = metadata(&runtime, ¤t);
+ initialize(&runtime, ¤t_metadata).await;
+
+ let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
+ .await
+ .expect("database");
+ for (id, permission) in [(0x51_u8, "sign_event:kind:1"), (0x52_u8, "nip04_encrypt")] {
+ sqlx::query(
+ "INSERT INTO connections (connection_id, connection_nonce, client_public_key, \
+ requested_permissions_sha256, policy_generation, status, created_at_unix_ms, \
+ updated_at_unix_ms, authorized_until_unix_ms) VALUES (?, ?, ?, ?, 1, \
+ 'active', 1, 1, 10000)",
+ )
+ .bind([id; 32].as_slice())
+ .bind([id.saturating_add(16); 32].as_slice())
+ .bind("7777777777777777777777777777777777777777777777777777777777777777")
+ .bind([id.saturating_add(32); 32].as_slice())
+ .execute(&mut connection)
+ .await
+ .expect("connection");
+ sqlx::query(
+ "INSERT INTO connection_permissions (connection_id, permission_scope, \
+ permission_code) VALUES (?, 'granted', ?)",
+ )
+ .bind([id; 32].as_slice())
+ .bind(permission)
+ .execute(&mut connection)
+ .await
+ .expect("permission");
+ }
+ connection.close().await.expect("close database");
+
+ let candidate_source = CONFIG
+ .replace(
+ "permission_ceiling = [\"nip04_decrypt\", \"nip04_encrypt\", \"nip44_decrypt\", \"nip44_encrypt\", \"sign_event:kind:1\"]",
+ "permission_ceiling = [\"nip04_decrypt\", \"nip04_encrypt\", \"nip44_decrypt\", \"nip44_encrypt\", \"sign_event:kind:2\"]",
+ )
+ .replace("allowed_sign_event_kinds = [1]", "allowed_sign_event_kinds = [2]");
+ let candidate = configuration(&candidate_source);
+ let writer = open_myc_state_read_write(
+ &runtime,
+ ¤t_metadata,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(),
+ &build(),
+ )
+ .await
+ .expect("writer");
+ let outcome = writer
+ .repository()
+ .apply_configuration(
+ ¤t,
+ &candidate,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
+ &build(),
+ )
+ .await
+ .expect("narrowing apply");
+ assert_eq!(outcome.revoked_connection_count(), 1);
+ writer.close().await.expect("close writer");
+
+ let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
+ .await
+ .expect("inspect");
+ let rows = sqlx::query("SELECT connection_id, status FROM connections ORDER BY connection_id")
+ .fetch_all(&mut connection)
+ .await
+ .expect("statuses");
+ assert_eq!(rows[0].get::<String, _>(1), "expired");
+ assert_eq!(rows[1].get::<String, _>(1), "active");
+ connection.close().await.expect("close inspect");
+}
+
+#[tokio::test]
+async fn exact_history_capacity_fails_with_resource_exhausted_without_mutation() {
+ let directory = tempfile::tempdir().expect("root");
+ let runtime = runtime(directory.path());
+ prepare(&runtime);
+ let current = configuration(CONFIG);
+ let current_metadata = metadata(&runtime, ¤t);
+ initialize(&runtime, ¤t_metadata).await;
+
+ let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
+ .await
+ .expect("database");
+ sqlx::query(
+ "WITH RECURSIVE generation(value) AS (VALUES(2) UNION ALL \
+ SELECT value + 1 FROM generation WHERE value < 1024) \
+ 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 generation.value, binding.normalized_config_sha256, \
+ binding.transport_public_key, binding.user_public_key, binding.discovery_public_key, \
+ binding.config_contract_version, binding.state_contract_version, \
+ binding.operator_contract_version, binding.status_contract_version, \
+ binding.applied_at_unix_s, binding.service_version, binding.service_commit, \
+ binding.lib_revision, binding.rust_version, binding.target, binding.feature_profile, \
+ binding.provider_contract_version FROM generation \
+ CROSS JOIN myc_config_bindings AS binding WHERE binding.generation = 1",
+ )
+ .execute(&mut connection)
+ .await
+ .expect("fill bounded history");
+ connection.close().await.expect("close database");
+
+ let writer = open_myc_state_read_write(
+ &runtime,
+ ¤t_metadata,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(),
+ &build(),
+ )
+ .await
+ .expect("writer");
+ let candidate = configuration(&CONFIG.replace("level = \"info\"", "level = \"warn\""));
+ let error = writer
+ .repository()
+ .apply_configuration(
+ ¤t,
+ &candidate,
+ MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(),
+ &build(),
+ )
+ .await
+ .expect_err("full history");
+ assert_eq!(error.kind(), MycConfigApplyErrorKind::ResourceExhausted);
+ assert_eq!(error.code(), "resource_exhausted");
+ assert!(Error::source(&error).is_none());
+ writer.close().await.expect("close writer");
+
+ let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime))
+ .await
+ .expect("inspect");
+ let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM myc_config_bindings")
+ .fetch_one(&mut connection)
+ .await
+ .expect("count");
+ assert_eq!(count, 1024);
+ connection.close().await.expect("close inspect");
+}
+
+#[test]
+fn configuration_lifecycle_surface_is_sealed_and_diagnostics_are_safe() {
+ assert!(LIB_SOURCE.contains("mod state_config;"));
+ assert!(!LIB_SOURCE.contains("pub mod state_config;"));
+ for forbidden in [
+ "pub transaction:",
+ "pub connection:",
+ "pub pool:",
+ "credential_reference",
+ "envelope_path",
+ "relay_url TEXT",
+ "DELETE FROM myc_config_bindings",
+ "UPDATE myc_config_bindings",
+ ] {
+ assert!(
+ !CONFIG_SOURCE.contains(forbidden),
+ "forbidden configuration-history surface `{forbidden}`"
+ );
+ }
+ for kind in [
+ MycConfigApplyErrorKind::InvalidMode,
+ MycConfigApplyErrorKind::InvalidInput,
+ MycConfigApplyErrorKind::Binding,
+ MycConfigApplyErrorKind::PolicyConflict,
+ MycConfigApplyErrorKind::ResourceExhausted,
+ MycConfigApplyErrorKind::Transaction,
+ MycConfigApplyErrorKind::CommitOutcomeUnknown,
+ ] {
+ let rendered = format!("{kind:?} {}", kind.code());
+ for secret in ["relay-primary", "credential", "state.sqlite", "/var/lib"] {
+ assert!(!rendered.contains(secret));
+ }
+ }
+ let error = MycConfigApplyErrorKind::PolicyConflict;
+ assert_eq!(error.code(), "config_apply_policy_conflict");
+ let _source_free: fn(&myc::MycConfigApplyError) -> Option<&(dyn Error + 'static)> =
+ Error::source;
+}
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": 9,
+ "state": 10,
"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 = 9
+ state_contract_version = 10
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
@@ -17,8 +17,10 @@ use myc::{
MYC_STATE_SCHEMA_VERSION_7_SHA256, MYC_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256,
MYC_STATE_SCHEMA_VERSION_8_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_8_SHA256,
MYC_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_9_OBJECT_COUNT,
- MYC_STATE_SCHEMA_VERSION_9_SHA256, MycStateCatalogErrorKind, myc_migration_catalog,
- myc_schema_catalog, validate_myc_state_catalogs,
+ 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,
};
use radroots_service_sqlite::{
MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaDigest,
@@ -30,13 +32,13 @@ const LIB_SOURCE: &str = include_str!("../src/lib.rs");
const MANIFEST: &str = include_str!("../Cargo.toml");
#[test]
-fn schema_v1_through_v9_and_all_migrations_have_exact_literal_identities() {
+fn schema_v1_through_v10_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, 9);
- assert_eq!(migrations.descriptors().len(), 8);
+ assert_eq!(MYC_STATE_SCHEMA_VERSION, 10);
+ assert_eq!(migrations.descriptors().len(), 9);
let metadata = &migrations.descriptors()[0];
assert_eq!(metadata.target_version(), 2);
assert_eq!(metadata.name().as_str(), "create_myc_state_metadata");
@@ -102,13 +104,23 @@ fn schema_v1_through_v9_and_all_migrations_have_exact_literal_identities() {
response.checksum().as_bytes(),
&MYC_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256
);
- assert_eq!(migrations.current_version(), 9);
+ let configuration = &migrations.descriptors()[8];
+ assert_eq!(configuration.target_version(), 10);
+ assert_eq!(
+ configuration.name().as_str(),
+ "create_configuration_binding_history"
+ );
+ assert_eq!(
+ configuration.checksum().as_bytes(),
+ &MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256
+ );
+ assert_eq!(migrations.current_version(), 10);
assert_eq!(
migrations.digest().as_bytes(),
&MYC_MIGRATION_CATALOG_SHA256
);
- assert_eq!(schema.versions().len(), 9);
+ assert_eq!(schema.versions().len(), 10);
assert_eq!(schema.versions()[0].version(), 1);
assert_eq!(
schema.versions()[0].object_count(),
@@ -198,6 +210,16 @@ fn schema_v1_through_v9_and_all_migrations_have_exact_literal_identities() {
schema.versions()[8].digest().as_bytes(),
&MYC_STATE_SCHEMA_VERSION_9_SHA256
);
+ assert_eq!(schema.versions()[9].version(), 10);
+ assert_eq!(
+ schema.versions()[9].object_count(),
+ MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT
+ );
+ assert_eq!(schema.versions()[9].object_count(), 63);
+ assert_eq!(
+ schema.versions()[9].digest().as_bytes(),
+ &MYC_STATE_SCHEMA_VERSION_10_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");
@@ -208,7 +230,7 @@ fn schema_v1_through_v9_and_all_migrations_have_exact_literal_identities() {
);
assert_eq!(
hex::encode(MYC_MIGRATION_CATALOG_SHA256),
- "8604c51ea6f16f0aa37a63eee09c600a0b7031efbc8e5e0e41b5f0f53c48e70b"
+ "5d6e0c8b832e66715abe176133d4433d52e6d18125aefdbc9d550515fd2121f2"
);
assert_eq!(
hex::encode(MYC_STATE_SCHEMA_VERSION_1_SHA256),
@@ -220,7 +242,7 @@ fn schema_v1_through_v9_and_all_migrations_have_exact_literal_identities() {
);
assert_eq!(
hex::encode(MYC_STATE_SCHEMA_CATALOG_SHA256),
- "632c8d5216d2a541fd28ae3a2cae8069c58bef11fe81d2f04399a77cf8359ea7"
+ "1f88c79f86ae472489f62ddc9661a681dcfb1ed7ff723a07d97ba776b036a25a"
);
assert_eq!(
hex::encode(MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256),
@@ -278,6 +300,14 @@ fn schema_v1_through_v9_and_all_migrations_have_exact_literal_identities() {
hex::encode(MYC_STATE_SCHEMA_VERSION_9_SHA256),
"eee76f4ff0bd2dc2c061ae384e00de16c5f5efc4b6edd0ac7f73cad83a991ff7"
);
+ assert_eq!(
+ hex::encode(MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256),
+ "28423ebb59f4b26223307b74a7e12905ca4818c01a655203ee8d63c911f44489"
+ );
+ assert_eq!(
+ hex::encode(MYC_STATE_SCHEMA_VERSION_10_SHA256),
+ "b77ed23afa39ff45dda250faa1ebfc0587c34462658fc49fb7b2fbc8909ba303"
+ );
}
#[test]
@@ -336,9 +366,15 @@ fn independent_validator_rejects_migration_or_schema_drift() {
let v8 = SchemaVersionCatalog::new(8, [object.clone()], v8_digest).expect("schema v8");
let v9_digest =
SchemaVersionCatalog::computed_digest(9, [object.clone()]).expect("schema-v9 digest");
- let v9 = SchemaVersionCatalog::new(9, [object], v9_digest).expect("schema v9");
- let schema = SchemaCatalog::new(&expected_migrations, [v1, v2, v3, v4, v5, v6, v7, v8, v9])
- .expect("drift schema catalog");
+ 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 schema = SchemaCatalog::new(
+ &expected_migrations,
+ [v1, v2, v3, v4, v5, v6, v7, v8, v9, v10],
+ )
+ .expect("drift schema catalog");
assert_eq!(
validate_myc_state_catalogs(&expected_migrations, &schema)
.expect_err("schema drift")
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(), 8);
+ assert_eq!(migrations.len(), 9);
assert_eq!(migrations[0].get::<i64, _>(0), 2);
assert_eq!(
migrations[0].get::<String, _>(1),
@@ -161,6 +161,11 @@ async fn initialization_migrates_and_binds_exact_metadata_before_inspection() {
migrations[7].get::<String, _>(1),
"create_nip46_atomic_response"
);
+ assert_eq!(migrations[8].get::<i64, _>(0), 10);
+ assert_eq!(
+ migrations[8].get::<String, _>(1),
+ "create_configuration_binding_history"
+ );
let binding = sqlx::query(
"SELECT normalized_config_sha256, transport_public_key, user_public_key, \
discovery_public_key, config_contract_version, state_contract_version, \
@@ -283,7 +288,9 @@ async fn repository_boundary_is_sealed_typed_redacted_and_network_free() {
"BEGIN ",
"COMMIT",
"ROLLBACK",
- "provider",
+ "MycProvider",
+ "provider_credential",
+ "provider_envelope",
"relay",
"reqwest",
"nostr::",