myc

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

commit d156f91c18f5c8a7a76570a06470c826c88019ed
parent 1be5b091edbe73d5509a456edb827f9102721cb2
Author: triesap <tyson@radroots.org>
Date:   Sat, 22 Aug 2026 02:33:44 +0000

feat(myc): bind NIP-46 operation completion

Diffstat:
MCargo.toml | 2+-
MREADME | 16++++++++++++++--
Mcontracts/api_baselines/myc.txt | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/services_hardening/native_release.v1.json | 2+-
Acontracts/services_hardening/nip46_completion.v1.json | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mradroots.service.source-lock.v1.toml | 2+-
Msrc/lib.rs | 13+++++++++++--
Msrc/nip46_wave_080_a.rs | 30+++++++++++++++---------------
Asrc/nip46_wave_080_b.rs | 376+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/nip46_work.rs | 22++++++++++++++++++++++
Msrc/state_catalog.rs | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Asrc/state_completion.rs | 825+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/state_connection.rs | 20++++++++++++++++++++
Msrc/state_host.rs | 15++++++++-------
Mtests/package_boundary.rs | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/services_hardening_native_release.rs | 4++--
Mtests/services_hardening_signer_request_state.rs | 7+++++--
Mtests/services_hardening_state_catalog.rs | 55++++++++++++++++++++++++++++++++++++++++++++-----------
18 files changed, 1711 insertions(+), 53 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -17,7 +17,7 @@ resolver = "3" service = "myc" host_feature_profile = "service-host" config_contract_version = 1 -state_contract_version = 7 +state_contract_version = 8 admin_contract_version = 1 status_contract_version = 1 provider_contract_version = 1 diff --git a/README b/README @@ -75,12 +75,24 @@ 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-v7 migrations, binds the normalized configuration, expected +through schema-v8 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 v6 prefix; read-only inspection requires the current +resume any exact v1 through v7 prefix; read-only inspection requires the current catalog and exact immutable Myc binding. +Schema v8 adds immutable NIP-46 operation-completion evidence. The Step 147 +integration checkpoint binds each durable request to its stable operation and +correlation identities, terminal connect authority or exact active session, +safe completion reason, and any independently verified inner signed-event +bytes and digest. Logout revocation and completion insertion share one +transaction, exact replay returns the stored decision, and failed transactions +expose neither effect. Protected provider output is never persisted. This is an +integration component, not the final production response boundary: Step 148 +must compose it with the outer signed response bytes, immutable relay targets, +and initial outbox state in one transaction before RCLD-RSHR-080 can be +promoted to `master`. + The public Myc repository exposes no raw pool, connection, transaction-control handle, path, or SQL. Binding and request-admission mutations execute only inside the shared `ServiceSqliteTransaction` runner. Provider and relay work diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt @@ -299,6 +299,18 @@ pub myc::MycNip46AdmissionErrorKind::TagsTooLarge pub myc::MycNip46AdmissionErrorKind::TooManyRequestParameters pub myc::MycNip46AdmissionErrorKind::TooManyTagElements pub myc::MycNip46AdmissionErrorKind::TooManyTags +pub enum myc::MycNip46CommitAdmission +pub myc::MycNip46CommitAdmission::Committed(myc::MycNip46CommitRecord) +pub myc::MycNip46CommitAdmission::ExactReplay(myc::MycNip46CommitRecord) +impl myc::MycNip46CommitAdmission +pub const fn myc::MycNip46CommitAdmission::record(&self) -> &myc::MycNip46CommitRecord +impl core::fmt::Debug for myc::MycNip46CommitAdmission +pub fn myc::MycNip46CommitAdmission::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub enum myc::MycNip46CommitErrorKind +pub myc::MycNip46CommitErrorKind::InvalidBinding +pub myc::MycNip46CommitErrorKind::InvalidTime +impl myc::MycNip46CommitErrorKind +pub const fn myc::MycNip46CommitErrorKind::code(self) -> &'static str pub enum myc::MycNip46EncryptionContext pub myc::MycNip46EncryptionContext::Nip04 pub myc::MycNip46EncryptionContext::Nip44V2 @@ -308,6 +320,10 @@ pub myc::MycNip46ReplayDisposition::ConflictingRequestReuse pub myc::MycNip46ReplayDisposition::DistinctRequest pub myc::MycNip46ReplayDisposition::DuplicateEvent pub myc::MycNip46ReplayDisposition::ExactRequestReplay +pub enum myc::MycNip46SessionEffect +pub myc::MycNip46SessionEffect::ConnectionAdmitted +pub myc::MycNip46SessionEffect::ConnectionRevoked +pub myc::MycNip46SessionEffect::None pub enum myc::MycNip46VerificationErrorKind pub myc::MycNip46VerificationErrorKind::AuthoredTimeRejected pub myc::MycNip46VerificationErrorKind::InvalidEventId @@ -959,6 +975,31 @@ pub fn myc::MycNip46ClientPublicKey::as_hex(&self) -> &str pub fn myc::MycNip46ClientPublicKey::new(&str) -> core::result::Result<Self, myc::MycSignerRequestError> impl core::fmt::Debug for myc::MycNip46ClientPublicKey pub fn myc::MycNip46ClientPublicKey::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycNip46CommitError +impl myc::MycNip46CommitError +pub const fn myc::MycNip46CommitError::code(self) -> &'static str +pub const fn myc::MycNip46CommitError::kind(self) -> myc::MycNip46CommitErrorKind +impl core::error::Error for myc::MycNip46CommitError +impl core::fmt::Debug for myc::MycNip46CommitError +pub fn myc::MycNip46CommitError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for myc::MycNip46CommitError +pub fn myc::MycNip46CommitError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycNip46CommitRecord +impl myc::MycNip46CommitRecord +pub const fn myc::MycNip46CommitRecord::artifact_sha256(&self) -> core::option::Option<&[u8; 32]> +pub const fn myc::MycNip46CommitRecord::completed_at(&self) -> myc::MycConnectionTimeUnixMs +pub const fn myc::MycNip46CommitRecord::connection_id(&self) -> core::option::Option<myc::MycConnectionId> +pub const fn myc::MycNip46CommitRecord::correlation_id(&self) -> myc::MycSignerCorrelationId +pub const fn myc::MycNip46CommitRecord::method(&self) -> myc::MycSignerRequestMethod +pub const fn myc::MycNip46CommitRecord::operation_id(&self) -> myc::MycSignerOperationId +pub const fn myc::MycNip46CommitRecord::session_effect(&self) -> myc::MycNip46SessionEffect +impl core::fmt::Debug for myc::MycNip46CommitRecord +pub fn myc::MycNip46CommitRecord::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycNip46CommitRequest +impl myc::MycNip46CommitRequest +pub fn myc::MycNip46CommitRequest::new(&myc::MycNip46Work, core::option::Option<&myc::MycConnectionDecisionRecord>, core::option::Option<&myc::MycVerifiedProviderResponse>, myc::MycConnectionTimeUnixMs) -> core::result::Result<Self, myc::MycNip46CommitError> +impl core::fmt::Debug for myc::MycNip46CommitRequest +pub fn myc::MycNip46CommitRequest::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycNip46ConnectionIdentity(_) impl myc::MycNip46ConnectionIdentity pub const fn myc::MycNip46ConnectionIdentity::as_bytes(&self) -> &[u8; 32] @@ -1323,6 +1364,7 @@ pub async fn myc::MycStateRepository<'_>::authorize_challenge(&self, myc::MycAut pub async fn myc::MycStateRepository<'_>::decide_pending_connection(&self, myc::MycSignerOperationId, myc::MycConnectionId, myc::MycConnectionPolicyGeneration, myc::MycConnectionTimeUnixMs, myc::MycAuditCorrelationId, myc::MycConnectionOperatorDecision) -> core::result::Result<myc::MycConnectionRecord, myc::MycStateRepositoryError> pub async fn myc::MycStateRepository<'_>::expire_connection(&self, myc::MycConnectionId, myc::MycConnectionPolicyGeneration, myc::MycConnectionTimeUnixMs, myc::MycAuditCorrelationId) -> core::result::Result<myc::MycConnectionRecord, myc::MycStateRepositoryError> pub async fn myc::MycStateRepository<'_>::issue_authorization_challenge(&self, &myc::MycAuthorizationChallengeRequest) -> core::result::Result<myc::MycAuthorizationChallengeAdmission, myc::MycStateRepositoryError> +pub async fn myc::MycStateRepository<'_>::read_connection_decision(&self, myc::MycSignerOperationId) -> core::result::Result<myc::MycConnectionDecisionRecord, myc::MycStateRepositoryError> impl myc::MycStateRepository<'_> pub async fn myc::MycStateRepository<'_>::admit_signer_request(&self, &myc::MycSignerRequest) -> core::result::Result<myc::MycSignerRequestAdmission, myc::MycStateRepositoryError> impl myc::MycStateRepository<'_> @@ -1339,6 +1381,8 @@ pub async fn myc::MycStateRepository<'_>::promote_delivered_discovery_state(&sel pub async fn myc::MycStateRepository<'_>::read_discovery_document_for_job(&self, myc::MycDeliveryJobId) -> core::result::Result<core::option::Option<myc::MycDiscoveryDocumentRecord>, myc::MycStateRepositoryError> pub async fn myc::MycStateRepository<'_>::read_discovery_publication_state(&self) -> core::result::Result<core::option::Option<myc::MycDiscoveryPublicationState>, myc::MycStateRepositoryError> impl myc::MycStateRepository<'_> +pub async fn myc::MycStateRepository<'_>::commit_nip46_operation(&self, &myc::MycNip46CommitRequest) -> core::result::Result<myc::MycNip46CommitAdmission, myc::MycStateRepositoryError> +impl myc::MycStateRepository<'_> pub async fn myc::MycStateRepository<'_>::compact_governance_evidence(&self, myc::MycConnectionTimeUnixMs, myc::MycGovernanceCompactionPolicy, myc::MycAuditCorrelationId) -> core::result::Result<myc::MycGovernanceCompactionOutcome, myc::MycStateRepositoryError> pub async fn myc::MycStateRepository<'_>::read_audit_page(&self, myc::MycAuditPageLimit, core::option::Option<u64>, core::option::Option<u64>) -> core::result::Result<myc::MycAuditPage, myc::MycStateRepositoryError> impl<'host> myc::MycStateRepository<'host> @@ -1466,6 +1510,9 @@ pub const myc::MYC_STATE_SCHEMA_VERSION_6_SHA256: [u8; 32] pub const myc::MYC_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256: [u8; 32] pub const myc::MYC_STATE_SCHEMA_VERSION_7_OBJECT_COUNT: u32 pub const myc::MYC_STATE_SCHEMA_VERSION_7_SHA256: [u8; 32] +pub const myc::MYC_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256: [u8; 32] +pub const myc::MYC_STATE_SCHEMA_VERSION_8_OBJECT_COUNT: u32 +pub const myc::MYC_STATE_SCHEMA_VERSION_8_SHA256: [u8; 32] pub const myc::MYC_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize pub const myc::MYC_WRAPPING_CREDENTIAL_CONTRACT_VERSION: u32 pub fn myc::admit_myc_nip46_event(myc::MycNip46AdmissionLimits, &[u8]) -> core::result::Result<myc::MycBoundedNip46Event, myc::MycNip46AdmissionError> 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": 7, + "state": 8, "admin": 1, "status": 1, "provider": 1 diff --git a/contracts/services_hardening/nip46_completion.v1.json b/contracts/services_hardening/nip46_completion.v1.json @@ -0,0 +1,61 @@ +{ + "schema": "radroots.myc.nip46-completion.v1", + "contract_version": 1, + "step": 147, + "schema_version": 8, + "authority": { + "request": "existing_durable_nip46_request", + "connection": "existing_validated_connection_or_terminal_connect_decision", + "provider_result": "independently_verified_exact_operation_result", + "transaction": "existing_service_sqlite_transaction_runner", + "time": "caller_injected_positive_unix_milliseconds" + }, + "atomic_commit": [ + "stable_operation_and_correlation_identity", + "terminal_request_outcome_and_safe_reason", + "connection_admission_or_logout_session_revocation", + "immutable_completion_audit_evidence", + "exact_verified_inner_signed_event_bytes_and_sha256_when_present" + ], + "artifact_policy": { + "signed_event": "exact_provider_verified_canonical_bytes", + "protected_provider_output": "not_persisted", + "maximum_bytes": 1048576, + "debug": "redacted" + }, + "replay": { + "same_bound_commit": "exact_replay", + "mismatched_commit": "fail_closed", + "failed_transaction": "no_session_or_completion_effect" + }, + "production_composition": { + "step147_checkpoint": "integration_only_not_promotable", + "step147_component": "must_be_composed_before_master_promotion", + "commit_owner": 148, + "required_same_transaction_members": [ + "step147_completion", + "outer_signed_nip46_response", + "immutable_relay_target_set", + "outbox_and_initial_attempt_state" + ], + "promotion_owner": 151 + }, + "deferrals": { + "outer_signed_nip46_response": 148, + "immutable_relay_target_set": 148, + "outbox_and_initial_attempt_state": 148, + "relay_io": "after_local_commit_only", + "nix": "deferred_and_unclaimed", + "oci": "deferred_and_unclaimed" + }, + "forbidden": [ + "provider_execution_inside_transaction", + "relay_io_inside_transaction", + "alternate_sqlite_authority", + "protected_payload_persistence", + "ambient_clock", + "ambient_entropy", + "task_spawn", + "outbox_creation" + ] +} diff --git a/radroots.service.source-lock.v1.toml b/radroots.service.source-lock.v1.toml @@ -14,7 +14,7 @@ host_feature_profile = "service-host" [contract_versions] config = 1 -state = 7 +state = 8 admin = 1 status = 1 provider = 1 diff --git a/src/lib.rs b/src/lib.rs @@ -9,6 +9,8 @@ mod nip46_replay; mod nip46_verification; #[cfg(all(test, any(target_os = "linux", target_os = "macos")))] mod nip46_wave_080_a; +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] +mod nip46_wave_080_b; mod nip46_work; mod provider_contract; mod provider_credential; @@ -18,6 +20,7 @@ mod provider_verification; mod runtime_context; mod runtime_foundation; mod state_catalog; +mod state_completion; mod state_connection; mod state_delivery; mod state_discovery; @@ -115,8 +118,14 @@ pub use state_catalog::{ MYC_STATE_SCHEMA_VERSION_5_SHA256, MYC_STATE_SCHEMA_VERSION_6_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_6_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_6_SHA256, MYC_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_7_OBJECT_COUNT, - MYC_STATE_SCHEMA_VERSION_7_SHA256, MycStateCatalogError, MycStateCatalogErrorKind, - myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs, + 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, + 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_connection::{ MYC_AUTHORIZATION_CHALLENGE_URL_MAX_BYTES, MYC_CONNECTION_PERMISSION_MAX_COUNT, diff --git a/src/nip46_wave_080_a.rs b/src/nip46_wave_080_a.rs @@ -38,17 +38,17 @@ use crate::{ const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); const CORPUS: &str = include_str!("../contracts/services_hardening/nip46_wave_080_a.v1.json"); -const OBSERVED_AT_SECONDS: u64 = 1_725_000_000; -const RECEIVED_AT_MS: u64 = 1_725_000_000_000; -const PROVIDER_DEADLINE_MS: u64 = RECEIVED_AT_MS + 120_000; +pub(crate) const OBSERVED_AT_SECONDS: u64 = 1_725_000_000; +pub(crate) const RECEIVED_AT_MS: u64 = 1_725_000_000_000; +pub(crate) const PROVIDER_DEADLINE_MS: u64 = RECEIVED_AT_MS + 120_000; #[derive(Clone, Copy)] -enum Encryption { +pub(crate) enum Encryption { Nip04, Nip44V2, } -fn keys(seed: u8) -> Keys { +pub(crate) fn keys(seed: u8) -> Keys { Keys::parse(&format!("{seed:02x}{}", "00".repeat(31))).expect("test keys") } @@ -67,7 +67,7 @@ fn configuration_source() -> String { .replacen("max_attempts = 10", "max_attempts = 2", 1) } -fn configuration() -> MycConfigDocumentV1 { +pub(crate) fn configuration() -> MycConfigDocumentV1 { parse_myc_config_v1( configuration_source().as_bytes(), MycConfigProfile::RepoLocal, @@ -75,7 +75,7 @@ fn configuration() -> MycConfigDocumentV1 { .expect("wave configuration") } -fn runtime(root: &Path) -> crate::MycRuntimeContext { +pub(crate) fn runtime(root: &Path) -> crate::MycRuntimeContext { let invocation = parse_myc_cli_v1_from([ "myc", "--profile", @@ -94,7 +94,7 @@ fn runtime(root: &Path) -> crate::MycRuntimeContext { .expect("runtime context") } -fn metadata(runtime: &crate::MycRuntimeContext) -> MycStateMetadata { +pub(crate) fn metadata(runtime: &crate::MycRuntimeContext) -> MycStateMetadata { MycStateMetadata::new( runtime, &configuration(), @@ -104,7 +104,7 @@ fn metadata(runtime: &crate::MycRuntimeContext) -> MycStateMetadata { .expect("state metadata") } -fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) { +pub(crate) fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) { let applied_at = MigrationAppliedAtUnixSeconds::new(OBSERVED_AT_SECONDS).expect("migration time"); let build = MigrationBuildIdentity::new( @@ -180,7 +180,7 @@ fn decrypt_work( } #[allow(clippy::too_many_arguments)] -fn prepared_request( +pub(crate) fn prepared_request( config: &MycConfigDocumentV1, client: &Keys, request_id: &str, @@ -212,7 +212,7 @@ fn prepared_request( .expect("prepared request") } -fn connect_request() -> Request { +pub(crate) fn connect_request() -> Request { Request::from_parts( Method::Connect, vec![ @@ -224,7 +224,7 @@ fn connect_request() -> Request { .expect("connect request") } -fn unsigned_sign_event() -> ConnectUnsignedEvent { +pub(crate) fn unsigned_sign_event() -> ConnectUnsignedEvent { let event = NostrUnsignedEvent::new( keys(3).public_key(), Timestamp::from_secs(OBSERVED_AT_SECONDS), @@ -235,15 +235,15 @@ fn unsigned_sign_event() -> ConnectUnsignedEvent { ConnectUnsignedEvent::from_json(&event.as_json()).expect("unsigned event") } -fn permissions() -> MycConnectionPermissionSet { +pub(crate) fn permissions() -> MycConnectionPermissionSet { MycConnectionPermissionSet::new(&[MycConnectionPermission::SignEvent(1)]).expect("permissions") } -fn connection_time(value: u64) -> MycConnectionTimeUnixMs { +pub(crate) fn connection_time(value: u64) -> MycConnectionTimeUnixMs { MycConnectionTimeUnixMs::new(value).expect("connection time") } -fn untrusted_response( +pub(crate) fn untrusted_response( operation: &crate::MycProviderOperation, outer_correlation_id: String, result: WireProviderResult, diff --git a/src/nip46_wave_080_b.rs b/src/nip46_wave_080_b.rs @@ -0,0 +1,376 @@ +//! Native test-only transaction qualification for RCLD-RSHR-080 wave 080-b. + +use std::{error::Error as _, fs, os::unix::fs::PermissionsExt}; + +use nostr::UnsignedEvent as NostrUnsignedEvent; +use radroots_nostr_connect::message::Request; +use sha2::{Digest, Sha256}; +use sqlx::{ConnectOptions as _, Connection as _, Row as _, sqlite::SqliteConnectOptions}; + +use crate::{ + MycAuditCorrelationId, MycConnectionAdmissionPolicy, MycConnectionNonce, + MycConnectionOperatorDecision, MycConnectionPolicyGeneration, MycLocalSignerUntrustedResponse, + MycNip46CommitAdmission, MycNip46CommitRequest, MycNip46SessionEffect, + MycProviderDeadlineUnixMs, MycProviderResponseObservedAtUnixMs, MycProviderRole, + MycRateRelayId, MycSignerRequestAdmission, MycSignerRequestMethod, MycStateRepositoryErrorKind, + initialize_myc_state, open_myc_state_read_write, prepare_myc_nip46_work, + provider_local_signer::{ProtectedWireHex, WireProviderResult}, +}; + +use super::nip46_wave_080_a::{ + Encryption, OBSERVED_AT_SECONDS, PROVIDER_DEADLINE_MS, RECEIVED_AT_MS, configuration, + connect_request, connection_time, keys, metadata, migration_evidence, permissions, + prepared_request, runtime, unsigned_sign_event, untrusted_response, +}; + +async fn active_connection( + repository: &crate::MycStateRepository<'_>, + config: &crate::MycConfigDocumentV1, +) -> ( + crate::MycNip46Work, + crate::MycConnectionRecord, + crate::MycConnectionDecisionRecord, +) { + let prepared = prepared_request( + config, + &keys(10), + "step147-connect", + connect_request(), + Encryption::Nip44V2, + OBSERVED_AT_SECONDS + 20, + 20, + RECEIVED_AT_MS + 20, + ); + let admitted = repository + .admit_signer_request(prepared.signer_request()) + .await + .expect("connect request admission"); + let work = prepare_myc_nip46_work( + prepared, + admitted.record().clone(), + None, + config.provider_contract(), + connection_time(RECEIVED_AT_MS + 1_000), + None, + ) + .expect("connect work"); + let connection_request = work + .connection_admission_request( + MycConnectionPolicyGeneration::new(1).expect("policy generation"), + MycConnectionNonce::from_injected_entropy([0x51; 32]), + connection_time(RECEIVED_AT_MS + 1_000), + None, + MycConnectionAdmissionPolicy::ExplicitApproval, + MycRateRelayId::new("primary").expect("relay ID"), + ) + .expect("connection request"); + let pending = repository + .admit_connection(&connection_request) + .await + .expect("pending connection"); + let record = pending.record().expect("pending record"); + let active = repository + .decide_pending_connection( + record.operation_id(), + record.connection().expect("connection").id(), + MycConnectionPolicyGeneration::new(1).expect("policy generation"), + connection_time(RECEIVED_AT_MS + 2_000), + MycAuditCorrelationId::new([0x52; 32]), + MycConnectionOperatorDecision::Approve { + granted_permissions: permissions(), + authorized_until: Some(connection_time(RECEIVED_AT_MS + 100_000)), + }, + ) + .await + .expect("approved connection"); + let decision = repository + .read_connection_decision(record.operation_id()) + .await + .expect("terminal connect decision"); + (work, active, decision) +} + +#[tokio::test] +async fn verified_signed_artifact_commits_exactly_once_without_outbox_state() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path()); + fs::create_dir_all(runtime.context().paths().state()).expect("state directory"); + fs::set_permissions( + runtime.context().paths().state(), + fs::Permissions::from_mode(0o700), + ) + .expect("state mode"); + let metadata = metadata(&runtime); + let config = configuration(); + let (applied_at, build) = migration_evidence(); + initialize_myc_state(&runtime, &metadata, applied_at, &build) + .await + .expect("state initialization"); + let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("writable state"); + let repository = host.repository(); + let (connect_work, active, connect_decision) = active_connection(&repository, &config).await; + let connect_commit = MycNip46CommitRequest::new( + &connect_work, + Some(&connect_decision), + None, + connection_time(RECEIVED_AT_MS + 2_001), + ) + .expect("connect completion"); + let invalid_binding = MycNip46CommitRequest::new( + &connect_work, + None, + None, + connection_time(RECEIVED_AT_MS + 2_001), + ) + .expect_err("connect completion requires its terminal decision"); + assert_eq!( + invalid_binding.kind(), + crate::MycNip46CommitErrorKind::InvalidBinding + ); + assert!(invalid_binding.source().is_none()); + assert!(!format!("{invalid_binding:?}").contains("step147-connect")); + let invalid_time = MycNip46CommitRequest::new( + &connect_work, + Some(&connect_decision), + None, + connection_time(RECEIVED_AT_MS), + ) + .expect_err("completion time precedes request admission"); + assert_eq!( + invalid_time.kind(), + crate::MycNip46CommitErrorKind::InvalidTime + ); + assert!(invalid_time.source().is_none()); + let before_terminal_decision = MycNip46CommitRequest::new( + &connect_work, + Some(&connect_decision), + None, + connection_time(RECEIVED_AT_MS + 1_500), + ) + .expect_err("completion time precedes terminal decision"); + assert_eq!( + before_terminal_decision.kind(), + crate::MycNip46CommitErrorKind::InvalidTime + ); + assert_eq!( + repository + .commit_nip46_operation(&connect_commit) + .await + .expect("connect completion commit") + .record() + .session_effect(), + MycNip46SessionEffect::ConnectionAdmitted + ); + + let prepared = prepared_request( + &config, + &keys(10), + "step147-sign", + Request::SignEvent(unsigned_sign_event()), + Encryption::Nip44V2, + OBSERVED_AT_SECONDS + 21, + 21, + RECEIVED_AT_MS + 21, + ); + let admitted = repository + .admit_signer_request(prepared.signer_request()) + .await + .expect("sign request admission"); + assert!(matches!(admitted, MycSignerRequestAdmission::Admitted(_))); + let work = prepare_myc_nip46_work( + prepared, + admitted.record().clone(), + Some(active), + config.provider_contract(), + connection_time(RECEIVED_AT_MS + 3_000), + Some(MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("provider deadline")), + ) + .expect("sign work"); + let operation = work.provider_operation().expect("provider operation"); + let unsigned: NostrUnsignedEvent = + serde_json::from_slice(operation.input().bytes().expect("canonical unsigned event")) + .expect("unsigned event"); + let signed = unsigned.sign_with_keys(&keys(3)).expect("signed event"); + let signed_bytes = serde_json::to_vec(&signed).expect("canonical signed event"); + let response: MycLocalSignerUntrustedResponse = untrusted_response( + operation, + hex::encode(operation.correlation_id().as_bytes()), + WireProviderResult::SignEvent { + payload_hex: ProtectedWireHex::from_bytes(&signed_bytes), + }, + ); + let verified = response + .verify( + config + .provider_contract() + .binding(MycProviderRole::User) + .expect("user binding"), + operation, + MycProviderResponseObservedAtUnixMs::new(RECEIVED_AT_MS + 3_001) + .expect("response time"), + ) + .expect("verified signed event"); + let request = MycNip46CommitRequest::new( + &work, + None, + Some(&verified), + connection_time(RECEIVED_AT_MS + 3_002), + ) + .expect("completion request"); + let request_debug = format!("{request:?}"); + assert!(!request_debug.contains(&hex::encode(operation.operation_id().as_bytes()))); + assert!(!request_debug.contains(&hex::encode(operation.correlation_id().as_bytes()))); + assert!(!request_debug.contains(String::from_utf8_lossy(&signed_bytes).as_ref())); + let committed = repository + .commit_nip46_operation(&request) + .await + .expect("completion commit"); + assert!(matches!(committed, MycNip46CommitAdmission::Committed(_))); + assert_eq!( + committed.record().method(), + MycSignerRequestMethod::SignEvent + ); + assert_eq!( + committed.record().artifact_sha256(), + Some(&<[u8; 32]>::from(Sha256::digest(&signed_bytes))) + ); + let record_debug = format!("{:?}", committed.record()); + assert!(!record_debug.contains(&hex::encode(committed.record().operation_id().as_bytes()))); + assert!(!record_debug.contains(&hex::encode(committed.record().correlation_id().as_bytes()))); + assert!(!record_debug.contains(&hex::encode(Sha256::digest(&signed_bytes)))); + let replay = repository + .commit_nip46_operation(&request) + .await + .expect("exact completion replay"); + assert!(matches!(replay, MycNip46CommitAdmission::ExactReplay(_))); + host.close().await.expect("host close"); + + let options = SqliteConnectOptions::new() + .filename(runtime.artifacts().state_database()) + .create_if_missing(false) + .disable_statement_logging(); + let mut connection = sqlx::SqliteConnection::connect_with(&options) + .await + .expect("inspection connection"); + let row = sqlx::query( + "SELECT provider_artifact, provider_artifact_sha256 FROM nip46_operation_commits \ + WHERE method = 'sign_event'", + ) + .fetch_one(&mut connection) + .await + .expect("committed artifact"); + assert_eq!(row.get::<Vec<u8>, _>("provider_artifact"), signed_bytes); + assert_eq!( + row.get::<Vec<u8>, _>("provider_artifact_sha256"), + Sha256::digest(&signed_bytes).as_slice() + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM delivery_jobs") + .fetch_one(&mut connection) + .await + .expect("delivery job count"), + 0 + ); + connection.close().await.expect("inspection close"); +} + +#[tokio::test] +async fn session_revocation_and_completion_roll_back_together_then_replay_exactly() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path()); + fs::create_dir_all(runtime.context().paths().state()).expect("state directory"); + fs::set_permissions( + runtime.context().paths().state(), + fs::Permissions::from_mode(0o700), + ) + .expect("state mode"); + let metadata = metadata(&runtime); + let config = configuration(); + let (applied_at, build) = migration_evidence(); + initialize_myc_state(&runtime, &metadata, applied_at, &build) + .await + .expect("state initialization"); + let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) + .await + .expect("writable state"); + let repository = host.repository(); + let (_connect_work, active, _connect_decision) = active_connection(&repository, &config).await; + + let prepared = prepared_request( + &config, + &keys(10), + "step147-logout", + Request::Logout, + Encryption::Nip44V2, + OBSERVED_AT_SECONDS + 22, + 22, + RECEIVED_AT_MS + 22, + ); + let admitted = repository + .admit_signer_request(prepared.signer_request()) + .await + .expect("logout request admission"); + let work = crate::MycNip46Work::local_for_test( + admitted.record().clone(), + active.clone(), + MycSignerRequestMethod::Logout, + ); + let failed = + MycNip46CommitRequest::new(&work, None, None, connection_time(RECEIVED_AT_MS + 3_100)) + .expect("logout completion") + .fail_after_session_effect_for_test(); + assert_eq!( + repository + .commit_nip46_operation(&failed) + .await + .expect_err("injected transaction failure") + .kind(), + MycStateRepositoryErrorKind::Transaction + ); + + let request = + MycNip46CommitRequest::new(&work, None, None, connection_time(RECEIVED_AT_MS + 3_100)) + .expect("logout completion retry"); + let committed = repository + .commit_nip46_operation(&request) + .await + .expect("rollback preserved retry authority"); + assert_eq!( + committed.record().session_effect(), + MycNip46SessionEffect::ConnectionRevoked + ); + assert!(matches!( + repository + .commit_nip46_operation(&request) + .await + .expect("logout replay"), + MycNip46CommitAdmission::ExactReplay(_) + )); + host.close().await.expect("host close"); + + let options = SqliteConnectOptions::new() + .filename(runtime.artifacts().state_database()) + .create_if_missing(false) + .disable_statement_logging(); + let mut connection = sqlx::SqliteConnection::connect_with(&options) + .await + .expect("inspection connection"); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT status FROM connections WHERE connection_id = ?") + .bind(active.id().as_bytes().as_slice()) + .fetch_one(&mut connection) + .await + .expect("connection status"), + "expired" + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM nip46_operation_commits") + .fetch_one(&mut connection) + .await + .expect("completion count"), + 1 + ); + connection.close().await.expect("inspection close"); +} diff --git a/src/nip46_work.rs b/src/nip46_work.rs @@ -302,6 +302,28 @@ pub struct MycNip46Work { } impl MycNip46Work { + #[cfg(test)] + pub(crate) fn local_for_test( + request: MycSignerRequestRecord, + connection: MycConnectionRecord, + method: MycSignerRequestMethod, + ) -> Self { + assert!(matches!( + method, + MycSignerRequestMethod::GetPublicKey + | MycSignerRequestMethod::GetSessionCapability + | MycSignerRequestMethod::Ping + | MycSignerRequestMethod::SwitchRelays + | MycSignerRequestMethod::Logout + )); + Self { + request, + connection: Some(connection), + method, + payload: Nip46WorkPayload::Local, + } + } + /// Returns the exact admitted durable request record. #[must_use] pub const fn request_record(&self) -> &MycSignerRequestRecord { 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 = 7; +pub const MYC_STATE_SCHEMA_VERSION: u32 = 8; /// The shared metadata and migration-ledger objects present at schema v1. pub const MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6; @@ -35,6 +35,9 @@ pub const MYC_STATE_SCHEMA_VERSION_6_OBJECT_COUNT: u32 = 43; /// The shared objects plus Myc discovery desired/current state and exact documents. pub const MYC_STATE_SCHEMA_VERSION_7_OBJECT_COUNT: u32 = 53; +/// The shared objects plus immutable NIP-46 operation completion evidence. +pub const MYC_STATE_SCHEMA_VERSION_8_OBJECT_COUNT: u32 = 56; + /// 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, @@ -49,14 +52,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] = [ - 0x6f, 0x47, 0xd1, 0xa6, 0x61, 0x42, 0x93, 0xb8, 0xd8, 0xd3, 0x10, 0x2a, 0xb5, 0x87, 0xc4, 0x2e, - 0x2b, 0xde, 0x40, 0xb2, 0x49, 0xa1, 0x65, 0xe2, 0x50, 0x4c, 0xe4, 0xa6, 0xf5, 0x08, 0x16, 0x77, + 0xa2, 0xdd, 0xd3, 0x20, 0xe2, 0xf9, 0x6d, 0x08, 0xc8, 0x17, 0x7d, 0xa1, 0x17, 0x5d, 0x0e, 0xd3, + 0x29, 0x83, 0x16, 0x8d, 0xc1, 0x6b, 0x3d, 0x32, 0x6f, 0xd4, 0x3d, 0x9d, 0x65, 0x5d, 0x8e, 0x82, ]; /// SHA-256 identity of the schema catalog bound to the migration catalog. pub const MYC_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [ - 0x4f, 0x8d, 0x6e, 0xe9, 0x87, 0x59, 0xcc, 0xad, 0x98, 0x42, 0xb1, 0xbe, 0xb6, 0xa1, 0xdc, 0x96, - 0xc2, 0x16, 0x49, 0x00, 0x8e, 0xd6, 0x47, 0xff, 0x73, 0x11, 0xcf, 0x22, 0x06, 0xc1, 0xf6, 0x20, + 0x49, 0x62, 0x28, 0xfc, 0xd4, 0xc2, 0xa5, 0x83, 0xd7, 0xff, 0xe2, 0x11, 0x3e, 0x0a, 0x7a, 0xdd, + 0x68, 0xbc, 0xf7, 0xb7, 0x02, 0x18, 0xce, 0x5b, 0x94, 0x6e, 0x2f, 0x92, 0xf3, 0x0e, 0xda, 0xc7, ]; /// SHA-256 identity of the schema-v2 migration content. @@ -125,6 +128,18 @@ pub const MYC_STATE_SCHEMA_VERSION_7_SHA256: [u8; 32] = [ 0x5e, 0x72, 0x3e, 0x97, 0xb6, 0xeb, 0xf5, 0xb4, 0x0a, 0x19, 0xb9, 0x05, 0x89, 0x58, 0xbf, 0x79, ]; +/// SHA-256 identity of the schema-v8 operation-completion migration. +pub const MYC_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256: [u8; 32] = [ + 0xb8, 0x1f, 0x98, 0x0c, 0x91, 0xac, 0xd9, 0x8b, 0x91, 0xec, 0xd5, 0xcb, 0x24, 0x8e, 0x40, 0x27, + 0xc9, 0xf4, 0x7c, 0x1d, 0xc8, 0xd0, 0x13, 0x4a, 0x61, 0xaf, 0x5c, 0x38, 0x23, 0x83, 0x15, 0xbc, +]; + +/// SHA-256 identity of the schema-v8 object snapshot. +pub const MYC_STATE_SCHEMA_VERSION_8_SHA256: [u8; 32] = [ + 0x50, 0x15, 0x8c, 0xb0, 0x93, 0xed, 0x70, 0xb3, 0xd5, 0x78, 0x37, 0x62, 0xb1, 0x8d, 0x21, 0xb7, + 0x80, 0x96, 0x65, 0xc8, 0x9f, 0xde, 0x92, 0x5d, 0x87, 0x23, 0x65, 0x90, 0x9d, 0x28, 0xf0, 0x6e, +]; + /// 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, @@ -1536,6 +1551,100 @@ const CREATE_DISCOVERY_STATE_MIGRATION_SQL: &str = concat!( ), ); +macro_rules! nip46_operation_commits_table_sql { + () => { + r#"CREATE TABLE nip46_operation_commits ( + operation_id BLOB NOT NULL PRIMARY KEY CHECK (length(operation_id) = 32) + REFERENCES nip46_requests(operation_id), + correlation_id BLOB NOT NULL UNIQUE CHECK (length(correlation_id) = 32), + method TEXT NOT NULL CHECK (method IN ( + 'connect', 'get_public_key', 'get_session_capability', 'sign_event', + 'nip04_encrypt', 'nip04_decrypt', 'nip44_encrypt', 'nip44_decrypt', + 'ping', 'switch_relays', 'logout' + )), + connection_id BLOB CHECK (connection_id IS NULL OR length(connection_id) = 32) + REFERENCES connections(connection_id), + session_effect TEXT NOT NULL CHECK (session_effect IN ( + 'none', 'connection_admitted', 'connection_revoked' + )), + provider_operation_id BLOB UNIQUE + CHECK (provider_operation_id IS NULL OR length(provider_operation_id) = 32), + provider_artifact_kind TEXT NOT NULL CHECK (provider_artifact_kind IN ( + 'none', 'signed_event' + )), + provider_artifact_sha256 BLOB + CHECK (provider_artifact_sha256 IS NULL OR length(provider_artifact_sha256) = 32), + provider_artifact BLOB + CHECK (provider_artifact IS NULL OR length(provider_artifact) BETWEEN 1 AND 1048576), + outcome TEXT NOT NULL CHECK (outcome = 'succeeded'), + reason_code TEXT NOT NULL CHECK (reason_code IN ( + 'completed', 'connection_admitted', 'connection_denied', 'session_revoked' + )), + completed_at_unix_ms INTEGER NOT NULL + CHECK (completed_at_unix_ms BETWEEN 1 AND 9223372036854775807), + CHECK ( + (method = 'connect' AND session_effect = 'connection_admitted' + AND connection_id IS NOT NULL AND reason_code = 'connection_admitted') + OR (method = 'connect' AND session_effect = 'none' + AND connection_id IS NULL AND reason_code = 'connection_denied') + OR (method = 'logout' AND session_effect = 'connection_revoked' + AND connection_id IS NOT NULL AND reason_code = 'session_revoked') + OR (method NOT IN ('connect', 'logout') AND session_effect = 'none' + AND connection_id IS NOT NULL AND reason_code = 'completed') + ), + CHECK ( + (method IN ('sign_event', 'nip04_encrypt', 'nip04_decrypt', + 'nip44_encrypt', 'nip44_decrypt') AND provider_operation_id IS NOT NULL) + OR (method NOT IN ('sign_event', 'nip04_encrypt', 'nip04_decrypt', + 'nip44_encrypt', 'nip44_decrypt') AND provider_operation_id IS NULL) + ), + CHECK ( + (method = 'sign_event' AND provider_artifact_kind = 'signed_event' + AND provider_artifact_sha256 IS NOT NULL AND provider_artifact IS NOT NULL) + OR (method != 'sign_event' AND provider_artifact_kind = 'none' + AND provider_artifact_sha256 IS NULL AND provider_artifact IS NULL) + ) +) STRICT"# + }; +} + +macro_rules! nip46_operation_commits_no_update_sql { + () => { + r#"CREATE TRIGGER nip46_operation_commits_no_update +BEFORE UPDATE ON nip46_operation_commits +BEGIN + SELECT RAISE(ABORT, 'NIP-46 operation completion is immutable'); +END"# + }; +} + +macro_rules! nip46_operation_commits_no_delete_sql { + () => { + r#"CREATE TRIGGER nip46_operation_commits_no_delete +BEFORE DELETE ON nip46_operation_commits +BEGIN + SELECT RAISE(ABORT, 'NIP-46 operation completion is retained'); +END"# + }; +} + +const CREATE_NIP46_OPERATION_COMMITS_TABLE_SQL: &str = nip46_operation_commits_table_sql!(); +const CREATE_NIP46_OPERATION_COMMITS_NO_UPDATE_SQL: &str = nip46_operation_commits_no_update_sql!(); +const CREATE_NIP46_OPERATION_COMMITS_NO_DELETE_SQL: &str = nip46_operation_commits_no_delete_sql!(); + +const CREATE_NIP46_OPERATION_COMPLETION_MIGRATION_SQL: &str = concat!( + "DROP TRIGGER myc_state_metadata_no_update;\n", + "UPDATE myc_state_metadata SET state_contract_version = CASE ", + "WHEN state_contract_version = 7 THEN 8 ELSE 0 END WHERE singleton = 1;\n", + myc_state_metadata_no_update_sql!(), + ";\n", + nip46_operation_commits_table_sql!(), + ";\n", + nip46_operation_commits_no_update_sql!(), + ";\n", + nip46_operation_commits_no_delete_sql!(), +); + 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, @@ -1732,6 +1841,18 @@ const DISCOVERY_PUBLICATION_STATE_NO_DELETE_SHA256: [u8; 32] = [ 0xcd, 0x1b, 0xb2, 0x56, 0x6e, 0x3e, 0x46, 0x14, 0x4a, 0x4d, 0x38, 0xf1, 0xcb, 0xf9, 0xa0, 0xc4, 0xb8, 0x94, 0xb8, 0x96, 0x71, 0x75, 0x49, 0x9c, 0x12, 0xb0, 0x15, 0x1d, 0xa0, 0x06, 0x1c, 0xa0, ]; +const NIP46_OPERATION_COMMITS_TABLE_SHA256: [u8; 32] = [ + 0x5f, 0x56, 0x46, 0xcf, 0xef, 0xee, 0x82, 0x58, 0xe6, 0xa7, 0x27, 0x53, 0x32, 0xbd, 0xb9, 0x92, + 0xaf, 0xc5, 0x3c, 0x0c, 0xaf, 0x10, 0x87, 0xe5, 0x25, 0x1e, 0x89, 0x8c, 0x4c, 0xd6, 0xc5, 0x4d, +]; +const NIP46_OPERATION_COMMITS_NO_UPDATE_SHA256: [u8; 32] = [ + 0x52, 0xa9, 0x7d, 0x6c, 0xbd, 0x06, 0x9e, 0xe1, 0x19, 0x00, 0x98, 0xdd, 0x36, 0x6b, 0x38, 0xd7, + 0xff, 0xa5, 0x9d, 0x04, 0x7b, 0x6b, 0xa9, 0x79, 0x54, 0xb7, 0x7f, 0x5c, 0x13, 0x97, 0x13, 0xd3, +]; +const NIP46_OPERATION_COMMITS_NO_DELETE_SHA256: [u8; 32] = [ + 0x99, 0x67, 0xd8, 0x61, 0xd2, 0xa7, 0x1e, 0x1a, 0x74, 0x3f, 0xb5, 0xe9, 0x7b, 0x96, 0xe0, 0x00, + 0xa9, 0xaa, 0x38, 0xe4, 0xb6, 0x76, 0x47, 0xa0, 0x87, 0x0b, 0x48, 0x9b, 0x55, 0x20, 0xa9, 0xa3, +]; /// Stable classes for invalid embedded Myc catalog definitions. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1846,6 +1967,13 @@ pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError> MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256), ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; + let completion = MigrationDescriptor::sql( + 8, + "create_nip46_operation_completion", + CREATE_NIP46_OPERATION_COMPLETION_MIGRATION_SQL, + MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; let catalog = MigrationCatalog::new([ metadata, requests, @@ -1853,10 +1981,11 @@ pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError> governance, delivery, discovery, + completion, ]) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; if catalog.current_version() != MYC_STATE_SCHEMA_VERSION - || catalog.descriptors().len() != 6 + || catalog.descriptors().len() != 7 || catalog.digest().as_bytes() != &MYC_MIGRATION_CATALOG_SHA256 { return Err(MycStateCatalogError::new( @@ -1911,6 +2040,12 @@ pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> { SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_7_SHA256), ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; + let version_eight = SchemaVersionCatalog::new( + 8, + myc_state_completion_objects()?, + SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_8_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; let catalog = SchemaCatalog::new( &migrations, [ @@ -1921,6 +2056,7 @@ pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> { version_five, version_six, version_seven, + version_eight, ], ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; @@ -2412,6 +2548,38 @@ fn myc_state_discovery_objects() -> Result<Vec<SchemaObject>, MycStateCatalogErr Ok(objects) } +fn myc_state_completion_objects() -> Result<Vec<SchemaObject>, MycStateCatalogError> { + let mut objects = myc_state_discovery_objects()?; + let object = |kind, name, table, sql, digest| { + SchemaObject::new(kind, name, table, sql, SchemaDigest::from_bytes(digest)) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog)) + }; + objects.extend([ + object( + SchemaObjectKind::Table, + "nip46_operation_commits", + "nip46_operation_commits", + CREATE_NIP46_OPERATION_COMMITS_TABLE_SQL, + NIP46_OPERATION_COMMITS_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "nip46_operation_commits_no_update", + "nip46_operation_commits", + CREATE_NIP46_OPERATION_COMMITS_NO_UPDATE_SQL, + NIP46_OPERATION_COMMITS_NO_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "nip46_operation_commits_no_delete", + "nip46_operation_commits", + CREATE_NIP46_OPERATION_COMMITS_NO_DELETE_SQL, + NIP46_OPERATION_COMMITS_NO_DELETE_SHA256, + )?, + ]); + Ok(objects) +} + /// Independently validates exact catalog versions, counts, and digests. pub fn validate_myc_state_catalogs( migrations: &MigrationCatalog, @@ -2420,7 +2588,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() == 6 + && descriptors.len() == 7 && 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 @@ -2439,9 +2607,12 @@ pub fn validate_myc_state_catalogs( && descriptors[5].target_version() == 7 && descriptors[5].name().as_str() == "create_discovery_desired_state" && descriptors[5].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256 + && descriptors[6].target_version() == 8 + && descriptors[6].name().as_str() == "create_nip46_operation_completion" + && descriptors[6].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256 && migrations.digest().as_bytes() == &MYC_MIGRATION_CATALOG_SHA256 && schema.migration_catalog_digest() == migrations.digest() - && versions.len() == 7 + && versions.len() == 8 && 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 @@ -2463,6 +2634,9 @@ pub fn validate_myc_state_catalogs( && versions[6].version() == 7 && versions[6].object_count() == MYC_STATE_SCHEMA_VERSION_7_OBJECT_COUNT && versions[6].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_7_SHA256 + && versions[7].version() == 8 + && versions[7].object_count() == MYC_STATE_SCHEMA_VERSION_8_OBJECT_COUNT + && versions[7].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_8_SHA256 && schema.digest().as_bytes() == &MYC_STATE_SCHEMA_CATALOG_SHA256; if valid { Ok(()) diff --git a/src/state_completion.rs b/src/state_completion.rs @@ -0,0 +1,825 @@ +//! Atomic durable completion of one admitted NIP-46 operation. + +use core::fmt; +use std::error::Error; + +use radroots_service_sqlite::{ + ServiceSqliteTransaction, ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind, +}; +use sha2::{Digest, Sha256}; +use sqlx::Row; + +use crate::state_repository::{ + MycStateRepository, MycStateRepositoryError, MycStateRepositoryErrorKind, PersistedMetadata, + RepositoryOperationError, require_expected_metadata, +}; +use crate::{ + MYC_PROVIDER_OUTPUT_MAX_BYTES, MycConnectionDecision, MycConnectionDecisionRecord, + MycConnectionId, MycConnectionStatus, MycConnectionTimeUnixMs, MycNip46Work, MycNip46WorkKind, + MycSignerCorrelationId, MycSignerOperationId, MycSignerRequestMethod, MycSignerRequestRecord, + MycVerifiedProviderResponse, +}; + +const READ_REQUEST_SQL: &str = r#"SELECT + CASE WHEN typeof(correlation_id) = 'blob' AND length(correlation_id) = 32 + THEN correlation_id ELSE NULL END AS correlation_id, + CASE WHEN typeof(method) = 'text' AND length(CAST(method AS BLOB)) BETWEEN 1 AND 32 + THEN method ELSE NULL END AS method, + received_at_unix_ms +FROM nip46_requests WHERE operation_id = ? LIMIT 2"#; + +const READ_CONNECTION_SQL: &str = r#"SELECT + CASE WHEN typeof(status) = 'text' AND length(CAST(status AS BLOB)) <= 16 + THEN status ELSE NULL END AS status, + policy_generation, updated_at_unix_ms +FROM connections WHERE connection_id = ? LIMIT 2"#; + +const READ_CONNECT_DECISION_SQL: &str = r#"SELECT + CASE WHEN typeof(decision) = 'text' AND length(CAST(decision AS BLOB)) <= 32 + THEN decision ELSE NULL END AS decision, + CASE WHEN typeof(connection_id) = 'blob' AND length(connection_id) = 32 + THEN connection_id ELSE NULL END AS connection_id, + typeof(connection_id) AS connection_id_type +FROM nip46_request_decisions WHERE operation_id = ? LIMIT 2"#; + +const READ_COMMIT_SQL: &str = r#"SELECT + CASE WHEN typeof(correlation_id) = 'blob' AND length(correlation_id) = 32 + THEN correlation_id ELSE NULL END AS correlation_id, + CASE WHEN typeof(method) = 'text' AND length(CAST(method AS BLOB)) BETWEEN 1 AND 32 + THEN method ELSE NULL END AS method, + CASE WHEN typeof(connection_id) = 'blob' AND length(connection_id) = 32 + THEN connection_id ELSE NULL END AS connection_id, + typeof(connection_id) AS connection_id_type, + CASE WHEN typeof(session_effect) = 'text' AND length(CAST(session_effect AS BLOB)) <= 32 + THEN session_effect ELSE NULL END AS session_effect, + CASE WHEN typeof(provider_operation_id) = 'blob' AND length(provider_operation_id) = 32 + THEN provider_operation_id ELSE NULL END AS provider_operation_id, + typeof(provider_operation_id) AS provider_operation_id_type, + CASE WHEN typeof(provider_artifact_kind) = 'text' + AND length(CAST(provider_artifact_kind AS BLOB)) <= 16 + THEN provider_artifact_kind ELSE NULL END AS provider_artifact_kind, + CASE WHEN typeof(provider_artifact_sha256) = 'blob' + AND length(provider_artifact_sha256) = 32 + THEN provider_artifact_sha256 ELSE NULL END AS provider_artifact_sha256, + typeof(provider_artifact_sha256) AS provider_artifact_sha256_type, + CASE WHEN typeof(provider_artifact) = 'blob' + AND length(provider_artifact) BETWEEN 1 AND 1048576 + THEN provider_artifact ELSE NULL END AS provider_artifact, + typeof(provider_artifact) AS provider_artifact_type, + CASE WHEN typeof(reason_code) = 'text' AND length(CAST(reason_code AS BLOB)) <= 32 + THEN reason_code ELSE NULL END AS reason_code, + CASE WHEN typeof(outcome) = 'text' AND length(CAST(outcome AS BLOB)) <= 16 + THEN outcome ELSE NULL END AS outcome, + completed_at_unix_ms +FROM nip46_operation_commits WHERE operation_id = ? LIMIT 2"#; + +const INSERT_COMMIT_SQL: &str = r#"INSERT INTO nip46_operation_commits ( + operation_id, correlation_id, method, connection_id, session_effect, + provider_operation_id, provider_artifact_kind, provider_artifact_sha256, + provider_artifact, outcome, reason_code, completed_at_unix_ms +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'succeeded', ?, ?)"#; + +const REVOKE_CONNECTION_SQL: &str = r#"UPDATE connections +SET status = 'expired', updated_at_unix_ms = ?, authorized_until_unix_ms = NULL +WHERE connection_id = ? AND status = 'active' AND policy_generation = ?"#; + +/// Closed session mutation committed with a NIP-46 operation decision. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycNip46SessionEffect { + None, + ConnectionAdmitted, + ConnectionRevoked, +} + +impl MycNip46SessionEffect { + const fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::ConnectionAdmitted => "connection_admitted", + Self::ConnectionRevoked => "connection_revoked", + } + } + + fn parse(value: &str) -> Option<Self> { + match value { + "none" => Some(Self::None), + "connection_admitted" => Some(Self::ConnectionAdmitted), + "connection_revoked" => Some(Self::ConnectionRevoked), + _ => None, + } + } +} + +/// Stable construction failure classes for a NIP-46 completion commit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycNip46CommitErrorKind { + InvalidBinding, + InvalidTime, +} + +impl MycNip46CommitErrorKind { + /// Returns the stable machine-readable error code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidBinding => "nip46_commit_binding_invalid", + Self::InvalidTime => "nip46_commit_time_invalid", + } + } +} + +/// Source-free redacted construction failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycNip46CommitError { + kind: MycNip46CommitErrorKind, +} + +impl MycNip46CommitError { + const fn new(kind: MycNip46CommitErrorKind) -> Self { + Self { kind } + } + + #[must_use] + pub const fn kind(self) -> MycNip46CommitErrorKind { + self.kind + } + + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Display for MycNip46CommitError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + MycNip46CommitErrorKind::InvalidBinding => "NIP-46 completion binding is invalid", + MycNip46CommitErrorKind::InvalidTime => "NIP-46 completion time is invalid", + }) + } +} + +impl fmt::Debug for MycNip46CommitError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycNip46CommitError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for MycNip46CommitError {} + +/// One sealed, independently bound NIP-46 completion request. +pub struct MycNip46CommitRequest { + request: MycSignerRequestRecord, + connection_id: Option<MycConnectionId>, + connection_generation: Option<u64>, + connect_decision: Option<MycConnectionDecision>, + session_effect: MycNip46SessionEffect, + provider_operation_id: Option<[u8; 32]>, + artifact: Option<Box<[u8]>>, + artifact_sha256: Option<[u8; 32]>, + reason_code: &'static str, + completed_at: MycConnectionTimeUnixMs, + #[cfg(test)] + fail_after_session_effect: bool, +} + +impl MycNip46CommitRequest { + /// Binds already-admitted work and any independently verified provider output. + /// + /// A terminal connect decision is required for connect work. Provider work + /// requires its exact verified result. Local work accepts neither. Only an + /// independently verified signed-event result is retained as an artifact; + /// protected provider output is deliberately not persisted here. + pub fn new( + work: &MycNip46Work, + connect_decision: Option<&MycConnectionDecisionRecord>, + provider_response: Option<&MycVerifiedProviderResponse>, + completed_at: MycConnectionTimeUnixMs, + ) -> Result<Self, MycNip46CommitError> { + if completed_at.get() < work.request_record().received_at().get() + || work + .connection() + .is_some_and(|connection| completed_at < connection.updated_at()) + { + return Err(MycNip46CommitError::new( + MycNip46CommitErrorKind::InvalidTime, + )); + } + + let mut connection_id = work.connection().map(|connection| connection.id()); + let mut connection_generation = work + .connection() + .map(|connection| connection.policy_generation().get()); + let mut terminal_connect_decision = None; + let (session_effect, reason_code) = match work.kind() { + MycNip46WorkKind::Connect => { + if provider_response.is_some() { + return Err(MycNip46CommitError::new( + MycNip46CommitErrorKind::InvalidBinding, + )); + } + let decision = connect_decision.ok_or_else(|| { + MycNip46CommitError::new(MycNip46CommitErrorKind::InvalidBinding) + })?; + if decision.operation_id() != work.request_record().operation_id() + || !matches!( + decision.decision(), + MycConnectionDecision::Allowed | MycConnectionDecision::Denied + ) + { + return Err(MycNip46CommitError::new( + MycNip46CommitErrorKind::InvalidBinding, + )); + } + if completed_at < decision.decided_at() { + return Err(MycNip46CommitError::new( + MycNip46CommitErrorKind::InvalidTime, + )); + } + connection_id = decision.connection().map(|connection| connection.id()); + connection_generation = decision + .connection() + .map(|connection| connection.policy_generation().get()); + terminal_connect_decision = Some(decision.decision()); + match decision.decision() { + MycConnectionDecision::Allowed => { + if decision.connection().is_none_or(|connection| { + connection.status() != MycConnectionStatus::Active + }) { + return Err(MycNip46CommitError::new( + MycNip46CommitErrorKind::InvalidBinding, + )); + } + ( + MycNip46SessionEffect::ConnectionAdmitted, + "connection_admitted", + ) + } + MycConnectionDecision::Denied => { + if decision.connection().is_some() { + return Err(MycNip46CommitError::new( + MycNip46CommitErrorKind::InvalidBinding, + )); + } + (MycNip46SessionEffect::None, "connection_denied") + } + MycConnectionDecision::PendingApproval | MycConnectionDecision::Challenged => { + unreachable!("nonterminal decisions rejected above") + } + } + } + MycNip46WorkKind::Local => { + if connect_decision.is_some() || provider_response.is_some() { + return Err(MycNip46CommitError::new( + MycNip46CommitErrorKind::InvalidBinding, + )); + } + if work.method() == MycSignerRequestMethod::Logout { + (MycNip46SessionEffect::ConnectionRevoked, "session_revoked") + } else { + (MycNip46SessionEffect::None, "completed") + } + } + MycNip46WorkKind::Provider => { + if connect_decision.is_some() { + return Err(MycNip46CommitError::new( + MycNip46CommitErrorKind::InvalidBinding, + )); + } + (MycNip46SessionEffect::None, "completed") + } + }; + + let provider = bind_provider_result(work, provider_response)?; + Ok(Self { + request: work.request_record().clone(), + connection_id, + connection_generation, + connect_decision: terminal_connect_decision, + session_effect, + provider_operation_id: provider.operation_id, + artifact: provider.artifact, + artifact_sha256: provider.artifact_sha256, + reason_code, + completed_at, + #[cfg(test)] + fail_after_session_effect: false, + }) + } + + #[cfg(test)] + pub(crate) fn fail_after_session_effect_for_test(mut self) -> Self { + self.fail_after_session_effect = true; + self + } +} + +impl fmt::Debug for MycNip46CommitRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycNip46CommitRequest") + .field("method", &self.request.method()) + .field("session_effect", &self.session_effect) + .field("artifact", &self.artifact.as_ref().map(|_| "[redacted]")) + .field("identity", &"[redacted]") + .finish() + } +} + +/// Immutable result of the atomic local completion transaction. +#[derive(Clone, PartialEq, Eq)] +pub struct MycNip46CommitRecord { + operation_id: MycSignerOperationId, + correlation_id: MycSignerCorrelationId, + method: MycSignerRequestMethod, + connection_id: Option<MycConnectionId>, + session_effect: MycNip46SessionEffect, + artifact_sha256: Option<[u8; 32]>, + completed_at: MycConnectionTimeUnixMs, +} + +impl MycNip46CommitRecord { + #[must_use] + pub const fn operation_id(&self) -> MycSignerOperationId { + self.operation_id + } + #[must_use] + pub const fn correlation_id(&self) -> MycSignerCorrelationId { + self.correlation_id + } + #[must_use] + pub const fn method(&self) -> MycSignerRequestMethod { + self.method + } + #[must_use] + pub const fn connection_id(&self) -> Option<MycConnectionId> { + self.connection_id + } + #[must_use] + pub const fn session_effect(&self) -> MycNip46SessionEffect { + self.session_effect + } + #[must_use] + pub const fn artifact_sha256(&self) -> Option<&[u8; 32]> { + self.artifact_sha256.as_ref() + } + #[must_use] + pub const fn completed_at(&self) -> MycConnectionTimeUnixMs { + self.completed_at + } +} + +impl fmt::Debug for MycNip46CommitRecord { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycNip46CommitRecord") + .field("method", &self.method) + .field("session_effect", &self.session_effect) + .field("artifact", &self.artifact_sha256.map(|_| "[redacted]")) + .field("identity", &"[redacted]") + .finish() + } +} + +/// New or exact-replayed completion result. +#[derive(Clone, PartialEq, Eq)] +pub enum MycNip46CommitAdmission { + Committed(MycNip46CommitRecord), + ExactReplay(MycNip46CommitRecord), +} + +impl MycNip46CommitAdmission { + #[must_use] + pub const fn record(&self) -> &MycNip46CommitRecord { + match self { + Self::Committed(record) | Self::ExactReplay(record) => record, + } + } +} + +impl fmt::Debug for MycNip46CommitAdmission { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Committed(_) => "MycNip46CommitAdmission::Committed([redacted])", + Self::ExactReplay(_) => "MycNip46CommitAdmission::ExactReplay([redacted])", + }) + } +} + +impl MycStateRepository<'_> { + /// Atomically records the Step 147 completion component. + /// + /// This integration-branch checkpoint is not a complete production + /// response commit. Step 148 must compose this component with the outer + /// signed response, immutable relay targets, and initial outbox state in + /// the same transaction before RCLD-RSHR-080 may be promoted to `master`. + pub async fn commit_nip46_operation( + &self, + request: &MycNip46CommitRequest, + ) -> Result<MycNip46CommitAdmission, MycStateRepositoryError> { + let expected = PersistedMetadata::from(self.expected()); + let request = request.owned(); + self.host() + .transaction(move |transaction| { + Box::pin(async move { + require_expected_metadata(transaction, &expected) + .await + .map_err(map_repository_error)?; + commit_operation(transaction, &request).await + }) + }) + .await + .map_err(map_transaction_error) + } +} + +impl MycNip46CommitRequest { + fn owned(&self) -> Self { + Self { + request: self.request.clone(), + connection_id: self.connection_id, + connection_generation: self.connection_generation, + connect_decision: self.connect_decision, + session_effect: self.session_effect, + provider_operation_id: self.provider_operation_id, + artifact: self.artifact.clone(), + artifact_sha256: self.artifact_sha256, + reason_code: self.reason_code, + completed_at: self.completed_at, + #[cfg(test)] + fail_after_session_effect: self.fail_after_session_effect, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CommitOperationError { + Binding, + Storage, +} + +async fn commit_operation( + transaction: &mut ServiceSqliteTransaction<'_>, + request: &MycNip46CommitRequest, +) -> Result<MycNip46CommitAdmission, CommitOperationError> { + verify_request(transaction, request).await?; + if let Some(existing) = read_commit(transaction, request).await? { + return Ok(MycNip46CommitAdmission::ExactReplay(existing)); + } + verify_connect_decision(transaction, request).await?; + verify_or_apply_session_effect(transaction, request).await?; + #[cfg(test)] + if request.fail_after_session_effect { + return Err(CommitOperationError::Storage); + } + let artifact_kind = if request.artifact.is_some() { + "signed_event" + } else { + "none" + }; + let result = sqlx::query(INSERT_COMMIT_SQL) + .bind(request.request.operation_id().as_bytes().as_slice()) + .bind(request.request.correlation_id().as_bytes().as_slice()) + .bind(request.request.method().as_str()) + .bind(request.connection_id.map(|id| id.as_bytes().to_vec())) + .bind(request.session_effect.as_str()) + .bind(request.provider_operation_id.map(|id| id.to_vec())) + .bind(artifact_kind) + .bind(request.artifact_sha256.map(|digest| digest.to_vec())) + .bind(request.artifact.as_deref()) + .bind(request.reason_code) + .bind(to_i64(request.completed_at.get())?) + .execute(&mut *transaction) + .await + .map_err(|_| CommitOperationError::Storage)?; + require_one(result.rows_affected())?; + read_commit(transaction, request) + .await? + .map(MycNip46CommitAdmission::Committed) + .ok_or(CommitOperationError::Binding) +} + +async fn verify_request( + transaction: &mut ServiceSqliteTransaction<'_>, + request: &MycNip46CommitRequest, +) -> Result<(), CommitOperationError> { + let rows = sqlx::query(READ_REQUEST_SQL) + .bind(request.request.operation_id().as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| CommitOperationError::Storage)?; + let [row] = rows.as_slice() else { + return Err(CommitOperationError::Binding); + }; + let correlation = exact_bytes(row, "correlation_id")?; + let method = bounded_text(row, "method")?; + let received_at = positive_i64(row, "received_at_unix_ms")?; + (correlation == *request.request.correlation_id().as_bytes() + && method == request.request.method().as_str() + && received_at == request.request.received_at().get() + && request.completed_at.get() >= received_at) + .then_some(()) + .ok_or(CommitOperationError::Binding) +} + +async fn verify_connect_decision( + transaction: &mut ServiceSqliteTransaction<'_>, + request: &MycNip46CommitRequest, +) -> Result<(), CommitOperationError> { + let Some(expected_decision) = request.connect_decision else { + return Ok(()); + }; + let rows = sqlx::query(READ_CONNECT_DECISION_SQL) + .bind(request.request.operation_id().as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| CommitOperationError::Storage)?; + let [row] = rows.as_slice() else { + return Err(CommitOperationError::Binding); + }; + let decision = bounded_text(row, "decision")?; + let connection = optional_exact_bytes(row, "connection_id", "connection_id_type")?; + (decision + == match expected_decision { + MycConnectionDecision::Allowed => "allowed", + MycConnectionDecision::Denied => "denied", + MycConnectionDecision::PendingApproval | MycConnectionDecision::Challenged => { + return Err(CommitOperationError::Binding); + } + } + && connection == request.connection_id.map(|id| *id.as_bytes())) + .then_some(()) + .ok_or(CommitOperationError::Binding) +} + +async fn verify_or_apply_session_effect( + transaction: &mut ServiceSqliteTransaction<'_>, + request: &MycNip46CommitRequest, +) -> Result<(), CommitOperationError> { + let Some(connection_id) = request.connection_id else { + return (request.connection_generation.is_none() + && request.session_effect == MycNip46SessionEffect::None) + .then_some(()) + .ok_or(CommitOperationError::Binding); + }; + let generation = request + .connection_generation + .ok_or(CommitOperationError::Binding)?; + let rows = sqlx::query(READ_CONNECTION_SQL) + .bind(connection_id.as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| CommitOperationError::Storage)?; + let [row] = rows.as_slice() else { + return Err(CommitOperationError::Binding); + }; + let status = bounded_text(row, "status")?; + let actual_generation = positive_i64(row, "policy_generation")?; + let updated_at = positive_i64(row, "updated_at_unix_ms")?; + if actual_generation != generation || request.completed_at.get() < updated_at { + return Err(CommitOperationError::Binding); + } + match request.session_effect { + MycNip46SessionEffect::ConnectionRevoked => { + if status != "active" { + return Err(CommitOperationError::Binding); + } + let result = sqlx::query(REVOKE_CONNECTION_SQL) + .bind(to_i64(request.completed_at.get())?) + .bind(connection_id.as_bytes().as_slice()) + .bind(to_i64(generation)?) + .execute(&mut *transaction) + .await + .map_err(|_| CommitOperationError::Storage)?; + require_one(result.rows_affected()) + } + MycNip46SessionEffect::None | MycNip46SessionEffect::ConnectionAdmitted => (status + == "active") + .then_some(()) + .ok_or(CommitOperationError::Binding), + } +} + +async fn read_commit( + transaction: &mut ServiceSqliteTransaction<'_>, + request: &MycNip46CommitRequest, +) -> Result<Option<MycNip46CommitRecord>, CommitOperationError> { + let rows = sqlx::query(READ_COMMIT_SQL) + .bind(request.request.operation_id().as_bytes().as_slice()) + .fetch_all(&mut *transaction) + .await + .map_err(|_| CommitOperationError::Storage)?; + if rows.len() > 1 { + return Err(CommitOperationError::Binding); + } + let Some(row) = rows.first() else { + return Ok(None); + }; + let correlation = exact_bytes(row, "correlation_id")?; + let method = MycSignerRequestMethod::parse(bounded_text(row, "method")?) + .ok_or(CommitOperationError::Binding)?; + let connection = optional_exact_bytes(row, "connection_id", "connection_id_type")?; + let session_effect = MycNip46SessionEffect::parse(bounded_text(row, "session_effect")?) + .ok_or(CommitOperationError::Binding)?; + let provider_operation = + optional_exact_bytes(row, "provider_operation_id", "provider_operation_id_type")?; + let artifact_kind = bounded_text(row, "provider_artifact_kind")?; + let artifact_digest = optional_exact_bytes( + row, + "provider_artifact_sha256", + "provider_artifact_sha256_type", + )?; + let artifact = optional_bounded_blob(row, "provider_artifact", "provider_artifact_type")?; + let reason = bounded_text(row, "reason_code")?; + let outcome = bounded_text(row, "outcome")?; + let completed_at = positive_i64(row, "completed_at_unix_ms")?; + let exact = correlation == *request.request.correlation_id().as_bytes() + && method == request.request.method() + && connection == request.connection_id.map(|id| *id.as_bytes()) + && session_effect == request.session_effect + && provider_operation == request.provider_operation_id + && artifact_digest == request.artifact_sha256 + && artifact.as_deref() == request.artifact.as_deref() + && artifact_kind + == if request.artifact.is_some() { + "signed_event" + } else { + "none" + } + && reason == request.reason_code + && outcome == "succeeded" + && completed_at == request.completed_at.get(); + if !exact { + return Err(CommitOperationError::Binding); + } + Ok(Some(MycNip46CommitRecord { + operation_id: request.request.operation_id(), + correlation_id: request.request.correlation_id(), + method, + connection_id: request.connection_id, + session_effect, + artifact_sha256: artifact_digest, + completed_at: request.completed_at, + })) +} + +struct BoundProviderResult { + operation_id: Option<[u8; 32]>, + artifact: Option<Box<[u8]>>, + artifact_sha256: Option<[u8; 32]>, +} + +fn bind_provider_result( + work: &MycNip46Work, + response: Option<&MycVerifiedProviderResponse>, +) -> Result<BoundProviderResult, MycNip46CommitError> { + let Some(operation) = work.provider_operation() else { + if response.is_some() { + return Err(MycNip46CommitError::new( + MycNip46CommitErrorKind::InvalidBinding, + )); + } + return Ok(BoundProviderResult { + operation_id: None, + artifact: None, + artifact_sha256: None, + }); + }; + let response = response + .ok_or_else(|| MycNip46CommitError::new(MycNip46CommitErrorKind::InvalidBinding))?; + if response.operation_id() != operation.operation_id() + || response.correlation_id() != operation.correlation_id() + || response.instance() != operation.instance() + || response.role() != operation.role() + || response.capability() != operation.input().capability() + || !response.matches_operation(operation) + { + return Err(MycNip46CommitError::new( + MycNip46CommitErrorKind::InvalidBinding, + )); + } + let artifact: Option<Box<[u8]>> = response.signed_event_bytes().map(Box::<[u8]>::from); + if artifact + .as_deref() + .is_some_and(|bytes| bytes.is_empty() || bytes.len() > MYC_PROVIDER_OUTPUT_MAX_BYTES) + { + return Err(MycNip46CommitError::new( + MycNip46CommitErrorKind::InvalidBinding, + )); + } + let digest = artifact + .as_deref() + .map(|bytes| <[u8; 32]>::from(Sha256::digest(bytes))); + Ok(BoundProviderResult { + operation_id: Some(*operation.operation_id().as_bytes()), + artifact, + artifact_sha256: digest, + }) +} + +fn exact_bytes( + row: &sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<[u8; 32], CommitOperationError> { + row.try_get::<Option<Vec<u8>>, _>(column) + .map_err(|_| CommitOperationError::Binding)? + .ok_or(CommitOperationError::Binding)? + .try_into() + .map_err(|_| CommitOperationError::Binding) +} + +fn optional_exact_bytes( + row: &sqlx::sqlite::SqliteRow, + column: &str, + type_column: &str, +) -> Result<Option<[u8; 32]>, CommitOperationError> { + let value = row + .try_get::<Option<Vec<u8>>, _>(column) + .map_err(|_| CommitOperationError::Binding)?; + let kind = row + .try_get::<String, _>(type_column) + .map_err(|_| CommitOperationError::Binding)?; + match (kind.as_str(), value) { + ("null", None) => Ok(None), + ("blob", Some(bytes)) => bytes + .try_into() + .map(Some) + .map_err(|_| CommitOperationError::Binding), + _ => Err(CommitOperationError::Binding), + } +} + +fn optional_bounded_blob( + row: &sqlx::sqlite::SqliteRow, + column: &str, + type_column: &str, +) -> Result<Option<Box<[u8]>>, CommitOperationError> { + let value = row + .try_get::<Option<Vec<u8>>, _>(column) + .map_err(|_| CommitOperationError::Binding)?; + let kind = row + .try_get::<String, _>(type_column) + .map_err(|_| CommitOperationError::Binding)?; + match (kind.as_str(), value) { + ("null", None) => Ok(None), + ("blob", Some(bytes)) + if !bytes.is_empty() && bytes.len() <= MYC_PROVIDER_OUTPUT_MAX_BYTES => + { + Ok(Some(bytes.into_boxed_slice())) + } + _ => Err(CommitOperationError::Binding), + } +} + +fn bounded_text<'row>( + row: &'row sqlx::sqlite::SqliteRow, + column: &str, +) -> Result<&'row str, CommitOperationError> { + row.try_get::<Option<&str>, _>(column) + .map_err(|_| CommitOperationError::Binding)? + .ok_or(CommitOperationError::Binding) +} + +fn positive_i64(row: &sqlx::sqlite::SqliteRow, column: &str) -> Result<u64, CommitOperationError> { + row.try_get::<i64, _>(column) + .map_err(|_| CommitOperationError::Binding) + .and_then(|value| u64::try_from(value).map_err(|_| CommitOperationError::Binding)) + .and_then(|value| { + (value != 0) + .then_some(value) + .ok_or(CommitOperationError::Binding) + }) +} + +fn to_i64(value: u64) -> Result<i64, CommitOperationError> { + i64::try_from(value).map_err(|_| CommitOperationError::Binding) +} + +fn require_one(rows: u64) -> Result<(), CommitOperationError> { + (rows == 1) + .then_some(()) + .ok_or(CommitOperationError::Storage) +} + +const fn map_repository_error(error: RepositoryOperationError) -> CommitOperationError { + match error { + RepositoryOperationError::Binding => CommitOperationError::Binding, + RepositoryOperationError::Storage => CommitOperationError::Storage, + } +} + +fn map_transaction_error( + error: ServiceSqliteTransactionError<CommitOperationError>, +) -> MycStateRepositoryError { + if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown { + return MycStateRepositoryError::new(MycStateRepositoryErrorKind::CommitOutcomeUnknown); + } + let kind = match error.operation_error() { + Some(CommitOperationError::Binding) => MycStateRepositoryErrorKind::Binding, + Some(CommitOperationError::Storage) | None => MycStateRepositoryErrorKind::Transaction, + }; + MycStateRepositoryError::new(kind) +} diff --git a/src/state_connection.rs b/src/state_connection.rs @@ -1079,6 +1079,26 @@ impl fmt::Debug for MycAuthorizationChallengeAuthorization { } impl MycStateRepository<'_> { + /// Reads one fully validated durable connection decision by stable operation identity. + pub async fn read_connection_decision( + &self, + operation_id: MycSignerOperationId, + ) -> Result<MycConnectionDecisionRecord, MycStateRepositoryError> { + let expected = PersistedMetadata::from(self.expected()); + self.host() + .transaction(move |transaction| { + Box::pin(async move { + verify_metadata(transaction, &expected).await?; + let decision = read_decision(transaction, operation_id) + .await? + .ok_or(ConnectionOperationError::Binding)?; + decision_record(transaction, operation_id, decision).await + }) + }) + .await + .map_err(map_transaction_error) + } + /// Atomically records trusted, explicit-approval, or direct-denial admission. pub async fn admit_connection( &self, diff --git a/src/state_host.rs b/src/state_host.rs @@ -377,19 +377,20 @@ 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() == 6 + && outcome.applied_count() == 7 } 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, 6) - | (2, 5) - | (3, 4) - | (4, 3) - | (5, 2) - | (6, 1) + (MYC_STATE_BASE_SCHEMA_VERSION, 7) + | (2, 6) + | (3, 5) + | (4, 4) + | (5, 3) + | (6, 2) + | (7, 1) | (MYC_STATE_SCHEMA_VERSION, 0) ) } diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -10,6 +10,7 @@ const NIP46_AUTHORIZATION: &str = include_str!("../src/nip46_authorization.rs"); const NIP46_REPLAY: &str = include_str!("../src/nip46_replay.rs"); const NIP46_WORK: &str = include_str!("../src/nip46_work.rs"); const NIP46_WAVE_080_A: &str = include_str!("../src/nip46_wave_080_a.rs"); +const NIP46_COMPLETION: &str = include_str!("../src/state_completion.rs"); const NIP46_VERIFICATION_CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_verification.v1.json"); const NIP46_REPLAY_CONTRACT: &str = @@ -20,6 +21,8 @@ const NIP46_WORK_CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_work.v1.json"); const NIP46_WAVE_080_A_CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_wave_080_a.v1.json"); +const NIP46_COMPLETION_CONTRACT: &str = + include_str!("../contracts/services_hardening/nip46_completion.v1.json"); const SOURCES: &[&str] = &[ include_str!("../src/cli_v1.rs"), include_str!("../src/config_v1.rs"), @@ -36,6 +39,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/runtime_context.rs"), include_str!("../src/runtime_foundation.rs"), include_str!("../src/state_catalog.rs"), + include_str!("../src/state_completion.rs"), include_str!("../src/state_connection.rs"), include_str!("../src/state_delivery.rs"), include_str!("../src/state_discovery.rs"), @@ -63,6 +67,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "nip46_verification", "nip46_work", "nip46_wave_080_a", + "nip46_wave_080_b", "provider_contract", "provider_credential", "provider_envelope", @@ -71,6 +76,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "runtime_context", "runtime_foundation", "state_catalog", + "state_completion", "state_connection", "state_delivery", "state_discovery", @@ -125,6 +131,13 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "pub struct myc::MycNip46WorkError", "pub struct myc::MycStateHost", "pub struct myc::MycStateRepository", + "pub struct myc::MycNip46CommitRequest", + "pub struct myc::MycNip46CommitRecord", + "pub enum myc::MycNip46CommitAdmission", + "pub enum myc::MycNip46SessionEffect", + "pub enum myc::MycNip46CommitErrorKind", + "pub async fn myc::MycStateRepository<'_>::commit_nip46_operation", + "pub async fn myc::MycStateRepository<'_>::read_connection_decision", "pub fn myc::admit_myc_nip46_event", "pub fn myc::admit_myc_nip46_request", "pub fn myc::bind_myc_nip46_replay", @@ -150,6 +163,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "nip46_verification", "nip46_work", "nip46_wave_080_a", + "nip46_wave_080_b", "provider_contract", "provider_credential", "provider_envelope", @@ -158,6 +172,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "runtime_context", "runtime_foundation", "state_catalog", + "state_completion", "state_connection", "state_delivery", "state_discovery", @@ -200,6 +215,66 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { } #[test] +fn step147_completion_is_atomic_redacted_and_defers_delivery_authority() { + let contract: serde_json::Value = + serde_json::from_str(NIP46_COMPLETION_CONTRACT).expect("Step 147 contract"); + assert_eq!(contract["schema"], "radroots.myc.nip46-completion.v1"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["step"], 147); + assert_eq!(contract["schema_version"], 8); + for required in [ + "radroots.myc.nip46-completion.v1", + "existing_durable_nip46_request", + "existing_service_sqlite_transaction_runner", + "connection_admission_or_logout_session_revocation", + "exact_verified_inner_signed_event_bytes_and_sha256_when_present", + "protected_provider_output\": \"not_persisted", + "failed_transaction\": \"no_session_or_completion_effect", + "step147_checkpoint\": \"integration_only_not_promotable", + "step147_component\": \"must_be_composed_before_master_promotion", + "commit_owner\": 148", + "promotion_owner\": 151", + "outer_signed_nip46_response\": 148", + "outbox_and_initial_attempt_state\": 148", + "outbox_creation", + ] { + assert!( + NIP46_COMPLETION_CONTRACT.contains(required), + "Step 147 contract is missing `{required}`" + ); + } + for required in [ + "ServiceSqliteTransaction", + "nip46_operation_commits", + "REVOKE_CONNECTION_SQL", + "provider_artifact_sha256", + "ExactReplay", + "fail_after_session_effect_for_test", + ] { + assert!( + NIP46_COMPLETION.contains(required), + "Step 147 implementation is missing `{required}`" + ); + } + for forbidden in [ + "RelayPool", + ".publish(", + "tokio::spawn", + "std::time::SystemTime", + "Timestamp::now", + "rand::", + "getrandom", + "SqlitePool", + "rusqlite", + ] { + assert!( + !NIP46_COMPLETION.contains(forbidden), + "Step 147 gained forbidden authority `{forbidden}`" + ); + } +} + +#[test] fn step145_work_is_exactly_bound_and_transaction_free() { for forbidden in [ "sqlx::", @@ -369,7 +444,7 @@ 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, 22); + assert_eq!(public_error_count, 23); assert!(!PUBLIC_API.contains("pub struct myc::MycRuntimeFoundation {")); assert!(!PUBLIC_API.contains("pub struct myc::MycStateHost {")); } diff --git a/tests/services_hardening_native_release.rs b/tests/services_hardening_native_release.rs @@ -52,7 +52,7 @@ fn native_release_contract_and_manifest_metadata_are_exact() { }, "contract_versions": { "config": 1, - "state": 7, + "state": 8, "admin": 1, "status": 1, "provider": 1 @@ -112,7 +112,7 @@ fn native_release_contract_and_manifest_metadata_are_exact() { service = "myc" host_feature_profile = "service-host" config_contract_version = 1 - state_contract_version = 7 + state_contract_version = 8 admin_contract_version = 1 status_contract_version = 1 provider_contract_version = 1 diff --git a/tests/services_hardening_signer_request_state.rs b/tests/services_hardening_signer_request_state.rs @@ -458,7 +458,7 @@ async fn concurrent_identical_admission_creates_one_request_and_bounded_replay_e } #[tokio::test] -async fn exact_schema_v3_state_advances_to_v7_before_request_admission() { +async fn exact_schema_v3_state_advances_to_v8_before_request_admission() { let directory = tempfile::tempdir().expect("temporary root"); let runtime = runtime(directory.path()); prepare_state_directory(&runtime); @@ -513,6 +513,9 @@ async fn exact_schema_v3_state_advances_to_v7_before_request_admission() { "DROP TRIGGER connection_permissions_no_update", "DROP TRIGGER connections_no_delete", "DROP TRIGGER connections_guard_update", + "DROP TRIGGER nip46_operation_commits_no_delete", + "DROP TRIGGER nip46_operation_commits_no_update", + "DROP TABLE nip46_operation_commits", "DROP TABLE discovery_publication_state", "DROP TABLE discovery_documents", "DROP TABLE discovery_desired_state", @@ -529,7 +532,7 @@ async fn exact_schema_v3_state_advances_to_v7_before_request_admission() { "DROP TABLE connections", "UPDATE radroots_service_metadata SET state_schema_version = 3 WHERE singleton = 1", "UPDATE myc_state_metadata SET state_contract_version = 3 WHERE singleton = 1", - "DELETE FROM schema_migrations WHERE version IN (4, 5, 6, 7)", + "DELETE FROM schema_migrations WHERE version IN (4, 5, 6, 7, 8)", ] { sqlx::query(sql) .execute(&mut connection) diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs @@ -14,8 +14,10 @@ use myc::{ MYC_STATE_SCHEMA_VERSION_5_SHA256, MYC_STATE_SCHEMA_VERSION_6_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_6_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_6_SHA256, MYC_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_7_OBJECT_COUNT, - MYC_STATE_SCHEMA_VERSION_7_SHA256, MycStateCatalogErrorKind, myc_migration_catalog, - myc_schema_catalog, validate_myc_state_catalogs, + 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, + MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog, + validate_myc_state_catalogs, }; use radroots_service_sqlite::{ MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaDigest, @@ -27,13 +29,13 @@ const LIB_SOURCE: &str = include_str!("../src/lib.rs"); const MANIFEST: &str = include_str!("../Cargo.toml"); #[test] -fn schema_v1_through_v7_and_all_migrations_have_exact_literal_identities() { +fn schema_v1_through_v8_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, 7); - assert_eq!(migrations.descriptors().len(), 6); + assert_eq!(MYC_STATE_SCHEMA_VERSION, 8); + assert_eq!(migrations.descriptors().len(), 7); let metadata = &migrations.descriptors()[0]; assert_eq!(metadata.target_version(), 2); assert_eq!(metadata.name().as_str(), "create_myc_state_metadata"); @@ -82,13 +84,23 @@ fn schema_v1_through_v7_and_all_migrations_have_exact_literal_identities() { discovery.checksum().as_bytes(), &MYC_STATE_SCHEMA_VERSION_7_MIGRATION_SHA256 ); - assert_eq!(migrations.current_version(), 7); + let completion = &migrations.descriptors()[6]; + assert_eq!(completion.target_version(), 8); + assert_eq!( + completion.name().as_str(), + "create_nip46_operation_completion" + ); + assert_eq!( + completion.checksum().as_bytes(), + &MYC_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256 + ); + assert_eq!(migrations.current_version(), 8); assert_eq!( migrations.digest().as_bytes(), &MYC_MIGRATION_CATALOG_SHA256 ); - assert_eq!(schema.versions().len(), 7); + assert_eq!(schema.versions().len(), 8); assert_eq!(schema.versions()[0].version(), 1); assert_eq!( schema.versions()[0].object_count(), @@ -158,6 +170,16 @@ fn schema_v1_through_v7_and_all_migrations_have_exact_literal_identities() { schema.versions()[6].digest().as_bytes(), &MYC_STATE_SCHEMA_VERSION_7_SHA256 ); + assert_eq!(schema.versions()[7].version(), 8); + assert_eq!( + schema.versions()[7].object_count(), + MYC_STATE_SCHEMA_VERSION_8_OBJECT_COUNT + ); + assert_eq!(schema.versions()[7].object_count(), 56); + assert_eq!( + schema.versions()[7].digest().as_bytes(), + &MYC_STATE_SCHEMA_VERSION_8_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"); @@ -168,7 +190,7 @@ fn schema_v1_through_v7_and_all_migrations_have_exact_literal_identities() { ); assert_eq!( hex::encode(MYC_MIGRATION_CATALOG_SHA256), - "6f47d1a6614293b8d8d3102ab587c42e2bde40b249a165e2504ce4a6f5081677" + "a2ddd320e2f96d08c8177da1175d0ed32983168dc16b3d326fd43d9d655d8e82" ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_VERSION_1_SHA256), @@ -180,7 +202,7 @@ fn schema_v1_through_v7_and_all_migrations_have_exact_literal_identities() { ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_CATALOG_SHA256), - "4f8d6ee98759ccad9842b1beb6a1dc96c21649008ed647ff7311cf2206c1f620" + "496228fcd4c2a583d7ffe2113e0a7add68bcf7b70218ce5b946e2f92f30edac7" ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256), @@ -222,6 +244,14 @@ fn schema_v1_through_v7_and_all_migrations_have_exact_literal_identities() { hex::encode(MYC_STATE_SCHEMA_VERSION_7_SHA256), "3b3528911a293499d9721da71b6ad07a5e723e97b6ebf5b40a19b9058958bf79" ); + assert_eq!( + hex::encode(MYC_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256), + "b81f980c91acd98b91ecd5cb248e4027c9f47c1dc8d0134a61af5c38238315bc" + ); + assert_eq!( + hex::encode(MYC_STATE_SCHEMA_VERSION_8_SHA256), + "50158cb093ed70b3d5783762b18d21b7809665c89fde925d872365909d28f06e" + ); } #[test] @@ -274,8 +304,11 @@ fn independent_validator_rejects_migration_or_schema_drift() { let v6 = SchemaVersionCatalog::new(6, [object.clone()], v6_digest).expect("schema v6"); let v7_digest = SchemaVersionCatalog::computed_digest(7, [object.clone()]).expect("schema-v7 digest"); - let v7 = SchemaVersionCatalog::new(7, [object], v7_digest).expect("schema v7"); - let schema = SchemaCatalog::new(&expected_migrations, [v1, v2, v3, v4, v5, v6, v7]) + let v7 = SchemaVersionCatalog::new(7, [object.clone()], v7_digest).expect("schema v7"); + let v8_digest = + SchemaVersionCatalog::computed_digest(8, [object.clone()]).expect("schema-v8 digest"); + let v8 = SchemaVersionCatalog::new(8, [object], v8_digest).expect("schema v8"); + let schema = SchemaCatalog::new(&expected_migrations, [v1, v2, v3, v4, v5, v6, v7, v8]) .expect("drift schema catalog"); assert_eq!( validate_myc_state_catalogs(&expected_migrations, &schema)