myc

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

commit 9d9d291fdc29cfa0d4fe3278fa1440631c7611cf
parent 4a73308a1cfd57fbe37dc89068565a71721d9a48
Author: triesap <tyson@radroots.org>
Date:   Sat, 22 Aug 2026 06:10:59 +0000

status: freeze passive lifecycle cache

Diffstat:
MAGENTS.md | 8++++++++
MREADME | 23+++++++++++++++++++++++
Mcontracts/api_baselines/myc.txt | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/services_hardening/status_cache.v1.json | 46++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 11+++++++++++
Asrc/status_v1.rs | 1041+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/package_boundary.rs | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Atests/services_hardening_status.rs | 457+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 1821 insertions(+), 1 deletion(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -122,6 +122,14 @@ arbitrary details, unbounded output, detached probe work, or liveness/readiness probe authority. A pass must prove every contracted scope facet, and deadline cancellation must stop or synchronously own cleanup. +- Step 154 freezes one passive latest-value status cache around the shared Lib + lifecycle primitive. The non-clone publisher encodes the complete bounded v1 + local-status envelope before atomic replacement; cloneable readers may only + return the retained immutable snapshot. Status reads never query SQLite, + providers, relays, credentials, DNS, time, or fresh probes and never spawn a + task. Keep connection-count keys and identity roles closed, preserve the last + valid snapshot on any failed publication, admit only the fixed twelve status + reasons, and keep detailed status on the permissioned Unix-admin boundary. - Treat checked-in source, tests, and prototype behavior as implementation evidence, not permission to preserve behavior that the active requirement removes. diff --git a/README b/README @@ -24,6 +24,16 @@ fn forge(_: MycRuntimeFoundation) {} The reviewed all-features surface is frozen in the [Myc API baseline](contracts/api_baselines/myc.txt). +Status publication and cached snapshots can be obtained only through the +validated factory and its sealed handles: + +```compile_fail +use myc::{MycStatusPublisher, MycStatusSnapshot}; + +let _publisher = MycStatusPublisher {}; +let _snapshot = MycStatusSnapshot {}; +``` + The native package and dependency-trust metadata is frozen by `contracts/services_hardening/native_release.v1.json`. Linux x86_64 and aarch64 are declared release targets, not qualified artifacts. The canonical @@ -71,6 +81,19 @@ credentials, public keys, and arbitrary detail strings cannot enter the report. A pass requires every machine-listed scope facet. Probe futures own their work, must stop safely when dropped at deadline, and may not detach later mutation. +The service status boundary uses the shared service-host lifecycle contract +behind one Myc-owned non-clone publisher and cloneable passive readers. Each +publication validates the lifecycle transition and encodes the complete +bounded `service_status_v1` payload before atomically replacing the single +retained immutable snapshot. Repeated reads return that exact cached value and +perform no SQLite, provider, relay, credential, DNS, clock, filesystem, or +fresh-probe work. Connection counts use the closed pending/active/denied/ +expired vocabulary, identity health uses the fixed transport/user/discovery +roles, failed publication preserves the last valid snapshot, and detailed +status remains local to the permissioned Unix-admin surface. Status reason +codes use a closed twelve-value vocabulary; arbitrary strings cannot enter the +cached response. + `parse_myc_config_v1` caps original bytes before decoding, checks the schema header before closed contract admission, rejects duplicate, null, unknown, and semantically inconsistent input, and returns an immutable document plus a diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt @@ -378,6 +378,10 @@ pub myc::MycIdentityCommandV1::Init pub myc::MycIdentityCommandV1::Rekey pub myc::MycIdentityCommandV1::Replace pub myc::MycIdentityCommandV1::Status +pub enum myc::MycIntegrityStateV1 +pub myc::MycIntegrityStateV1::Failed +pub myc::MycIntegrityStateV1::VerificationRequired +pub myc::MycIntegrityStateV1::Verified pub enum myc::MycLocalSignerTransportErrorKind pub myc::MycLocalSignerTransportErrorKind::Deadline pub myc::MycLocalSignerTransportErrorKind::InvalidBinding @@ -481,6 +485,11 @@ pub enum myc::MycNip46WorkKind pub myc::MycNip46WorkKind::Connect pub myc::MycNip46WorkKind::Local pub myc::MycNip46WorkKind::Provider +pub enum myc::MycPersistenceHealthV1 +pub myc::MycPersistenceHealthV1::ReadOnly +pub myc::MycPersistenceHealthV1::Ready +pub myc::MycPersistenceHealthV1::RepairRequired +pub myc::MycPersistenceHealthV1::Unavailable pub enum myc::MycProviderCapability pub myc::MycProviderCapability::Describe pub myc::MycProviderCapability::Nip04Decrypt @@ -501,6 +510,10 @@ pub myc::MycProviderContractErrorKind::InvalidInput pub myc::MycProviderContractErrorKind::InvalidLimits pub myc::MycProviderContractErrorKind::InvalidOutput pub myc::MycProviderContractErrorKind::UnsupportedOperation +pub enum myc::MycProviderHealthV1 +pub myc::MycProviderHealthV1::Degraded +pub myc::MycProviderHealthV1::Ready +pub myc::MycProviderHealthV1::Unavailable pub enum myc::MycProviderInstanceId pub myc::MycProviderInstanceId::Discovery pub myc::MycProviderInstanceId::Transport @@ -580,6 +593,13 @@ pub myc::MycRuntimeReadinessReason::SignerProviderUnavailable pub myc::MycRuntimeReadinessReason::SubscriberNotActive impl myc::MycRuntimeReadinessReason pub const fn myc::MycRuntimeReadinessReason::as_str(self) -> &'static str +pub enum myc::MycServicePhase +pub myc::MycServicePhase::Degraded +pub myc::MycServicePhase::Failed +pub myc::MycServicePhase::Ready +pub myc::MycServicePhase::Starting +pub myc::MycServicePhase::Stopping +pub myc::MycServicePhase::Unready pub enum myc::MycSignerRequestAdmission pub myc::MycSignerRequestAdmission::Admitted(myc::MycSignerRequestRecord) pub myc::MycSignerRequestAdmission::ConflictingReuse(myc::MycSignerRequestRecord) @@ -663,6 +683,50 @@ pub myc::MycStateRepositoryErrorKind::CommitOutcomeUnknown pub myc::MycStateRepositoryErrorKind::Transaction impl myc::MycStateRepositoryErrorKind pub const fn myc::MycStateRepositoryErrorKind::code(self) -> &'static str +pub enum myc::MycStatusBuildMode +pub myc::MycStatusBuildMode::Development +pub myc::MycStatusBuildMode::Release +pub enum myc::MycStatusConfigurationSource +pub myc::MycStatusConfigurationSource::DerivedRepoLocal +pub myc::MycStatusConfigurationSource::ExplicitConfig +pub enum myc::MycStatusErrorKind +pub myc::MycStatusErrorKind::Encoding +pub myc::MycStatusErrorKind::InvalidBuildInfo +pub myc::MycStatusErrorKind::InvalidConfiguration +pub myc::MycStatusErrorKind::InvalidIdentityHealth +pub myc::MycStatusErrorKind::InvalidLifecycle +pub myc::MycStatusErrorKind::InvalidModel +pub myc::MycStatusErrorKind::InvalidPersistence +pub myc::MycStatusErrorKind::InvalidProviderState +pub myc::MycStatusErrorKind::InvalidReasonCode +pub myc::MycStatusErrorKind::InvalidTime +pub myc::MycStatusErrorKind::InvalidTransition +pub myc::MycStatusErrorKind::InvalidTransportState +pub myc::MycStatusErrorKind::PublisherDropped +pub myc::MycStatusErrorKind::ResponseTooLarge +pub myc::MycStatusErrorKind::TooManyReasonCodes +impl myc::MycStatusErrorKind +pub const fn myc::MycStatusErrorKind::code(self) -> &'static str +pub enum myc::MycStatusReasonCode +pub myc::MycStatusReasonCode::AdminListenerFailed +pub myc::MycStatusReasonCode::DatabaseLowDisk +pub myc::MycStatusReasonCode::DatabaseReadOnly +pub myc::MycStatusReasonCode::DatabaseSchemaMismatch +pub myc::MycStatusReasonCode::IdentityUnavailable +pub myc::MycStatusReasonCode::OperationsListenerFailed +pub myc::MycStatusReasonCode::OutboxInvariantFailed +pub myc::MycStatusReasonCode::PublicationBacklogExceeded +pub myc::MycStatusReasonCode::RequiredRelayUnavailable +pub myc::MycStatusReasonCode::ShutdownInProgress +pub myc::MycStatusReasonCode::SignerProviderUnavailable +pub myc::MycStatusReasonCode::SubscriberNotActive +impl myc::MycStatusReasonCode +pub const fn myc::MycStatusReasonCode::as_str(self) -> &'static str +pub fn myc::MycStatusReasonCode::new(impl core::convert::AsRef<str>) -> core::result::Result<Self, myc::MycStatusError> +pub enum myc::MycTransportHealthV1 +pub myc::MycTransportHealthV1::Degraded +pub myc::MycTransportHealthV1::Ready +pub myc::MycTransportHealthV1::Unavailable pub struct myc::MycAdminDocumentError impl myc::MycAdminDocumentError pub const fn myc::MycAdminDocumentError::kind(self) -> myc::MycAdminDocumentErrorKind @@ -825,6 +889,13 @@ impl myc::MycConnectionAdmissionRequest pub fn myc::MycConnectionAdmissionRequest::new(myc::MycSignerOperationId, myc::MycNip46ClientPublicKey, myc::MycConnectionPermissionSet, myc::MycConnectionPolicyGeneration, myc::MycConnectionNonce, myc::MycConnectionTimeUnixMs, core::option::Option<myc::MycConnectionTimeUnixMs>, myc::MycConnectionAdmissionPolicy, myc::MycRateRelayId) -> core::result::Result<Self, myc::MycConnectionStateError> impl core::fmt::Debug for myc::MycConnectionAdmissionRequest pub fn myc::MycConnectionAdmissionRequest::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycConnectionCountsV1 +impl myc::MycConnectionCountsV1 +pub const fn myc::MycConnectionCountsV1::active(self) -> u64 +pub const fn myc::MycConnectionCountsV1::denied(self) -> u64 +pub const fn myc::MycConnectionCountsV1::expired(self) -> u64 +pub const fn myc::MycConnectionCountsV1::new(u64, u64, u64, u64) -> Self +pub const fn myc::MycConnectionCountsV1::pending(self) -> u64 pub struct myc::MycConnectionDecisionRecord impl myc::MycConnectionDecisionRecord pub const fn myc::MycConnectionDecisionRecord::connection(&self) -> core::option::Option<&myc::MycConnectionRecord> @@ -1130,6 +1201,12 @@ impl core::fmt::Debug for myc::MycGovernanceStateError pub fn myc::MycGovernanceStateError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::fmt::Display for myc::MycGovernanceStateError pub fn myc::MycGovernanceStateError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycIdentityHealthV1 +impl myc::MycIdentityHealthV1 +pub const fn myc::MycIdentityHealthV1::is_available(&self) -> bool +pub const fn myc::MycIdentityHealthV1::is_configured(&self) -> bool +pub fn myc::MycIdentityHealthV1::new(bool, bool, myc::MycStatusReasonCodes) -> core::result::Result<Self, myc::MycStatusError> +pub const fn myc::MycIdentityHealthV1::reason_codes(&self) -> &myc::MycStatusReasonCodes pub struct myc::MycLocalSignerClient impl myc::MycLocalSignerClient pub async fn myc::MycLocalSignerClient::execute(&self, &myc::MycProviderOperation) -> core::result::Result<myc::MycLocalSignerUntrustedResponse, myc::MycLocalSignerTransportError> @@ -1331,6 +1408,17 @@ impl myc::MycNormalizedConfigDigest pub const fn myc::MycNormalizedConfigDigest::as_bytes(&self) -> &[u8; 32] impl core::fmt::Debug for myc::MycNormalizedConfigDigest pub fn myc::MycNormalizedConfigDigest::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycOutboxStatusV1 +impl myc::MycOutboxStatusV1 +pub const fn myc::MycOutboxStatusV1::new(u64, u64, core::option::Option<myc::MycStatusUnixSeconds>) -> Self +pub const fn myc::MycOutboxStatusV1::oldest_pending_at_utc(self) -> core::option::Option<myc::MycStatusUnixSeconds> +pub const fn myc::MycOutboxStatusV1::pending(self) -> u64 +pub const fn myc::MycOutboxStatusV1::unknown(self) -> u64 +pub struct myc::MycPersistenceStatusV1 +impl myc::MycPersistenceStatusV1 +pub fn myc::MycPersistenceStatusV1::new(myc::MycPersistenceHealthV1, u32, u64, myc::MycIntegrityStateV1, myc::MycStatusReasonCodes) -> core::result::Result<Self, myc::MycStatusError> +impl core::fmt::Debug for myc::MycPersistenceStatusV1 +pub fn myc::MycPersistenceStatusV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycPreparedNip46Request impl myc::MycPreparedNip46Request pub const fn myc::MycPreparedNip46Request::method(&self) -> myc::MycSignerRequestMethod @@ -1432,6 +1520,14 @@ pub struct myc::MycProviderResponseObservedAtUnixMs(_) impl myc::MycProviderResponseObservedAtUnixMs pub const fn myc::MycProviderResponseObservedAtUnixMs::get(self) -> u64 pub fn myc::MycProviderResponseObservedAtUnixMs::new(u64) -> core::result::Result<Self, myc::MycProviderVerificationError> +pub struct myc::MycProviderStatusV1 +impl myc::MycProviderStatusV1 +pub const fn myc::MycProviderStatusV1::discovery(&self) -> &myc::MycIdentityHealthV1 +pub const fn myc::MycProviderStatusV1::health(&self) -> myc::MycProviderHealthV1 +pub fn myc::MycProviderStatusV1::new(myc::MycIdentityHealthV1, myc::MycIdentityHealthV1, myc::MycIdentityHealthV1, myc::MycStatusReasonCodes) -> core::result::Result<Self, myc::MycStatusError> +pub const fn myc::MycProviderStatusV1::reason_codes(&self) -> &myc::MycStatusReasonCodes +pub const fn myc::MycProviderStatusV1::transport(&self) -> &myc::MycIdentityHealthV1 +pub const fn myc::MycProviderStatusV1::user(&self) -> &myc::MycIdentityHealthV1 pub struct myc::MycProviderVerificationError impl myc::MycProviderVerificationError pub const fn myc::MycProviderVerificationError::code(self) -> &'static str @@ -1452,6 +1548,13 @@ impl myc::MycRateRelayId pub fn myc::MycRateRelayId::new(&str) -> core::result::Result<Self, myc::MycGovernanceStateError> impl core::fmt::Debug for myc::MycRateRelayId pub fn myc::MycRateRelayId::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycRelayTransportStatusV1 +impl myc::MycRelayTransportStatusV1 +pub const fn myc::MycRelayTransportStatusV1::connected_relay_count(&self) -> u64 +pub const fn myc::MycRelayTransportStatusV1::health(&self) -> myc::MycTransportHealthV1 +pub fn myc::MycRelayTransportStatusV1::new(myc::MycTransportHealthV1, bool, u64, myc::MycStatusReasonCodes) -> core::result::Result<Self, myc::MycStatusError> +pub const fn myc::MycRelayTransportStatusV1::reason_codes(&self) -> &myc::MycStatusReasonCodes +pub const fn myc::MycRelayTransportStatusV1::required_relays_ready(&self) -> bool pub struct myc::MycReplayBoundNip46Request impl myc::MycReplayBoundNip46Request pub fn myc::MycReplayBoundNip46Request::classify_against(&self, myc::MycNip46ReplayKey) -> myc::MycNip46ReplayDisposition @@ -1664,6 +1767,65 @@ impl core::fmt::Debug for myc::MycStateRepositoryError pub fn myc::MycStateRepositoryError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::fmt::Display for myc::MycStateRepositoryError pub fn myc::MycStateRepositoryError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycStatusBuildInfoV1 +impl myc::MycStatusBuildInfoV1 +pub fn myc::MycStatusBuildInfoV1::new(myc::MycStatusBuildMode, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>) -> core::result::Result<Self, myc::MycStatusError> +impl core::fmt::Debug for myc::MycStatusBuildInfoV1 +pub fn myc::MycStatusBuildInfoV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycStatusCommonV1 +impl myc::MycStatusCommonV1 +pub fn myc::MycStatusCommonV1::new(myc::MycServicePhase, bool, myc::MycStatusReasonCodes, u64, myc::MycStatusBuildInfoV1, myc::MycStatusConfigurationIdentityV1, myc::MycPersistenceStatusV1) -> core::result::Result<Self, myc::MycStatusError> +impl core::fmt::Debug for myc::MycStatusCommonV1 +pub fn myc::MycStatusCommonV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycStatusConfigurationIdentityV1 +impl myc::MycStatusConfigurationIdentityV1 +pub fn myc::MycStatusConfigurationIdentityV1::new(impl core::convert::AsRef<str>, myc::MycStatusConfigurationSource) -> core::result::Result<Self, myc::MycStatusError> +impl core::fmt::Debug for myc::MycStatusConfigurationIdentityV1 +pub fn myc::MycStatusConfigurationIdentityV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycStatusError +impl myc::MycStatusError +pub const fn myc::MycStatusError::code(self) -> &'static str +pub const fn myc::MycStatusError::kind(self) -> myc::MycStatusErrorKind +impl core::error::Error for myc::MycStatusError +impl core::fmt::Debug for myc::MycStatusError +pub fn myc::MycStatusError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for myc::MycStatusError +pub fn myc::MycStatusError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycStatusObservationV1 +impl myc::MycStatusObservationV1 +pub fn myc::MycStatusObservationV1::new(myc::MycStatusCommonV1, myc::MycProviderStatusV1, myc::MycRelayTransportStatusV1, myc::MycConnectionCountsV1, myc::MycOutboxStatusV1) -> Self +impl core::fmt::Debug for myc::MycStatusObservationV1 +pub fn myc::MycStatusObservationV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycStatusPublisher +impl myc::MycStatusPublisher +pub fn myc::MycStatusPublisher::publish(&mut self, myc::MycStatusObservationV1) -> core::result::Result<(), myc::MycStatusError> +pub fn myc::MycStatusPublisher::subscribe(&self) -> myc::MycStatusReader +impl core::fmt::Debug for myc::MycStatusPublisher +pub fn myc::MycStatusPublisher::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycStatusReader +impl myc::MycStatusReader +pub async fn myc::MycStatusReader::changed(&mut self) -> core::result::Result<myc::MycStatusSnapshot, myc::MycStatusError> +pub fn myc::MycStatusReader::snapshot(&self) -> myc::MycStatusSnapshot +impl core::clone::Clone for myc::MycStatusReader +pub fn myc::MycStatusReader::clone(&self) -> Self +impl core::fmt::Debug for myc::MycStatusReader +pub fn myc::MycStatusReader::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycStatusReasonCodes(_) +impl myc::MycStatusReasonCodes +pub fn myc::MycStatusReasonCodes::as_slice(&self) -> &[myc::MycStatusReasonCode] +pub const fn myc::MycStatusReasonCodes::empty() -> Self +pub fn myc::MycStatusReasonCodes::new(impl core::iter::traits::collect::IntoIterator<Item = myc::MycStatusReasonCode>) -> core::result::Result<Self, myc::MycStatusError> +pub struct myc::MycStatusSnapshot +impl myc::MycStatusSnapshot +pub fn myc::MycStatusSnapshot::detailed_status_json(&self) -> &[u8] +pub fn myc::MycStatusSnapshot::is_ready(&self) -> bool +pub fn myc::MycStatusSnapshot::phase(&self) -> myc::MycServicePhase +impl core::fmt::Debug for myc::MycStatusSnapshot +pub fn myc::MycStatusSnapshot::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycStatusUnixSeconds(_) +impl myc::MycStatusUnixSeconds +pub const fn myc::MycStatusUnixSeconds::get(self) -> u64 +pub fn myc::MycStatusUnixSeconds::new(u64) -> core::result::Result<Self, myc::MycStatusError> pub struct myc::MycUntrustedProviderOutput(_) impl myc::MycUntrustedProviderOutput pub fn myc::MycUntrustedProviderOutput::as_bytes(&self) -> &[u8] @@ -1726,6 +1888,7 @@ pub const myc::MYC_DELIVERY_RECOVERY_BATCH_MAX_COUNT: usize pub const myc::MYC_DELIVERY_RELAY_ID_MAX_BYTES: usize pub const myc::MYC_DELIVERY_RETRY_JITTER_MAX_MS: u64 pub const myc::MYC_DELIVERY_TARGET_MAX_COUNT: usize +pub const myc::MYC_DETAILED_STATUS_MAX_UTF8_BYTES: usize pub const myc::MYC_DISCOVERY_DOCUMENT_MAX_BYTES: usize pub const myc::MYC_DOCTOR_CHECK_COUNT: usize pub const myc::MYC_DOCTOR_CONTRACT_VERSION: u32 @@ -1789,6 +1952,8 @@ pub const myc::MYC_STATE_SCHEMA_VERSION_8_SHA256: [u8; 32] pub const myc::MYC_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256: [u8; 32] pub const myc::MYC_STATE_SCHEMA_VERSION_9_OBJECT_COUNT: u32 pub const myc::MYC_STATE_SCHEMA_VERSION_9_SHA256: [u8; 32] +pub const myc::MYC_STATUS_CACHE_CONTRACT_VERSION: u32 +pub const myc::MYC_STATUS_REASON_CODE_COUNT: usize pub const myc::MYC_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize pub const myc::MYC_WRAPPING_CREDENTIAL_CONTRACT_VERSION: u32 pub trait myc::MycAdminHandler: core::marker::Send + core::marker::Sync + 'static @@ -1804,6 +1969,7 @@ pub async fn myc::initialize_myc_state(&myc::MycRuntimeContext, &myc::MycStateMe pub const fn myc::myc_doctor_check_definitions() -> &'static [myc::MycDoctorCheckDefinition; 13] pub fn myc::myc_migration_catalog() -> core::result::Result<radroots_service_sqlite::migration::MigrationCatalog, myc::MycStateCatalogError> pub fn myc::myc_schema_catalog() -> core::result::Result<radroots_service_sqlite::integrity::catalog::SchemaCatalog, myc::MycStateCatalogError> +pub fn myc::myc_status_cache(radroots_runtime_paths::identifier::InstanceId, myc::MycStatusObservationV1) -> core::result::Result<(myc::MycStatusPublisher, myc::MycStatusReader), myc::MycStatusError> pub fn myc::open_myc_encrypted_identity(&myc::MycProviderBinding, &myc::MycWrappingCredential) -> core::result::Result<myc::MycDecryptedIdentity, myc::MycEncryptedIdentityEnvelopeError> pub async fn myc::open_myc_runtime_foundation(myc::MycRuntimeContext, myc::MycConfigDocumentV1, myc::MycStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<myc::MycRuntimeFoundation, myc::MycRuntimeFoundationError> pub async fn myc::open_myc_state_inspection(&myc::MycRuntimeContext, &myc::MycStateMetadata) -> core::result::Result<myc::MycStateHost, myc::MycStateHostError> diff --git a/contracts/services_hardening/status_cache.v1.json b/contracts/services_hardening/status_cache.v1.json @@ -0,0 +1,46 @@ +{ + "schema": "radroots.myc.status-cache.v1", + "contract_version": 1, + "step": 154, + "shared_cache": "radroots_service_host::cached_service_state", + "publication": { + "authority": "single_non_clone_publisher", + "capacity": "one_latest_immutable_arc", + "replacement": "atomic", + "phase_transition": "shared_service_operational_state", + "failed_publication": "previous_snapshot_retained" + }, + "read": { + "kind": "passive_cached_snapshot", + "await_required": false, + "sqlite": false, + "provider": false, + "relay": false, + "credential": false, + "dns": false, + "clock": false, + "fresh_probe": false, + "task_spawn": false + }, + "detailed_status": { + "model": "service_status_v1", + "service": "myc", + "local_transport": "permissioned_unix_admin_only", + "construction": "bounded_encode_before_atomic_publication", + "maximum_utf8_bytes": 1048576, + "connection_count_keys": ["pending", "active", "denied", "expired"], + "identity_roles": ["transport", "user", "discovery"], + "reason_codes": ["identity_unavailable", "database_schema_mismatch", "database_read_only", "database_low_disk", "required_relay_unavailable", "subscriber_not_active", "signer_provider_unavailable", "outbox_invariant_failed", "publication_backlog_exceeded", "admin_listener_failed", "operations_listener_failed", "shutdown_in_progress"], + "oldest_pending_time": "optional_nonnegative_i64_range_unix_seconds", + "protected_material": false, + "filesystem_paths": false, + "raw_errors": false + }, + "deferred": [ + "unix_admin_handler_binding", + "tcp_operations_binding", + "metrics_rendering", + "final_runtime_task_graph", + "signal_handling" + ] +} diff --git a/src/lib.rs b/src/lib.rs @@ -35,6 +35,7 @@ mod state_recovery; mod state_repository; mod state_request; mod state_response; +mod status_v1; #[cfg(any(target_os = "linux", target_os = "macos"))] pub use admin_v1::{ @@ -216,3 +217,13 @@ pub use state_response::{ MycNip46ResponseCommitAdmission, MycNip46ResponseCommitError, MycNip46ResponseCommitErrorKind, MycNip46ResponseCommitRecord, MycNip46ResponseCommitRequest, MycNip46ResponseRecord, }; +pub use status_v1::{ + MYC_DETAILED_STATUS_MAX_UTF8_BYTES, MYC_STATUS_CACHE_CONTRACT_VERSION, + MYC_STATUS_REASON_CODE_COUNT, MycConnectionCountsV1, MycIdentityHealthV1, MycIntegrityStateV1, + MycOutboxStatusV1, MycPersistenceHealthV1, MycPersistenceStatusV1, MycProviderHealthV1, + MycProviderStatusV1, MycRelayTransportStatusV1, MycServicePhase, MycStatusBuildInfoV1, + MycStatusBuildMode, MycStatusCommonV1, MycStatusConfigurationIdentityV1, + MycStatusConfigurationSource, MycStatusError, MycStatusErrorKind, MycStatusObservationV1, + MycStatusPublisher, MycStatusReader, MycStatusReasonCode, MycStatusReasonCodes, + MycStatusSnapshot, MycStatusUnixSeconds, MycTransportHealthV1, myc_status_cache, +}; diff --git a/src/status_v1.rs b/src/status_v1.rs @@ -0,0 +1,1041 @@ +//! Passive, latest-value Myc lifecycle and detailed-status publication. + +use core::fmt; +use std::{error::Error, sync::Arc, time::Duration}; + +use radroots_service_host::{ + BuildInfo as HostBuildInfo, BuildInfoEnvironment as HostBuildInfoEnvironment, + BuildMode as HostBuildMode, CachedServiceState, CachedServiceStatePublisher, + CachedServiceStateReader, ConfigurationIdentity as HostConfigurationIdentity, + ConfigurationSource as HostConfigurationSource, ContractVersions as HostContractVersions, + InstanceId, IntegrityState as HostIntegrityState, PersistenceHealth as HostPersistenceHealth, + PersistenceSummary as HostPersistenceSummary, Readiness as HostReadiness, + ReasonCode as HostReasonCode, ReasonCodes as HostReasonCodes, ServiceId, + ServiceOperationalState as HostServiceOperationalState, ServicePhase as HostServicePhase, + ServiceStatus, ServiceStatusDetail, Sha256Digest as HostSha256Digest, StatusContractError, + StatusEncodingError, StatusModelError, UptimeMillis as HostUptimeMillis, cached_service_state, +}; +use serde::Serialize; + +/// Exact version of the passive Myc status-cache contract. +pub const MYC_STATUS_CACHE_CONTRACT_VERSION: u32 = 1; + +/// Maximum encoded byte length of one detailed Myc status response. +pub const MYC_DETAILED_STATUS_MAX_UTF8_BYTES: usize = + radroots_service_host::SERVICE_STATUS_MAX_UTF8_BYTES; + +/// Number of stable reason codes admitted by detailed Myc status. +pub const MYC_STATUS_REASON_CODE_COUNT: usize = 12; + +/// One closed stable source-free status reason code. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MycStatusReasonCode { + IdentityUnavailable, + DatabaseSchemaMismatch, + DatabaseReadOnly, + DatabaseLowDisk, + RequiredRelayUnavailable, + SubscriberNotActive, + SignerProviderUnavailable, + OutboxInvariantFailed, + PublicationBacklogExceeded, + AdminListenerFailed, + OperationsListenerFailed, + ShutdownInProgress, +} + +impl MycStatusReasonCode { + pub fn new(value: impl AsRef<str>) -> Result<Self, MycStatusError> { + match value.as_ref() { + "identity_unavailable" => Ok(Self::IdentityUnavailable), + "database_schema_mismatch" => Ok(Self::DatabaseSchemaMismatch), + "database_read_only" => Ok(Self::DatabaseReadOnly), + "database_low_disk" => Ok(Self::DatabaseLowDisk), + "required_relay_unavailable" => Ok(Self::RequiredRelayUnavailable), + "subscriber_not_active" => Ok(Self::SubscriberNotActive), + "signer_provider_unavailable" => Ok(Self::SignerProviderUnavailable), + "outbox_invariant_failed" => Ok(Self::OutboxInvariantFailed), + "publication_backlog_exceeded" => Ok(Self::PublicationBacklogExceeded), + "admin_listener_failed" => Ok(Self::AdminListenerFailed), + "operations_listener_failed" => Ok(Self::OperationsListenerFailed), + "shutdown_in_progress" => Ok(Self::ShutdownInProgress), + _ => Err(MycStatusError::new(MycStatusErrorKind::InvalidReasonCode)), + } + } + + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::IdentityUnavailable => "identity_unavailable", + Self::DatabaseSchemaMismatch => "database_schema_mismatch", + Self::DatabaseReadOnly => "database_read_only", + Self::DatabaseLowDisk => "database_low_disk", + Self::RequiredRelayUnavailable => "required_relay_unavailable", + Self::SubscriberNotActive => "subscriber_not_active", + Self::SignerProviderUnavailable => "signer_provider_unavailable", + Self::OutboxInvariantFailed => "outbox_invariant_failed", + Self::PublicationBacklogExceeded => "publication_backlog_exceeded", + Self::AdminListenerFailed => "admin_listener_failed", + Self::OperationsListenerFailed => "operations_listener_failed", + Self::ShutdownInProgress => "shutdown_in_progress", + } + } +} + +/// Canonically ordered, unique, bounded status reasons. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct MycStatusReasonCodes(Vec<MycStatusReasonCode>); + +impl MycStatusReasonCodes { + #[must_use] + pub const fn empty() -> Self { + Self(Vec::new()) + } + + pub fn new( + values: impl IntoIterator<Item = MycStatusReasonCode>, + ) -> Result<Self, MycStatusError> { + let mut bounded = Vec::with_capacity(MYC_STATUS_REASON_CODE_COUNT); + for value in values.into_iter().take(MYC_STATUS_REASON_CODE_COUNT + 1) { + if bounded.len() == MYC_STATUS_REASON_CODE_COUNT { + return Err(MycStatusError::new(MycStatusErrorKind::TooManyReasonCodes)); + } + bounded.push(value); + } + bounded.sort_unstable(); + bounded.dedup(); + Ok(Self(bounded)) + } + + #[must_use] + pub fn as_slice(&self) -> &[MycStatusReasonCode] { + &self.0 + } + + fn into_host(self) -> Result<HostReasonCodes, MycStatusError> { + let values = self + .0 + .into_iter() + .map(|value| HostReasonCode::new(value.as_str()).map_err(map_contract_error)) + .collect::<Result<Vec<_>, _>>()?; + HostReasonCodes::new(values).map_err(map_contract_error) + } +} + +/// Closed common lifecycle phase used by the Myc public boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MycServicePhase { + Starting, + Ready, + Degraded, + Unready, + Stopping, + Failed, +} + +impl MycServicePhase { + const fn into_host(self) -> HostServicePhase { + match self { + Self::Starting => HostServicePhase::Starting, + Self::Ready => HostServicePhase::Ready, + Self::Degraded => HostServicePhase::Degraded, + Self::Unready => HostServicePhase::Unready, + Self::Stopping => HostServicePhase::Stopping, + Self::Failed => HostServicePhase::Failed, + } + } + + const fn from_host(value: HostServicePhase) -> Self { + match value { + HostServicePhase::Starting => Self::Starting, + HostServicePhase::Ready => Self::Ready, + HostServicePhase::Degraded => Self::Degraded, + HostServicePhase::Unready => Self::Unready, + HostServicePhase::Stopping => Self::Stopping, + HostServicePhase::Failed => Self::Failed, + } + } +} + +/// Build-metadata admission mode for Myc status identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycStatusBuildMode { + Development, + Release, +} + +/// Complete deterministic build identity retained behind the Myc boundary. +pub struct MycStatusBuildInfoV1 { + inner: HostBuildInfo, +} + +impl MycStatusBuildInfoV1 { + /// Validates the complete build/source-lock identity with fixed Myc contracts. + pub fn new( + mode: MycStatusBuildMode, + service_version: Option<&str>, + service_commit: Option<&str>, + lib_revision: Option<&str>, + rust_version: Option<&str>, + target: Option<&str>, + feature_profile: Option<&str>, + ) -> Result<Self, MycStatusError> { + let contract_versions = HostContractVersions::new(1, 9, 1, 1, 1) + .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidBuildInfo))?; + HostBuildInfo::from_compile_time( + match mode { + MycStatusBuildMode::Development => HostBuildMode::Development, + MycStatusBuildMode::Release => HostBuildMode::Release, + }, + HostBuildInfoEnvironment { + service_version, + service_commit, + lib_revision, + rust_version, + target, + feature_profile, + contract_versions, + }, + ) + .map(|inner| Self { inner }) + .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidBuildInfo)) + } +} + +impl fmt::Debug for MycStatusBuildInfoV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycStatusBuildInfoV1([redacted])") + } +} + +/// Exact configuration-source vocabulary exposed by detailed status. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycStatusConfigurationSource { + ExplicitConfig, + DerivedRepoLocal, +} + +/// Safe configuration identity retained behind the Myc boundary. +pub struct MycStatusConfigurationIdentityV1 { + inner: HostConfigurationIdentity, +} + +impl MycStatusConfigurationIdentityV1 { + pub fn new( + digest: impl AsRef<str>, + source: MycStatusConfigurationSource, + ) -> Result<Self, MycStatusError> { + let service = ServiceId::new("myc") + .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidConfiguration))?; + let digest = HostSha256Digest::new(digest) + .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidConfiguration))?; + HostConfigurationIdentity::for_service( + &service, + digest, + match source { + MycStatusConfigurationSource::ExplicitConfig => { + HostConfigurationSource::ExplicitConfig + } + MycStatusConfigurationSource::DerivedRepoLocal => { + HostConfigurationSource::DerivedRepoLocal + } + }, + ) + .map(|inner| Self { inner }) + .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidConfiguration)) + } +} + +impl fmt::Debug for MycStatusConfigurationIdentityV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycStatusConfigurationIdentityV1([redacted])") + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycPersistenceHealthV1 { + Ready, + ReadOnly, + RepairRequired, + Unavailable, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycIntegrityStateV1 { + Verified, + VerificationRequired, + Failed, +} + +/// Validated persistence summary retained behind the Myc boundary. +pub struct MycPersistenceStatusV1 { + inner: HostPersistenceSummary, +} + +impl MycPersistenceStatusV1 { + pub fn new( + health: MycPersistenceHealthV1, + schema_version: u32, + generation: u64, + integrity: MycIntegrityStateV1, + reason_codes: MycStatusReasonCodes, + ) -> Result<Self, MycStatusError> { + let reason_codes = reason_codes.into_host()?; + HostPersistenceSummary::new( + match health { + MycPersistenceHealthV1::Ready => HostPersistenceHealth::Ready, + MycPersistenceHealthV1::ReadOnly => HostPersistenceHealth::ReadOnly, + MycPersistenceHealthV1::RepairRequired => HostPersistenceHealth::RepairRequired, + MycPersistenceHealthV1::Unavailable => HostPersistenceHealth::Unavailable, + }, + schema_version, + generation, + match integrity { + MycIntegrityStateV1::Verified => HostIntegrityState::Verified, + MycIntegrityStateV1::VerificationRequired => { + HostIntegrityState::VerificationRequired + } + MycIntegrityStateV1::Failed => HostIntegrityState::Failed, + }, + reason_codes, + ) + .map(|inner| Self { inner }) + .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidPersistence)) + } +} + +impl fmt::Debug for MycPersistenceStatusV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycPersistenceStatusV1([redacted])") + } +} + +/// One validated Unix timestamp used only for the oldest pending outbox item. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +pub struct MycStatusUnixSeconds(u64); + +impl MycStatusUnixSeconds { + /// Constructs a timestamp representable by SQLite and the frozen wire contract. + pub fn new(value: u64) -> Result<Self, MycStatusError> { + if value > i64::MAX as u64 { + return Err(MycStatusError::new(MycStatusErrorKind::InvalidTime)); + } + Ok(Self(value)) + } + + /// Returns exact whole Unix seconds. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Passive availability of one configured Myc identity role. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct MycIdentityHealthV1 { + configured: bool, + available: bool, + reason_codes: MycStatusReasonCodes, +} + +impl MycIdentityHealthV1 { + /// Constructs one role observation, rejecting availability without configuration. + pub fn new( + configured: bool, + available: bool, + reason_codes: MycStatusReasonCodes, + ) -> Result<Self, MycStatusError> { + if available && !configured { + return Err(MycStatusError::new( + MycStatusErrorKind::InvalidIdentityHealth, + )); + } + Ok(Self { + configured, + available, + reason_codes, + }) + } + + #[must_use] + pub const fn is_configured(&self) -> bool { + self.configured + } + + #[must_use] + pub const fn is_available(&self) -> bool { + self.available + } + + #[must_use] + pub const fn reason_codes(&self) -> &MycStatusReasonCodes { + &self.reason_codes + } +} + +/// Complete provider projection for the three fixed Myc identity roles. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct MycProviderStatusV1 { + health: MycProviderHealthV1, + transport: MycIdentityHealthV1, + user: MycIdentityHealthV1, + discovery: MycIdentityHealthV1, + reason_codes: MycStatusReasonCodes, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MycProviderHealthV1 { + Ready, + Degraded, + Unavailable, +} + +impl MycProviderStatusV1 { + /// Derives aggregate provider health from the exact role inventory. + pub fn new( + transport: MycIdentityHealthV1, + user: MycIdentityHealthV1, + discovery: MycIdentityHealthV1, + reason_codes: MycStatusReasonCodes, + ) -> Result<Self, MycStatusError> { + if !transport.configured || !user.configured { + return Err(MycStatusError::new( + MycStatusErrorKind::InvalidProviderState, + )); + } + let health = if !transport.available || !user.available { + MycProviderHealthV1::Unavailable + } else if discovery.configured && !discovery.available { + MycProviderHealthV1::Degraded + } else { + MycProviderHealthV1::Ready + }; + Ok(Self { + health, + transport, + user, + discovery, + reason_codes, + }) + } + + #[must_use] + pub const fn health(&self) -> MycProviderHealthV1 { + self.health + } + + #[must_use] + pub const fn transport(&self) -> &MycIdentityHealthV1 { + &self.transport + } + + #[must_use] + pub const fn user(&self) -> &MycIdentityHealthV1 { + &self.user + } + + #[must_use] + pub const fn discovery(&self) -> &MycIdentityHealthV1 { + &self.discovery + } + + #[must_use] + pub const fn reason_codes(&self) -> &MycStatusReasonCodes { + &self.reason_codes + } +} + +/// Passive relay-transport projection for detailed status. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct MycRelayTransportStatusV1 { + health: MycTransportHealthV1, + required_relays_ready: bool, + connected_relay_count: u64, + reason_codes: MycStatusReasonCodes, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MycTransportHealthV1 { + Ready, + Degraded, + Unavailable, +} + +impl MycRelayTransportStatusV1 { + /// Constructs one transport observation, rejecting contradictory ready state. + pub fn new( + health: MycTransportHealthV1, + required_relays_ready: bool, + connected_relay_count: u64, + reason_codes: MycStatusReasonCodes, + ) -> Result<Self, MycStatusError> { + if health == MycTransportHealthV1::Ready && !required_relays_ready { + return Err(MycStatusError::new( + MycStatusErrorKind::InvalidTransportState, + )); + } + Ok(Self { + health, + required_relays_ready, + connected_relay_count, + reason_codes, + }) + } + + #[must_use] + pub const fn health(&self) -> MycTransportHealthV1 { + self.health + } + + #[must_use] + pub const fn required_relays_ready(&self) -> bool { + self.required_relays_ready + } + + #[must_use] + pub const fn connected_relay_count(&self) -> u64 { + self.connected_relay_count + } + + #[must_use] + pub const fn reason_codes(&self) -> &MycStatusReasonCodes { + &self.reason_codes + } +} + +/// Exact closed connection-count vocabulary rendered as the safe-count map. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub struct MycConnectionCountsV1 { + pending: u64, + active: u64, + denied: u64, + expired: u64, +} + +impl MycConnectionCountsV1 { + #[must_use] + pub const fn new(pending: u64, active: u64, denied: u64, expired: u64) -> Self { + Self { + pending, + active, + denied, + expired, + } + } + + #[must_use] + pub const fn pending(self) -> u64 { + self.pending + } + + #[must_use] + pub const fn active(self) -> u64 { + self.active + } + + #[must_use] + pub const fn denied(self) -> u64 { + self.denied + } + + #[must_use] + pub const fn expired(self) -> u64 { + self.expired + } +} + +/// Passive outbox summary derived by an owning runtime task before publication. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub struct MycOutboxStatusV1 { + pending: u64, + unknown: u64, + #[serde(skip_serializing_if = "Option::is_none")] + oldest_pending_at_utc: Option<MycStatusUnixSeconds>, +} + +impl MycOutboxStatusV1 { + #[must_use] + pub const fn new( + pending: u64, + unknown: u64, + oldest_pending_at_utc: Option<MycStatusUnixSeconds>, + ) -> Self { + Self { + pending, + unknown, + oldest_pending_at_utc, + } + } + + #[must_use] + pub const fn pending(self) -> u64 { + self.pending + } + + #[must_use] + pub const fn unknown(self) -> u64 { + self.unknown + } + + #[must_use] + pub const fn oldest_pending_at_utc(self) -> Option<MycStatusUnixSeconds> { + self.oldest_pending_at_utc + } +} + +#[derive(Serialize)] +struct MycStatusDetailV1 { + transport: MycIdentityHealthV1, + user: MycIdentityHealthV1, + discovery: MycIdentityHealthV1, + connection_counts: MycConnectionCountsV1, + outbox: MycOutboxStatusV1, +} + +impl ServiceStatusDetail for MycStatusDetailV1 { + type Provider = MycProviderStatusV1; + type Transport = MycRelayTransportStatusV1; + + const FIELD_NAME: &'static str = "myc"; +} + +/// Validated common fields shared by one detailed status publication. +/// +/// Construction and publication perform validation and bounded encoding only. +/// They do not query SQLite, providers, relays, DNS, credentials, or the clock. +pub struct MycStatusCommonV1 { + operational: HostServiceOperationalState, + uptime: HostUptimeMillis, + build: HostBuildInfo, + configuration: HostConfigurationIdentity, + persistence: HostPersistenceSummary, +} + +impl MycStatusCommonV1 { + /// Validates the common lifecycle and detailed-status envelope fields. + pub fn new( + phase: MycServicePhase, + ready: bool, + reason_codes: MycStatusReasonCodes, + uptime_millis: u64, + build: MycStatusBuildInfoV1, + configuration: MycStatusConfigurationIdentityV1, + persistence: MycPersistenceStatusV1, + ) -> Result<Self, MycStatusError> { + let operational = HostServiceOperationalState::new( + phase.into_host(), + if ready { + HostReadiness::READY + } else { + HostReadiness::NOT_READY + }, + reason_codes.into_host()?, + ) + .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidLifecycle))?; + let uptime = HostUptimeMillis::from_duration(Duration::from_millis(uptime_millis)) + .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidTime))?; + Ok(Self { + operational, + uptime, + build: build.inner, + configuration: configuration.inner, + persistence: persistence.inner, + }) + } +} + +impl fmt::Debug for MycStatusCommonV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycStatusCommonV1([redacted])") + } +} + +/// One complete, already-observed status publication input. +pub struct MycStatusObservationV1 { + common: MycStatusCommonV1, + provider: MycProviderStatusV1, + transport: MycRelayTransportStatusV1, + connection_counts: MycConnectionCountsV1, + outbox: MycOutboxStatusV1, +} + +impl MycStatusObservationV1 { + #[must_use] + pub fn new( + common: MycStatusCommonV1, + provider: MycProviderStatusV1, + transport: MycRelayTransportStatusV1, + connection_counts: MycConnectionCountsV1, + outbox: MycOutboxStatusV1, + ) -> Self { + Self { + common, + provider, + transport, + connection_counts, + outbox, + } + } + + fn into_cached( + self, + instance: &InstanceId, + ) -> Result<CachedServiceState<MycCachedStatus>, MycStatusError> { + let operational = self.common.operational.clone(); + let detail = MycStatusDetailV1 { + transport: self.provider.transport.clone(), + user: self.provider.user.clone(), + discovery: self.provider.discovery.clone(), + connection_counts: self.connection_counts, + outbox: self.outbox, + }; + let service = ServiceId::new("myc") + .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidModel))?; + let status = ServiceStatus::new( + service, + instance.clone(), + self.common.operational, + self.common.uptime, + self.common.build, + self.common.configuration, + self.common.persistence, + self.provider, + self.transport, + detail, + ) + .map_err(map_model_error)?; + let json = status.to_bounded_json().map_err(map_encoding_error)?; + Ok(CachedServiceState::new( + operational, + MycCachedStatus { + json: json.into_boxed_slice(), + }, + )) + } +} + +impl fmt::Debug for MycStatusObservationV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycStatusObservationV1([redacted])") + } +} + +struct MycCachedStatus { + json: Box<[u8]>, +} + +impl fmt::Debug for MycCachedStatus { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycCachedStatus") + .field("json_utf8_bytes", &self.json.len()) + .finish() + } +} + +/// Sole publication authority for one process-local Myc status cache. +/// +/// This type deliberately does not implement `Clone`. A successful publish +/// atomically replaces the one retained snapshot. A failed encoding or illegal +/// lifecycle transition leaves the previous snapshot unchanged. +pub struct MycStatusPublisher { + instance: InstanceId, + inner: CachedServiceStatePublisher<MycCachedStatus>, +} + +impl MycStatusPublisher { + /// Encodes and atomically publishes one already-observed status value. + pub fn publish(&mut self, next: MycStatusObservationV1) -> Result<(), MycStatusError> { + let next = next.into_cached(&self.instance)?; + self.inner.publish(next).map_err(map_contract_error) + } + + /// Creates another passive reader without sharing publication authority. + #[must_use] + pub fn subscribe(&self) -> MycStatusReader { + MycStatusReader { + inner: self.inner.subscribe(), + } + } +} + +impl fmt::Debug for MycStatusPublisher { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycStatusPublisher([sealed])") + } +} + +/// Cloneable passive reader of the latest Myc lifecycle and detailed status. +pub struct MycStatusReader { + inner: CachedServiceStateReader<MycCachedStatus>, +} + +impl Clone for MycStatusReader { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl MycStatusReader { + /// Returns the latest immutable snapshot without awaiting or probing. + #[must_use] + pub fn snapshot(&self) -> MycStatusSnapshot { + MycStatusSnapshot { + inner: self.inner.snapshot(), + } + } + + /// Waits for a later publication and returns the newest retained value. + pub async fn changed(&mut self) -> Result<MycStatusSnapshot, MycStatusError> { + self.inner + .changed() + .await + .map(|inner| MycStatusSnapshot { inner }) + .map_err(|_| MycStatusError::new(MycStatusErrorKind::PublisherDropped)) + } +} + +impl fmt::Debug for MycStatusReader { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycStatusReader([passive])") + } +} + +/// One immutable point-in-time status snapshot backed by the retained cache `Arc`. +pub struct MycStatusSnapshot { + inner: Arc<CachedServiceState<MycCachedStatus>>, +} + +impl MycStatusSnapshot { + #[must_use] + pub fn phase(&self) -> MycServicePhase { + MycServicePhase::from_host(self.inner.operational().phase()) + } + + #[must_use] + pub fn is_ready(&self) -> bool { + self.inner.operational().readiness().is_ready() + } + + /// Returns the already-bounded canonical detailed-status JSON bytes. + #[must_use] + pub fn detailed_status_json(&self) -> &[u8] { + &self.inner.metrics().json + } +} + +impl fmt::Debug for MycStatusSnapshot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycStatusSnapshot") + .field("phase", &self.phase()) + .field("ready", &self.is_ready()) + .field("json_utf8_bytes", &self.detailed_status_json().len()) + .finish() + } +} + +/// Creates the single-writer, one-latest-value Myc status cache. +pub fn myc_status_cache( + instance: InstanceId, + initial: MycStatusObservationV1, +) -> Result<(MycStatusPublisher, MycStatusReader), MycStatusError> { + let initial = initial.into_cached(&instance)?; + let (inner, reader) = cached_service_state(initial); + Ok(( + MycStatusPublisher { instance, inner }, + MycStatusReader { inner: reader }, + )) +} + +/// Stable source-free status failure category. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycStatusErrorKind { + InvalidReasonCode, + TooManyReasonCodes, + InvalidLifecycle, + InvalidBuildInfo, + InvalidConfiguration, + InvalidPersistence, + InvalidIdentityHealth, + InvalidProviderState, + InvalidTransportState, + InvalidTime, + InvalidModel, + Encoding, + ResponseTooLarge, + InvalidTransition, + PublisherDropped, +} + +impl MycStatusErrorKind { + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidReasonCode => "status_reason_code_invalid", + Self::TooManyReasonCodes => "status_reason_count_exceeded", + Self::InvalidLifecycle => "status_lifecycle_invalid", + Self::InvalidBuildInfo => "status_build_info_invalid", + Self::InvalidConfiguration => "status_configuration_invalid", + Self::InvalidPersistence => "status_persistence_invalid", + Self::InvalidIdentityHealth => "status_identity_health_invalid", + Self::InvalidProviderState => "status_provider_state_invalid", + Self::InvalidTransportState => "status_transport_state_invalid", + Self::InvalidTime => "status_time_invalid", + Self::InvalidModel => "status_model_invalid", + Self::Encoding => "status_encoding_failed", + Self::ResponseTooLarge => "status_response_too_large", + Self::InvalidTransition => "status_transition_invalid", + Self::PublisherDropped => "status_publisher_dropped", + } + } + + const fn message(self) -> &'static str { + match self { + Self::InvalidReasonCode => "Myc status reason code is invalid", + Self::TooManyReasonCodes => "Myc status has too many reason codes", + Self::InvalidLifecycle => "Myc lifecycle status is invalid", + Self::InvalidBuildInfo => "Myc status build identity is invalid", + Self::InvalidConfiguration => "Myc status configuration identity is invalid", + Self::InvalidPersistence => "Myc persistence status is invalid", + Self::InvalidIdentityHealth => "Myc identity health is invalid", + Self::InvalidProviderState => "Myc provider status is invalid", + Self::InvalidTransportState => "Myc transport status is invalid", + Self::InvalidTime => "Myc status time is invalid", + Self::InvalidModel => "Myc detailed status is invalid", + Self::Encoding => "Myc detailed status encoding failed", + Self::ResponseTooLarge => "Myc detailed status exceeds its byte limit", + Self::InvalidTransition => "Myc lifecycle transition is invalid", + Self::PublisherDropped => "Myc status publisher is unavailable", + } + } +} + +/// One redacted source-free status failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycStatusError { + kind: MycStatusErrorKind, +} + +impl MycStatusError { + const fn new(kind: MycStatusErrorKind) -> Self { + Self { kind } + } + + #[must_use] + pub const fn kind(self) -> MycStatusErrorKind { + self.kind + } + + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Debug for MycStatusError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycStatusError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycStatusError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for MycStatusError {} + +const fn map_model_error(_error: StatusModelError) -> MycStatusError { + MycStatusError::new(MycStatusErrorKind::InvalidModel) +} + +const fn map_encoding_error(error: StatusEncodingError) -> MycStatusError { + match error { + StatusEncodingError::EncodingFailed => MycStatusError::new(MycStatusErrorKind::Encoding), + StatusEncodingError::ResponseTooLarge => { + MycStatusError::new(MycStatusErrorKind::ResponseTooLarge) + } + } +} + +const fn map_contract_error(_error: StatusContractError) -> MycStatusError { + MycStatusError::new(MycStatusErrorKind::InvalidTransition) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_status_inputs_fail_with_safe_source_free_errors() { + assert_eq!( + MycIdentityHealthV1::new(false, true, MycStatusReasonCodes::empty()), + Err(MycStatusError::new( + MycStatusErrorKind::InvalidIdentityHealth + )) + ); + let unavailable = + MycIdentityHealthV1::new(true, false, MycStatusReasonCodes::empty()).unwrap(); + assert_eq!( + MycProviderStatusV1::new( + MycIdentityHealthV1::new(false, false, MycStatusReasonCodes::empty()).unwrap(), + unavailable, + MycIdentityHealthV1::new(false, false, MycStatusReasonCodes::empty()).unwrap(), + MycStatusReasonCodes::empty(), + ), + Err(MycStatusError::new( + MycStatusErrorKind::InvalidProviderState + )) + ); + assert_eq!( + MycRelayTransportStatusV1::new( + MycTransportHealthV1::Ready, + false, + 0, + MycStatusReasonCodes::empty(), + ), + Err(MycStatusError::new( + MycStatusErrorKind::InvalidTransportState + )) + ); + assert_eq!( + MycStatusUnixSeconds::new(i64::MAX as u64 + 1), + Err(MycStatusError::new(MycStatusErrorKind::InvalidTime)) + ); + for kind in [ + MycStatusErrorKind::InvalidReasonCode, + MycStatusErrorKind::TooManyReasonCodes, + MycStatusErrorKind::InvalidLifecycle, + MycStatusErrorKind::InvalidBuildInfo, + MycStatusErrorKind::InvalidConfiguration, + MycStatusErrorKind::InvalidPersistence, + MycStatusErrorKind::InvalidIdentityHealth, + MycStatusErrorKind::InvalidProviderState, + MycStatusErrorKind::InvalidTransportState, + MycStatusErrorKind::InvalidTime, + MycStatusErrorKind::InvalidModel, + MycStatusErrorKind::Encoding, + MycStatusErrorKind::ResponseTooLarge, + MycStatusErrorKind::InvalidTransition, + MycStatusErrorKind::PublisherDropped, + ] { + let error = MycStatusError::new(kind); + assert!(!error.code().is_empty()); + assert!(Error::source(&error).is_none()); + assert!(!format!("{error} {error:?}").contains("source")); + } + } +} diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -15,6 +15,8 @@ const NIP46_COMPLETION: &str = include_str!("../src/state_completion.rs"); const NIP46_RESPONSE: &str = include_str!("../src/state_response.rs"); const DELIVERY_RECOVERY: &str = include_str!("../src/state_recovery.rs"); const DOCTOR_V1: &str = include_str!("../src/doctor_v1.rs"); +const STATUS_V1: &str = include_str!("../src/status_v1.rs"); +const STATUS_CONTRACT: &str = include_str!("../contracts/services_hardening/status_cache.v1.json"); const DISCOVERY_STATE: &str = include_str!("../src/state_discovery.rs"); const NIP46_VERIFICATION_CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_verification.v1.json"); @@ -49,6 +51,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/provider_verification.rs"), include_str!("../src/runtime_context.rs"), include_str!("../src/runtime_foundation.rs"), + include_str!("../src/status_v1.rs"), include_str!("../src/state_catalog.rs"), include_str!("../src/state_completion.rs"), include_str!("../src/state_connection.rs"), @@ -90,6 +93,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "provider_verification", "runtime_context", "runtime_foundation", + "status_v1", "state_catalog", "state_completion", "state_connection", @@ -114,6 +118,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "public errors use Myc-owned stable classifications", "```compile_fail", "[Myc API baseline](contracts/api_baselines/myc.txt)", + "Status publication and cached snapshots can be obtained only", ] { assert!(README.contains(required), "README is missing `{required}`"); } @@ -137,6 +142,12 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "pub enum myc::MycDoctorRemediationCode", "pub trait myc::MycDoctorProbe", "pub async fn myc::run_myc_doctor", + "pub struct myc::MycStatusPublisher", + "pub struct myc::MycStatusReader", + "pub struct myc::MycStatusSnapshot", + "pub struct myc::MycStatusCommonV1", + "pub struct myc::MycStatusObservationV1", + "pub fn myc::myc_status_cache", "pub struct myc::MycAdminRequestDocument", "pub struct myc::MycAdminResponseDocument", "pub enum myc::MycAdminMethod", @@ -224,6 +235,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "provider_verification", "runtime_context", "runtime_foundation", + "status_v1", "state_catalog", "state_completion", "state_connection", @@ -271,6 +283,62 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { } #[test] +fn status_cache_is_passive_latest_value_and_dependency_neutral() { + for required in [ + "CachedServiceStatePublisher<MycCachedStatus>", + "pub struct MycStatusPublisher", + "pub struct MycStatusReader", + "pub struct MycStatusSnapshot", + "pub fn myc_status_cache(", + "status.to_bounded_json()", + "self.inner.publish(next)", + "self.inner.snapshot()", + "connection_counts: MycConnectionCountsV1", + "oldest_pending_at_utc: Option<MycStatusUnixSeconds>", + "MYC_STATUS_REASON_CODE_COUNT: usize = 12", + ] { + assert!(STATUS_V1.contains(required), "missing `{required}`"); + } + for forbidden in [ + "sqlx::", + "std::fs::", + "tokio::spawn", + "spawn_blocking", + "std::time::SystemTime", + "std::env::", + "reqwest::", + "url::Url", + "provider.execute", + "relay.connect", + "dns", + ] { + assert!(!STATUS_V1.contains(forbidden), "found `{forbidden}`"); + } + assert!(PUBLIC_API.contains("impl core::clone::Clone for myc::MycStatusReader")); + assert!(!PUBLIC_API.contains("impl core::clone::Clone for myc::MycStatusPublisher")); + assert!(!PUBLIC_API.contains("impl core::clone::Clone for myc::MycStatusSnapshot")); + for required in [ + "identity_unavailable", + "database_schema_mismatch", + "database_read_only", + "database_low_disk", + "required_relay_unavailable", + "subscriber_not_active", + "signer_provider_unavailable", + "outbox_invariant_failed", + "publication_backlog_exceeded", + "admin_listener_failed", + "operations_listener_failed", + "shutdown_in_progress", + ] { + assert!( + STATUS_CONTRACT.contains(required), + "status contract is missing `{required}`" + ); + } +} + +#[test] fn doctor_boundary_is_closed_bounded_and_dependency_neutral() { for required in [ "MYC_DOCTOR_CHECK_COUNT: usize = 13", @@ -642,7 +710,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, 28); + assert_eq!(public_error_count, 29); assert!(PUBLIC_API.contains("pub struct myc::MycDoctorError")); assert!(!PUBLIC_API.contains("pub struct myc::MycRuntimeFoundation {")); assert!(!PUBLIC_API.contains("pub struct myc::MycStateHost {")); diff --git a/tests/services_hardening_status.rs b/tests/services_hardening_status.rs @@ -0,0 +1,457 @@ +#![forbid(unsafe_code)] + +use std::error::Error; + +use myc::{ + InstanceId, MYC_DETAILED_STATUS_MAX_UTF8_BYTES, MYC_STATUS_CACHE_CONTRACT_VERSION, + MycConnectionCountsV1, MycIdentityHealthV1, MycIntegrityStateV1, MycOutboxStatusV1, + MycPersistenceHealthV1, MycPersistenceStatusV1, MycProviderStatusV1, MycRelayTransportStatusV1, + MycServicePhase, MycStatusBuildInfoV1, MycStatusBuildMode, MycStatusCommonV1, + MycStatusConfigurationIdentityV1, MycStatusConfigurationSource, MycStatusErrorKind, + MycStatusObservationV1, MycStatusReasonCode, MycStatusReasonCodes, MycStatusUnixSeconds, + MycTransportHealthV1, myc_status_cache, +}; + +const CONTRACT: &str = include_str!("../contracts/services_hardening/status_cache.v1.json"); +const SERVICE_REVISION: &str = "0123456789abcdef0123456789abcdef01234567"; +const LIB_REVISION: &str = "89abcdef0123456789abcdef0123456789abcdef"; + +fn reasons(values: &[&str]) -> MycStatusReasonCodes { + MycStatusReasonCodes::new( + values + .iter() + .map(|value| MycStatusReasonCode::new(value).expect("reason code")), + ) + .expect("reason codes") +} + +fn build_info() -> MycStatusBuildInfoV1 { + MycStatusBuildInfoV1::new( + MycStatusBuildMode::Release, + Some("0.1.0"), + Some(SERVICE_REVISION), + Some(LIB_REVISION), + Some("1.97.1"), + Some("x86_64-unknown-linux-gnu"), + Some("service-host"), + ) + .expect("build info") +} + +fn identity(configured: bool, available: bool, reason: &[&str]) -> MycIdentityHealthV1 { + MycIdentityHealthV1::new(configured, available, reasons(reason)).expect("identity health") +} + +fn observation( + phase: MycServicePhase, + ready: bool, + uptime_ms: u64, + pending_connections: u64, +) -> MycStatusObservationV1 { + let lifecycle_reasons = if phase == MycServicePhase::Degraded { + reasons(&["required_relay_unavailable"]) + } else { + MycStatusReasonCodes::empty() + }; + let configuration = MycStatusConfigurationIdentityV1::new( + "a".repeat(64), + MycStatusConfigurationSource::ExplicitConfig, + ) + .expect("configuration"); + let persistence = MycPersistenceStatusV1::new( + MycPersistenceHealthV1::Ready, + 9, + 42, + MycIntegrityStateV1::Verified, + MycStatusReasonCodes::empty(), + ) + .expect("persistence"); + let provider = MycProviderStatusV1::new( + identity(true, true, &[]), + identity(true, true, &[]), + identity(false, false, &[]), + MycStatusReasonCodes::empty(), + ) + .expect("provider"); + let transport = MycRelayTransportStatusV1::new( + if phase == MycServicePhase::Degraded { + MycTransportHealthV1::Degraded + } else { + MycTransportHealthV1::Ready + }, + phase != MycServicePhase::Degraded, + 2, + if phase == MycServicePhase::Degraded { + reasons(&["required_relay_unavailable"]) + } else { + MycStatusReasonCodes::empty() + }, + ) + .expect("transport"); + MycStatusObservationV1::new( + MycStatusCommonV1::new( + phase, + ready, + lifecycle_reasons, + uptime_ms, + build_info(), + configuration, + persistence, + ) + .expect("common status"), + provider, + transport, + MycConnectionCountsV1::new(pending_connections, 3, 1, 2), + MycOutboxStatusV1::new( + 4, + 1, + Some(MycStatusUnixSeconds::new(1_723_456_789).expect("outbox time")), + ), + ) +} + +#[test] +fn machine_contract_and_canonical_detailed_status_are_exact() { + let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("status contract"); + assert_eq!(contract["schema"], "radroots.myc.status-cache.v1"); + assert_eq!( + contract["contract_version"], + MYC_STATUS_CACHE_CONTRACT_VERSION + ); + assert_eq!(contract["step"], 154); + assert_eq!( + contract["publication"]["capacity"], + "one_latest_immutable_arc" + ); + assert_eq!(contract["read"]["fresh_probe"], false); + assert_eq!( + contract["detailed_status"]["maximum_utf8_bytes"], + MYC_DETAILED_STATUS_MAX_UTF8_BYTES + ); + assert_eq!( + contract["detailed_status"]["reason_codes"] + .as_array() + .expect("reason inventory") + .iter() + .map(|value| value.as_str().expect("reason")) + .collect::<Vec<_>>(), + [ + MycStatusReasonCode::IdentityUnavailable, + MycStatusReasonCode::DatabaseSchemaMismatch, + MycStatusReasonCode::DatabaseReadOnly, + MycStatusReasonCode::DatabaseLowDisk, + MycStatusReasonCode::RequiredRelayUnavailable, + MycStatusReasonCode::SubscriberNotActive, + MycStatusReasonCode::SignerProviderUnavailable, + MycStatusReasonCode::OutboxInvariantFailed, + MycStatusReasonCode::PublicationBacklogExceeded, + MycStatusReasonCode::AdminListenerFailed, + MycStatusReasonCode::OperationsListenerFailed, + MycStatusReasonCode::ShutdownInProgress, + ] + .map(MycStatusReasonCode::as_str) + ); + + let (_publisher, reader) = myc_status_cache( + InstanceId::new("primary").expect("instance"), + observation(MycServicePhase::Ready, true, 120_000, 5), + ) + .expect("status cache"); + let snapshot = reader.snapshot(); + let wire = std::str::from_utf8(snapshot.detailed_status_json()).expect("status UTF-8"); + assert_eq!( + wire, + r#"{"contract_version":1,"service":"myc","instance":"primary","phase":"ready","ready":true,"uptime_millis":120000,"reason_codes":[],"build_info":{"version":"0.1.0","revision":"0123456789abcdef0123456789abcdef01234567","toolchain":"1.97.1","contract_versions":{"config":1,"state":9,"admin":1,"status":1,"provider":1}},"configuration":{"schema":"radroots.myc.config","schema_version":1,"digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source":"explicit_config"},"persistence":{"health":"ready","schema_version":9,"generation":42,"integrity":"verified","reason_codes":[]},"provider":{"health":"ready","transport":{"configured":true,"available":true,"reason_codes":[]},"user":{"configured":true,"available":true,"reason_codes":[]},"discovery":{"configured":false,"available":false,"reason_codes":[]},"reason_codes":[]},"transport":{"health":"ready","required_relays_ready":true,"connected_relay_count":2,"reason_codes":[]},"myc":{"transport":{"configured":true,"available":true,"reason_codes":[]},"user":{"configured":true,"available":true,"reason_codes":[]},"discovery":{"configured":false,"available":false,"reason_codes":[]},"connection_counts":{"pending":5,"active":3,"denied":1,"expired":2},"outbox":{"pending":4,"unknown":1,"oldest_pending_at_utc":1723456789}}}"# + ); + assert!(wire.len() < MYC_DETAILED_STATUS_MAX_UTF8_BYTES); + for forbidden in [ + "secret", + "credential", + "private_key", + "password", + "filesystem_path", + "relay_url", + "raw_error", + ] { + assert!(!wire.contains(forbidden), "wire leaked `{forbidden}`"); + } +} + +#[tokio::test] +async fn latest_publication_is_atomic_passive_and_retains_old_snapshots() { + let (mut publisher, reader) = myc_status_cache( + InstanceId::new("primary").expect("instance"), + observation(MycServicePhase::Starting, false, 0, 9), + ) + .expect("status cache"); + let old = reader.snapshot(); + let old_pointer = old.detailed_status_json().as_ptr(); + for _ in 0..1_000 { + let same = reader.snapshot(); + assert_eq!(same.detailed_status_json().as_ptr(), old_pointer); + assert_eq!(same.phase(), MycServicePhase::Starting); + } + + let mut changed = publisher.subscribe(); + publisher + .publish(observation(MycServicePhase::Ready, true, 10, 2)) + .expect("ready publication"); + publisher + .publish(observation(MycServicePhase::Degraded, true, 20, 1)) + .expect("degraded publication"); + + let latest = changed.changed().await.expect("latest publication"); + assert_eq!(latest.phase(), MycServicePhase::Degraded); + assert!(latest.is_ready()); + assert!( + std::str::from_utf8(latest.detailed_status_json()) + .expect("status UTF-8") + .contains("\"uptime_millis\":20") + ); + assert_eq!(old.phase(), MycServicePhase::Starting); + assert!( + std::str::from_utf8(old.detailed_status_json()) + .expect("old UTF-8") + .contains("\"uptime_millis\":0") + ); +} + +#[tokio::test] +async fn illegal_transition_and_publisher_drop_preserve_the_last_valid_value() { + let (mut publisher, reader) = myc_status_cache( + InstanceId::new("primary").expect("instance"), + observation(MycServicePhase::Starting, false, 1, 0), + ) + .expect("status cache"); + let before = reader.snapshot().detailed_status_json().to_vec(); + let error = publisher + .publish(observation(MycServicePhase::Unready, false, 2, 0)) + .expect_err("illegal starting to unready transition"); + assert_eq!(error.kind(), MycStatusErrorKind::InvalidTransition); + assert_eq!(reader.snapshot().detailed_status_json(), before); + assert!(Error::source(&error).is_none()); + + let mut dropped = publisher.subscribe(); + drop(publisher); + let dropped_error = dropped.changed().await.expect_err("publisher dropped"); + assert_eq!(dropped_error.kind(), MycStatusErrorKind::PublisherDropped); + assert_eq!(reader.snapshot().detailed_status_json(), before); +} + +#[test] +fn closed_role_counts_time_and_safe_debug_bound_the_status_surface() { + let counts = MycConnectionCountsV1::new(u64::MAX, 2, 3, 4); + assert_eq!(counts.pending(), u64::MAX); + assert_eq!(counts.active(), 2); + assert_eq!(counts.denied(), 3); + assert_eq!(counts.expired(), 4); + assert_eq!(MycStatusUnixSeconds::new(0).expect("zero").get(), 0); + assert_eq!( + MycStatusUnixSeconds::new(i64::MAX as u64) + .expect("maximum") + .get(), + i64::MAX as u64 + ); + assert_eq!( + MycStatusUnixSeconds::new(i64::MAX as u64 + 1) + .expect_err("over maximum") + .kind(), + MycStatusErrorKind::InvalidTime + ); + + let (publisher, reader) = myc_status_cache( + InstanceId::new("primary").expect("instance"), + observation(MycServicePhase::Ready, true, 5, 0), + ) + .expect("status cache"); + let rendered = format!("{publisher:?} {reader:?} {:?}", reader.snapshot()); + for forbidden in [ + SERVICE_REVISION, + LIB_REVISION, + "radroots.myc.config", + "aaaaaaaaaaaaaaaa", + "oldest_pending_at_utc", + ] { + assert!(!rendered.contains(forbidden)); + } +} + +#[test] +fn model_boundaries_fail_closed_before_publication() { + assert_eq!( + MycStatusReasonCode::new("") + .expect_err("empty reason") + .kind(), + MycStatusErrorKind::InvalidReasonCode + ); + assert_eq!( + MycStatusReasonCode::new("secret_canary_value") + .expect_err("unknown reason") + .kind(), + MycStatusErrorKind::InvalidReasonCode + ); + let maximum = [ + MycStatusReasonCode::IdentityUnavailable, + MycStatusReasonCode::DatabaseSchemaMismatch, + MycStatusReasonCode::DatabaseReadOnly, + MycStatusReasonCode::DatabaseLowDisk, + MycStatusReasonCode::RequiredRelayUnavailable, + MycStatusReasonCode::SubscriberNotActive, + MycStatusReasonCode::SignerProviderUnavailable, + MycStatusReasonCode::OutboxInvariantFailed, + MycStatusReasonCode::PublicationBacklogExceeded, + MycStatusReasonCode::AdminListenerFailed, + MycStatusReasonCode::OperationsListenerFailed, + MycStatusReasonCode::ShutdownInProgress, + ]; + assert_eq!( + MycStatusReasonCodes::new(maximum) + .expect("maximum reasons") + .as_slice() + .len(), + 12 + ); + let mut infinite = std::iter::repeat(MycStatusReasonCode::IdentityUnavailable); + assert_eq!( + MycStatusReasonCodes::new(&mut infinite) + .expect_err("bounded infinite iterator") + .kind(), + MycStatusErrorKind::TooManyReasonCodes + ); + assert_eq!( + infinite.next().expect("iterator retained"), + MycStatusReasonCode::IdentityUnavailable + ); + + assert_eq!( + MycStatusBuildInfoV1::new( + MycStatusBuildMode::Release, + Some("0.1.0"), + None, + Some(LIB_REVISION), + Some("1.97.1"), + Some("x86_64-unknown-linux-gnu"), + Some("service-host"), + ) + .expect_err("release revision required") + .kind(), + MycStatusErrorKind::InvalidBuildInfo + ); + assert_eq!( + MycStatusConfigurationIdentityV1::new( + "A".repeat(64), + MycStatusConfigurationSource::ExplicitConfig, + ) + .expect_err("lowercase digest required") + .kind(), + MycStatusErrorKind::InvalidConfiguration + ); + assert_eq!( + MycPersistenceStatusV1::new( + MycPersistenceHealthV1::Ready, + 0, + 0, + MycIntegrityStateV1::Verified, + MycStatusReasonCodes::empty(), + ) + .expect_err("positive schema required") + .kind(), + MycStatusErrorKind::InvalidPersistence + ); + + let error = MycStatusCommonV1::new( + MycServicePhase::Ready, + false, + MycStatusReasonCodes::empty(), + 0, + build_info(), + MycStatusConfigurationIdentityV1::new( + "a".repeat(64), + MycStatusConfigurationSource::ExplicitConfig, + ) + .expect("configuration"), + MycPersistenceStatusV1::new( + MycPersistenceHealthV1::Ready, + 9, + 0, + MycIntegrityStateV1::Verified, + MycStatusReasonCodes::empty(), + ) + .expect("persistence"), + ) + .expect_err("ready phase requires readiness"); + assert_eq!(error.kind(), MycStatusErrorKind::InvalidLifecycle); +} + +#[test] +fn provider_aggregate_and_optional_oldest_time_are_deterministic() { + let ready = MycProviderStatusV1::new( + identity(true, true, &[]), + identity(true, true, &[]), + identity(false, false, &[]), + MycStatusReasonCodes::empty(), + ) + .expect("ready provider"); + assert_eq!(ready.health(), myc::MycProviderHealthV1::Ready); + + let degraded = MycProviderStatusV1::new( + identity(true, true, &[]), + identity(true, true, &[]), + identity(true, false, &["identity_unavailable"]), + reasons(&["identity_unavailable"]), + ) + .expect("degraded provider"); + assert_eq!(degraded.health(), myc::MycProviderHealthV1::Degraded); + + let unavailable = MycProviderStatusV1::new( + identity(true, false, &["identity_unavailable"]), + identity(true, true, &[]), + identity(false, false, &[]), + reasons(&["identity_unavailable"]), + ) + .expect("unavailable provider"); + assert_eq!(unavailable.health(), myc::MycProviderHealthV1::Unavailable); + + let (_publisher, reader) = myc_status_cache( + InstanceId::new("primary").expect("instance"), + MycStatusObservationV1::new( + MycStatusCommonV1::new( + MycServicePhase::Ready, + true, + MycStatusReasonCodes::empty(), + 1, + build_info(), + MycStatusConfigurationIdentityV1::new( + "a".repeat(64), + MycStatusConfigurationSource::ExplicitConfig, + ) + .expect("configuration"), + MycPersistenceStatusV1::new( + MycPersistenceHealthV1::Ready, + 9, + 0, + MycIntegrityStateV1::Verified, + MycStatusReasonCodes::empty(), + ) + .expect("persistence"), + ) + .expect("common status"), + ready, + MycRelayTransportStatusV1::new( + MycTransportHealthV1::Ready, + true, + 0, + MycStatusReasonCodes::empty(), + ) + .expect("transport"), + MycConnectionCountsV1::default(), + MycOutboxStatusV1::new(0, 0, None), + ), + ) + .expect("cache"); + let wire = std::str::from_utf8(reader.snapshot().detailed_status_json()) + .expect("status UTF-8") + .to_owned(); + assert!(wire.contains("\"outbox\":{\"pending\":0,\"unknown\":0}")); + assert!(!wire.contains("oldest_pending_at_utc")); +}