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:
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)