commit 4a73308a1cfd57fbe37dc89068565a71721d9a48
parent 343a9f76c1298f9a8d1e2b37cc1f3c1bff54d3b2
Author: triesap <tyson@radroots.org>
Date: Sat, 22 Aug 2026 05:42:23 +0000
doctor: freeze bounded active check contract
Diffstat:
9 files changed, 1133 insertions(+), 27 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -114,6 +114,14 @@
Rekey, replace, and every other live mutation have no direct-state fallback.
Do not reparse process arguments or let a live CLI plan obtain SQLite,
provider, relay, task, signal, or runtime authority.
+- Step 153 freezes one ordered 13-check doctor engine. Check adapters retain
+ their operation-specific authority and may return only closed observations;
+ the engine owns exact deadlines, required/optional aggregation, fixed safe
+ summaries/remediation codes, bounded canonical JSON, and exit 6 for required
+ failure or timeout. Do not admit raw errors, paths, URLs, keys, credentials,
+ 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.
- 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
@@ -60,6 +60,17 @@ read-only status, backup, and public-identity operations may fall back when a
later executor proves the daemon writer lock is free. Rekey and replace never
fall back to direct state access, and no live plan carries SQLite authority.
+The active doctor boundary executes the exact 13-check operator inventory in
+contract order under fixed per-check deadlines. Check implementations retain
+their filesystem, SQLite, provider, bind, network, relay, and clock authority;
+only closed pass/fail/skipped observations cross into the report builder. The
+builder enforces required-check semantics, returns exit 6 for required failure
+or timeout, and emits at most 8,192 bytes of compact canonical JSON using only
+fixed summaries and remediation codes. Raw errors, paths, relay URLs,
+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.
+
`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
@@ -304,6 +304,50 @@ pub myc::MycDiscoveryStateErrorKind::InvalidProjection
pub myc::MycDiscoveryStateErrorKind::TooLarge
impl myc::MycDiscoveryStateErrorKind
pub const fn myc::MycDiscoveryStateErrorKind::code(self) -> &'static str
+pub enum myc::MycDoctorAggregateStatus
+pub myc::MycDoctorAggregateStatus::Degraded
+pub myc::MycDoctorAggregateStatus::Fail
+pub myc::MycDoctorAggregateStatus::Pass
+pub enum myc::MycDoctorCheckId
+pub myc::MycDoctorCheckId::AdminBindPolicy
+pub myc::MycDoctorCheckId::ClockSkew
+pub myc::MycDoctorCheckId::IdentityBinding
+pub myc::MycDoctorCheckId::NetworkPolicy
+pub myc::MycDoctorCheckId::OperationsBindPolicy
+pub myc::MycDoctorCheckId::OutboxInvariants
+pub myc::MycDoctorCheckId::PathsPermissions
+pub myc::MycDoctorCheckId::RequiredRelays
+pub myc::MycDoctorCheckId::SignerProvider
+pub myc::MycDoctorCheckId::SqliteFreeSpace
+pub myc::MycDoctorCheckId::SqliteIntegrity
+pub myc::MycDoctorCheckId::SqliteSchema
+pub myc::MycDoctorCheckId::WriterLock
+pub enum myc::MycDoctorCheckStatus
+pub myc::MycDoctorCheckStatus::Fail
+pub myc::MycDoctorCheckStatus::Pass
+pub myc::MycDoctorCheckStatus::Skipped
+pub myc::MycDoctorCheckStatus::Timeout
+pub enum myc::MycDoctorErrorKind
+pub myc::MycDoctorErrorKind::Encoding
+pub myc::MycDoctorErrorKind::OutputTooLarge
+pub enum myc::MycDoctorObservation
+pub myc::MycDoctorObservation::Fail
+pub myc::MycDoctorObservation::Pass
+pub myc::MycDoctorObservation::Skipped
+pub enum myc::MycDoctorRemediationCode
+pub myc::MycDoctorRemediationCode::CorrectAdminBindPolicy
+pub myc::MycDoctorRemediationCode::CorrectClock
+pub myc::MycDoctorRemediationCode::CorrectNetworkPolicy
+pub myc::MycDoctorRemediationCode::CorrectOperationsBindPolicy
+pub myc::MycDoctorRemediationCode::CorrectPathPolicy
+pub myc::MycDoctorRemediationCode::FreeStateDiskSpace
+pub myc::MycDoctorRemediationCode::ReleaseWriterLock
+pub myc::MycDoctorRemediationCode::RepairOutboxState
+pub myc::MycDoctorRemediationCode::RepairSchema
+pub myc::MycDoctorRemediationCode::RepairSignerProvider
+pub myc::MycDoctorRemediationCode::RestoreIdentityBinding
+pub myc::MycDoctorRemediationCode::RestoreRequiredRelays
+pub myc::MycDoctorRemediationCode::RestoreVerifiedState
pub enum myc::MycEncryptedIdentityEnvelopeErrorKind
pub myc::MycEncryptedIdentityEnvelopeErrorKind::AlreadyExists
pub myc::MycEncryptedIdentityEnvelopeErrorKind::IdentityMismatch
@@ -1008,6 +1052,36 @@ impl core::fmt::Debug for myc::MycDiscoveryStateError
pub fn myc::MycDiscoveryStateError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::fmt::Display for myc::MycDiscoveryStateError
pub fn myc::MycDiscoveryStateError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycDoctorCheckDefinition
+impl myc::MycDoctorCheckDefinition
+pub const fn myc::MycDoctorCheckDefinition::deadline_ms(self) -> u64
+pub const fn myc::MycDoctorCheckDefinition::id(self) -> myc::MycDoctorCheckId
+pub const fn myc::MycDoctorCheckDefinition::remediation_code(self) -> myc::MycDoctorRemediationCode
+pub const fn myc::MycDoctorCheckDefinition::required(self) -> bool
+pub const fn myc::MycDoctorCheckDefinition::scope(self) -> &'static [&'static str]
+pub struct myc::MycDoctorCheckResult
+impl myc::MycDoctorCheckResult
+pub const fn myc::MycDoctorCheckResult::definition(self) -> myc::MycDoctorCheckDefinition
+pub const fn myc::MycDoctorCheckResult::status(self) -> myc::MycDoctorCheckStatus
+pub const fn myc::MycDoctorCheckResult::summary(self) -> &'static str
+pub struct myc::MycDoctorError
+impl myc::MycDoctorError
+pub const fn myc::MycDoctorError::kind(self) -> myc::MycDoctorErrorKind
+impl core::error::Error for myc::MycDoctorError
+impl core::fmt::Debug for myc::MycDoctorError
+pub fn myc::MycDoctorError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+impl core::fmt::Display for myc::MycDoctorError
+pub fn myc::MycDoctorError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct myc::MycDoctorReport
+impl myc::MycDoctorReport
+pub fn myc::MycDoctorReport::canonical_json(&self) -> &[u8]
+pub fn myc::MycDoctorReport::checks(&self) -> &[myc::MycDoctorCheckResult]
+pub const fn myc::MycDoctorReport::exit_code(&self) -> u8
+pub const fn myc::MycDoctorReport::instance(&self) -> &radroots_runtime_paths::identifier::InstanceId
+pub const fn myc::MycDoctorReport::service(&self) -> &'static str
+pub const fn myc::MycDoctorReport::status(&self) -> myc::MycDoctorAggregateStatus
+impl core::fmt::Debug for myc::MycDoctorReport
+pub fn myc::MycDoctorReport::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct myc::MycEffectiveConfigV1
impl myc::MycEffectiveConfigV1
pub fn myc::MycEffectiveConfigV1::canonical_json(&self) -> &str
@@ -1653,6 +1727,10 @@ 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_DISCOVERY_DOCUMENT_MAX_BYTES: usize
+pub const myc::MYC_DOCTOR_CHECK_COUNT: usize
+pub const myc::MYC_DOCTOR_CONTRACT_VERSION: u32
+pub const myc::MYC_DOCTOR_REPORT_MAX_UTF8_BYTES: usize
+pub const myc::MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES: usize
pub const myc::MYC_ENCRYPTED_IDENTITY_BACKUP_INCLUDED: bool
pub const myc::MYC_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION: u32
pub const myc::MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES: usize
@@ -1715,12 +1793,15 @@ 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
pub fn myc::MycAdminHandler::handle<'a>(&'a self, myc::MycAdminRequestDocument) -> myc::MycAdminFuture<'a>
+pub trait myc::MycDoctorProbe: core::marker::Send + core::marker::Sync
+pub fn myc::MycDoctorProbe::probe(&self, myc::MycDoctorCheckDefinition) -> myc::MycDoctorFuture<'_>
pub fn myc::admit_myc_nip46_event(myc::MycNip46AdmissionLimits, &[u8]) -> core::result::Result<myc::MycBoundedNip46Event, myc::MycNip46AdmissionError>
pub fn myc::admit_myc_nip46_request(myc::MycNip46AdmissionLimits, &[u8]) -> core::result::Result<myc::MycBoundedNip46Request, myc::MycNip46AdmissionError>
pub fn myc::bind_myc_nip46_replay(myc::MycVerifiedNip46Event, myc::MycVerifiedNip46Request) -> core::result::Result<myc::MycReplayBoundNip46Request, myc::MycSignerRequestError>
pub fn myc::build_myc_admin_router<H>(alloc::sync::Arc<H>) -> core::result::Result<myc::MycAdminRouter, myc::MycAdminRouterError> where H: myc::MycAdminHandler
pub async fn myc::finalize_myc_state_restore(myc::MycStagedStateRestore) -> core::result::Result<(), myc::MycStateMaintenanceError>
pub async fn myc::initialize_myc_state(&myc::MycRuntimeContext, &myc::MycStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<(), myc::MycStateHostError>
+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::open_myc_encrypted_identity(&myc::MycProviderBinding, &myc::MycWrappingCredential) -> core::result::Result<myc::MycDecryptedIdentity, myc::MycEncryptedIdentityEnvelopeError>
@@ -1736,9 +1817,11 @@ pub fn myc::prepare_myc_nip46_work(myc::MycPreparedNip46Request, myc::MycSignerR
pub fn myc::provision_myc_encrypted_identity(&myc::MycProviderBinding, &myc::MycWrappingCredential, myc::MycEncryptedIdentityProvisioningMaterial) -> core::result::Result<myc::MycDecryptedIdentity, myc::MycEncryptedIdentityEnvelopeError>
pub fn myc::resolve_myc_runtime_context(&radroots_runtime_paths::roots::RadrootsPathResolver, &myc::MycCliInvocationV1) -> core::result::Result<myc::MycRuntimeContext, myc::MycRuntimeContextError>
pub fn myc::resolve_myc_wrapping_credential(&myc::MycRuntimeContext, &myc::MycProviderBinding) -> core::result::Result<myc::MycWrappingCredential, myc::MycCredentialResolutionError>
+pub async fn myc::run_myc_doctor(&myc::MycRuntimeContext, &impl myc::MycDoctorProbe + ?core::marker::Sized) -> core::result::Result<myc::MycDoctorReport, myc::MycDoctorError>
pub async fn myc::stage_myc_state_restore(&myc::MycRuntimeContext, &myc::MycStateMetadata, myc::MycVerifiedStateBackup) -> core::result::Result<myc::MycStagedStateRestore, myc::MycStateMaintenanceError>
pub fn myc::validate_myc_state_catalogs(&radroots_service_sqlite::migration::MigrationCatalog, &radroots_service_sqlite::integrity::catalog::SchemaCatalog) -> core::result::Result<(), myc::MycStateCatalogError>
pub fn myc::verify_myc_nip46_event(myc::MycBoundedNip46Event, &myc::MycProviderBinding, myc::MycNip46ObservedAtUnixSeconds, myc::MycNip46AuthoredTimePolicy) -> core::result::Result<myc::MycVerifiedNip46Event, myc::MycNip46VerificationError>
pub fn myc::verify_myc_nip46_request(myc::MycBoundedNip46Request) -> core::result::Result<myc::MycVerifiedNip46Request, myc::MycNip46VerificationError>
pub fn myc::verify_myc_state_backup(&[u8], radroots_service_sqlite::backup::manifest::BackupManifestSha256, &std::path::Path, &myc::MycStateMetadata, core::num::nonzero::NonZeroU64) -> core::result::Result<myc::MycVerifiedStateBackup, myc::MycStateMaintenanceError>
pub type myc::MycAdminFuture<'a> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = core::result::Result<myc::MycAdminResponseDocument, myc::MycAdminHandlerError>> + core::marker::Send + 'a)>>
+pub type myc::MycDoctorFuture<'a> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = myc::MycDoctorObservation> + core::marker::Send + 'a)>>
diff --git a/contracts/services_hardening/operator_contract.v1.json b/contracts/services_hardening/operator_contract.v1.json
@@ -272,20 +272,31 @@
"doctor": {
"shared_schema": "radroots.service.doctor.v1",
"contract_version": 1,
+ "execution": "ordered",
+ "pass_requires_all_scope": true,
+ "probe_future_cancellation": "drop_stops_or_owns_cleanup",
+ "detached_probe_work": false,
+ "statuses": ["pass", "fail", "timeout", "skipped"],
+ "aggregate_statuses": ["pass", "degraded", "fail"],
+ "required_skipped": "forbidden",
+ "summary_max_utf8_bytes": 256,
+ "report_max_utf8_bytes": 8192,
+ "raw_error_or_path_allowed": false,
+ "required_fail_or_timeout_exit": 6,
"checks": [
- { "id": "paths_permissions", "required": true },
- { "id": "writer_lock", "required": true },
- { "id": "sqlite_schema", "required": true },
- { "id": "sqlite_integrity", "required": true },
- { "id": "sqlite_free_space", "required": true },
- { "id": "identity_binding", "required": true },
- { "id": "signer_provider", "required": true },
- { "id": "admin_bind_policy", "required": true },
- { "id": "operations_bind_policy", "required": true },
- { "id": "network_policy", "required": true },
- { "id": "required_relays", "required": true },
- { "id": "outbox_invariants", "required": true },
- { "id": "clock_skew", "required": false }
+ { "id": "paths_permissions", "required": true, "deadline_ms": 2000, "remediation_code": "correct_path_policy", "scope": ["resolved_path_containment", "owner", "type", "mode"] },
+ { "id": "writer_lock", "required": true, "deadline_ms": 2000, "remediation_code": "release_writer_lock", "scope": ["state_directory_binding", "writer_lock_state"] },
+ { "id": "sqlite_schema", "required": true, "deadline_ms": 5000, "remediation_code": "repair_schema", "scope": ["metadata_identity", "migration_history", "schema_catalog"] },
+ { "id": "sqlite_integrity", "required": true, "deadline_ms": 15000, "remediation_code": "restore_verified_state", "scope": ["integrity_check", "foreign_key_check"] },
+ { "id": "sqlite_free_space", "required": true, "deadline_ms": 2000, "remediation_code": "free_state_disk_space", "scope": ["state_filesystem_capacity", "minimum_free_bytes"] },
+ { "id": "identity_binding", "required": true, "deadline_ms": 2000, "remediation_code": "restore_identity_binding", "scope": ["envelope_contract", "credential_reference", "public_identity"] },
+ { "id": "signer_provider", "required": true, "deadline_ms": 15000, "remediation_code": "repair_signer_provider", "scope": ["capability", "contract_version", "identity", "correlation", "deadline"] },
+ { "id": "admin_bind_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_admin_bind_policy", "scope": ["unix_socket_path", "socket_mode", "peer_authorization"] },
+ { "id": "operations_bind_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_operations_bind_policy", "scope": ["enabled_posture", "listen_address", "bind_policy"] },
+ { "id": "network_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_network_policy", "scope": ["dns_policy", "tls_policy", "relay_url_policy"] },
+ { "id": "required_relays", "required": true, "deadline_ms": 15000, "remediation_code": "restore_required_relays", "scope": ["required_read_relays", "required_write_relays", "connect_deadline"] },
+ { "id": "outbox_invariants", "required": true, "deadline_ms": 5000, "remediation_code": "repair_outbox_state", "scope": ["claim_invariants", "retry_state", "exact_response_bytes"] },
+ { "id": "clock_skew", "required": false, "deadline_ms": 5000, "remediation_code": "correct_clock", "scope": ["wall_clock_skew"] }
]
},
"exit_codes": [
diff --git a/src/doctor_v1.rs b/src/doctor_v1.rs
@@ -0,0 +1,626 @@
+//! Bounded active-doctor orchestration and safe structured evidence.
+
+use core::{fmt, future::Future, pin::Pin, time::Duration};
+use std::error::Error;
+
+use radroots_runtime_paths::InstanceId;
+use serde::Serialize;
+
+use crate::MycRuntimeContext;
+
+/// Myc doctor wire-contract version.
+pub const MYC_DOCTOR_CONTRACT_VERSION: u32 = 1;
+/// Exact number of governed Myc doctor checks.
+pub const MYC_DOCTOR_CHECK_COUNT: usize = 13;
+/// Maximum encoded size of one safe summary.
+pub const MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES: usize = 256;
+/// Maximum encoded size of the complete canonical doctor report.
+pub const MYC_DOCTOR_REPORT_MAX_UTF8_BYTES: usize = 8_192;
+
+const MYC_SERVICE: &str = "myc";
+const DOCTOR_FAILURE_EXIT_CODE: u8 = 6;
+const _: () = {
+ assert!("check passed".len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
+ assert!("check failed".len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
+ assert!("check timed out".len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
+ assert!("optional check skipped".len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
+};
+
+/// The closed Myc doctor inventory.
+#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
+pub enum MycDoctorCheckId {
+ PathsPermissions,
+ WriterLock,
+ SqliteSchema,
+ SqliteIntegrity,
+ SqliteFreeSpace,
+ IdentityBinding,
+ SignerProvider,
+ AdminBindPolicy,
+ OperationsBindPolicy,
+ NetworkPolicy,
+ RequiredRelays,
+ OutboxInvariants,
+ ClockSkew,
+}
+
+impl MycDoctorCheckId {
+ const fn as_str(self) -> &'static str {
+ match self {
+ Self::PathsPermissions => "paths_permissions",
+ Self::WriterLock => "writer_lock",
+ Self::SqliteSchema => "sqlite_schema",
+ Self::SqliteIntegrity => "sqlite_integrity",
+ Self::SqliteFreeSpace => "sqlite_free_space",
+ Self::IdentityBinding => "identity_binding",
+ Self::SignerProvider => "signer_provider",
+ Self::AdminBindPolicy => "admin_bind_policy",
+ Self::OperationsBindPolicy => "operations_bind_policy",
+ Self::NetworkPolicy => "network_policy",
+ Self::RequiredRelays => "required_relays",
+ Self::OutboxInvariants => "outbox_invariants",
+ Self::ClockSkew => "clock_skew",
+ }
+ }
+}
+
+/// Stable operator action associated with one doctor check.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MycDoctorRemediationCode {
+ CorrectPathPolicy,
+ ReleaseWriterLock,
+ RepairSchema,
+ RestoreVerifiedState,
+ FreeStateDiskSpace,
+ RestoreIdentityBinding,
+ RepairSignerProvider,
+ CorrectAdminBindPolicy,
+ CorrectOperationsBindPolicy,
+ CorrectNetworkPolicy,
+ RestoreRequiredRelays,
+ RepairOutboxState,
+ CorrectClock,
+}
+
+impl MycDoctorRemediationCode {
+ const fn as_str(self) -> &'static str {
+ match self {
+ Self::CorrectPathPolicy => "correct_path_policy",
+ Self::ReleaseWriterLock => "release_writer_lock",
+ Self::RepairSchema => "repair_schema",
+ Self::RestoreVerifiedState => "restore_verified_state",
+ Self::FreeStateDiskSpace => "free_state_disk_space",
+ Self::RestoreIdentityBinding => "restore_identity_binding",
+ Self::RepairSignerProvider => "repair_signer_provider",
+ Self::CorrectAdminBindPolicy => "correct_admin_bind_policy",
+ Self::CorrectOperationsBindPolicy => "correct_operations_bind_policy",
+ Self::CorrectNetworkPolicy => "correct_network_policy",
+ Self::RestoreRequiredRelays => "restore_required_relays",
+ Self::RepairOutboxState => "repair_outbox_state",
+ Self::CorrectClock => "correct_clock",
+ }
+ }
+}
+
+/// Immutable authority for one check's requirement, deadline, and remediation.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct MycDoctorCheckDefinition {
+ id: MycDoctorCheckId,
+ required: bool,
+ deadline_ms: u64,
+ remediation_code: MycDoctorRemediationCode,
+ scope: &'static [&'static str],
+}
+
+impl MycDoctorCheckDefinition {
+ const fn new(
+ id: MycDoctorCheckId,
+ required: bool,
+ deadline_ms: u64,
+ remediation_code: MycDoctorRemediationCode,
+ scope: &'static [&'static str],
+ ) -> Self {
+ Self {
+ id,
+ required,
+ deadline_ms,
+ remediation_code,
+ scope,
+ }
+ }
+
+ /// Returns the governed check identifier.
+ #[must_use]
+ pub const fn id(self) -> MycDoctorCheckId {
+ self.id
+ }
+
+ /// Returns whether a non-pass result fails the doctor command.
+ #[must_use]
+ pub const fn required(self) -> bool {
+ self.required
+ }
+
+ /// Returns the exact per-check deadline in milliseconds.
+ #[must_use]
+ pub const fn deadline_ms(self) -> u64 {
+ self.deadline_ms
+ }
+
+ /// Returns the fixed, safe operator remediation classification.
+ #[must_use]
+ pub const fn remediation_code(self) -> MycDoctorRemediationCode {
+ self.remediation_code
+ }
+
+ /// Returns the exact safe evidence facets owned by this check.
+ #[must_use]
+ pub const fn scope(self) -> &'static [&'static str] {
+ self.scope
+ }
+}
+
+const CHECK_DEFINITIONS: [MycDoctorCheckDefinition; MYC_DOCTOR_CHECK_COUNT] = [
+ MycDoctorCheckDefinition::new(
+ MycDoctorCheckId::PathsPermissions,
+ true,
+ 2_000,
+ MycDoctorRemediationCode::CorrectPathPolicy,
+ &["resolved_path_containment", "owner", "type", "mode"],
+ ),
+ MycDoctorCheckDefinition::new(
+ MycDoctorCheckId::WriterLock,
+ true,
+ 2_000,
+ MycDoctorRemediationCode::ReleaseWriterLock,
+ &["state_directory_binding", "writer_lock_state"],
+ ),
+ MycDoctorCheckDefinition::new(
+ MycDoctorCheckId::SqliteSchema,
+ true,
+ 5_000,
+ MycDoctorRemediationCode::RepairSchema,
+ &["metadata_identity", "migration_history", "schema_catalog"],
+ ),
+ MycDoctorCheckDefinition::new(
+ MycDoctorCheckId::SqliteIntegrity,
+ true,
+ 15_000,
+ MycDoctorRemediationCode::RestoreVerifiedState,
+ &["integrity_check", "foreign_key_check"],
+ ),
+ MycDoctorCheckDefinition::new(
+ MycDoctorCheckId::SqliteFreeSpace,
+ true,
+ 2_000,
+ MycDoctorRemediationCode::FreeStateDiskSpace,
+ &["state_filesystem_capacity", "minimum_free_bytes"],
+ ),
+ MycDoctorCheckDefinition::new(
+ MycDoctorCheckId::IdentityBinding,
+ true,
+ 2_000,
+ MycDoctorRemediationCode::RestoreIdentityBinding,
+ &[
+ "envelope_contract",
+ "credential_reference",
+ "public_identity",
+ ],
+ ),
+ MycDoctorCheckDefinition::new(
+ MycDoctorCheckId::SignerProvider,
+ true,
+ 15_000,
+ MycDoctorRemediationCode::RepairSignerProvider,
+ &[
+ "capability",
+ "contract_version",
+ "identity",
+ "correlation",
+ "deadline",
+ ],
+ ),
+ MycDoctorCheckDefinition::new(
+ MycDoctorCheckId::AdminBindPolicy,
+ true,
+ 2_000,
+ MycDoctorRemediationCode::CorrectAdminBindPolicy,
+ &["unix_socket_path", "socket_mode", "peer_authorization"],
+ ),
+ MycDoctorCheckDefinition::new(
+ MycDoctorCheckId::OperationsBindPolicy,
+ true,
+ 2_000,
+ MycDoctorRemediationCode::CorrectOperationsBindPolicy,
+ &["enabled_posture", "listen_address", "bind_policy"],
+ ),
+ MycDoctorCheckDefinition::new(
+ MycDoctorCheckId::NetworkPolicy,
+ true,
+ 2_000,
+ MycDoctorRemediationCode::CorrectNetworkPolicy,
+ &["dns_policy", "tls_policy", "relay_url_policy"],
+ ),
+ MycDoctorCheckDefinition::new(
+ MycDoctorCheckId::RequiredRelays,
+ true,
+ 15_000,
+ MycDoctorRemediationCode::RestoreRequiredRelays,
+ &[
+ "required_read_relays",
+ "required_write_relays",
+ "connect_deadline",
+ ],
+ ),
+ MycDoctorCheckDefinition::new(
+ MycDoctorCheckId::OutboxInvariants,
+ true,
+ 5_000,
+ MycDoctorRemediationCode::RepairOutboxState,
+ &["claim_invariants", "retry_state", "exact_response_bytes"],
+ ),
+ MycDoctorCheckDefinition::new(
+ MycDoctorCheckId::ClockSkew,
+ false,
+ 5_000,
+ MycDoctorRemediationCode::CorrectClock,
+ &["wall_clock_skew"],
+ ),
+];
+
+/// Returns the exact ordered doctor inventory.
+#[must_use]
+pub const fn myc_doctor_check_definitions()
+-> &'static [MycDoctorCheckDefinition; MYC_DOCTOR_CHECK_COUNT] {
+ &CHECK_DEFINITIONS
+}
+
+/// A closed result supplied by one bounded check implementation.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MycDoctorObservation {
+ Pass,
+ Fail,
+ Skipped,
+}
+
+/// Future returned by one doctor probe.
+pub type MycDoctorFuture<'a> = Pin<Box<dyn Future<Output = MycDoctorObservation> + Send + 'a>>;
+
+/// Executes each active check without receiving report-construction authority.
+///
+/// `Pass` is permitted only after every facet in [`MycDoctorCheckDefinition::scope`]
+/// is proven. Implementations must be cancellation-safe: the returned future
+/// owns its work, and dropping it at the deadline must not leave detached work
+/// or mutation running.
+pub trait MycDoctorProbe: Send + Sync {
+ /// Runs one exact check. Raw errors, paths, and arbitrary summaries cannot
+ /// cross this boundary.
+ fn probe(&self, definition: MycDoctorCheckDefinition) -> MycDoctorFuture<'_>;
+}
+
+/// Stable status of one completed check.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MycDoctorCheckStatus {
+ Pass,
+ Fail,
+ Timeout,
+ Skipped,
+}
+
+impl MycDoctorCheckStatus {
+ const fn as_str(self) -> &'static str {
+ match self {
+ Self::Pass => "pass",
+ Self::Fail => "fail",
+ Self::Timeout => "timeout",
+ Self::Skipped => "skipped",
+ }
+ }
+
+ const fn summary(self) -> &'static str {
+ match self {
+ Self::Pass => "check passed",
+ Self::Fail => "check failed",
+ Self::Timeout => "check timed out",
+ Self::Skipped => "optional check skipped",
+ }
+ }
+}
+
+/// Stable aggregate doctor status.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MycDoctorAggregateStatus {
+ Pass,
+ Degraded,
+ Fail,
+}
+
+impl MycDoctorAggregateStatus {
+ const fn as_str(self) -> &'static str {
+ match self {
+ Self::Pass => "pass",
+ Self::Degraded => "degraded",
+ Self::Fail => "fail",
+ }
+ }
+}
+
+/// One sealed structured doctor result.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct MycDoctorCheckResult {
+ definition: MycDoctorCheckDefinition,
+ status: MycDoctorCheckStatus,
+}
+
+impl MycDoctorCheckResult {
+ /// Returns the exact check definition.
+ #[must_use]
+ pub const fn definition(self) -> MycDoctorCheckDefinition {
+ self.definition
+ }
+
+ /// Returns the admitted check status.
+ #[must_use]
+ pub const fn status(self) -> MycDoctorCheckStatus {
+ self.status
+ }
+
+ /// Returns the fixed content-free summary.
+ #[must_use]
+ pub const fn summary(self) -> &'static str {
+ self.status.summary()
+ }
+}
+
+/// Stable source-free doctor construction failures.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MycDoctorErrorKind {
+ Encoding,
+ OutputTooLarge,
+}
+
+impl MycDoctorErrorKind {
+ const fn message(self) -> &'static str {
+ match self {
+ Self::Encoding => "Myc doctor output encoding failed",
+ Self::OutputTooLarge => "Myc doctor output exceeds its byte limit",
+ }
+ }
+}
+
+/// One redacted doctor construction failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct MycDoctorError {
+ kind: MycDoctorErrorKind,
+}
+
+impl MycDoctorError {
+ const fn new(kind: MycDoctorErrorKind) -> Self {
+ Self { kind }
+ }
+
+ /// Returns the stable error classification.
+ #[must_use]
+ pub const fn kind(self) -> MycDoctorErrorKind {
+ self.kind
+ }
+}
+
+impl fmt::Debug for MycDoctorError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycDoctorError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl fmt::Display for MycDoctorError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.kind.message())
+ }
+}
+
+impl Error for MycDoctorError {}
+
+/// One immutable, bounded, canonical Myc doctor report.
+///
+/// Construction remains inside [`run_myc_doctor`]:
+///
+/// ```compile_fail
+/// use myc::{MycDoctorAggregateStatus, MycDoctorReport};
+///
+/// let _ = MycDoctorReport {
+/// instance: todo!(),
+/// status: MycDoctorAggregateStatus::Pass,
+/// checks: Box::new([]),
+/// canonical_json: Box::new([]),
+/// };
+/// ```
+pub struct MycDoctorReport {
+ instance: InstanceId,
+ status: MycDoctorAggregateStatus,
+ checks: Box<[MycDoctorCheckResult]>,
+ canonical_json: Box<[u8]>,
+}
+
+impl MycDoctorReport {
+ /// Returns the fixed service identifier.
+ #[must_use]
+ pub const fn service(&self) -> &'static str {
+ MYC_SERVICE
+ }
+
+ /// Returns the validated instance identifier admitted into the report.
+ #[must_use]
+ pub const fn instance(&self) -> &InstanceId {
+ &self.instance
+ }
+
+ /// Returns the aggregate result.
+ #[must_use]
+ pub const fn status(&self) -> MycDoctorAggregateStatus {
+ self.status
+ }
+
+ /// Returns the ordered complete check inventory.
+ #[must_use]
+ pub fn checks(&self) -> &[MycDoctorCheckResult] {
+ &self.checks
+ }
+
+ /// Returns exact compact UTF-8 JSON in the shared v1 field order.
+ #[must_use]
+ pub fn canonical_json(&self) -> &[u8] {
+ &self.canonical_json
+ }
+
+ /// Returns exit 6 only when a required check failed or timed out.
+ #[must_use]
+ pub const fn exit_code(&self) -> u8 {
+ match self.status {
+ MycDoctorAggregateStatus::Fail => DOCTOR_FAILURE_EXIT_CODE,
+ MycDoctorAggregateStatus::Pass | MycDoctorAggregateStatus::Degraded => 0,
+ }
+ }
+}
+
+impl fmt::Debug for MycDoctorReport {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycDoctorReport")
+ .field("service", &MYC_SERVICE)
+ .field("instance", &"[redacted]")
+ .field("status", &self.status)
+ .field("check_count", &self.checks.len())
+ .field("canonical_json", &"[redacted]")
+ .finish()
+ }
+}
+
+/// Runs every governed check in exact contract order under its fixed deadline.
+///
+/// Probe implementations retain operation-specific filesystem, SQLite,
+/// provider, listener, network, relay, and clock authority. This orchestrator
+/// accepts only a closed result and cannot serialize their paths or raw errors.
+pub async fn run_myc_doctor(
+ context: &MycRuntimeContext,
+ probe: &(impl MycDoctorProbe + ?Sized),
+) -> Result<MycDoctorReport, MycDoctorError> {
+ let mut checks = Vec::with_capacity(MYC_DOCTOR_CHECK_COUNT);
+ for definition in CHECK_DEFINITIONS {
+ let status = match tokio::time::timeout(
+ Duration::from_millis(definition.deadline_ms),
+ probe.probe(definition),
+ )
+ .await
+ {
+ Ok(MycDoctorObservation::Pass) => MycDoctorCheckStatus::Pass,
+ Ok(MycDoctorObservation::Fail) => MycDoctorCheckStatus::Fail,
+ Ok(MycDoctorObservation::Skipped) if !definition.required => {
+ MycDoctorCheckStatus::Skipped
+ }
+ Ok(MycDoctorObservation::Skipped) => MycDoctorCheckStatus::Fail,
+ Err(_) => MycDoctorCheckStatus::Timeout,
+ };
+ checks.push(MycDoctorCheckResult { definition, status });
+ }
+ let checks = checks.into_boxed_slice();
+ let status = aggregate_status(&checks);
+ let instance = context.context().instance().clone();
+ let canonical_json = encode_report(&instance, status, &checks)?;
+
+ Ok(MycDoctorReport {
+ instance,
+ status,
+ checks,
+ canonical_json,
+ })
+}
+
+fn aggregate_status(checks: &[MycDoctorCheckResult]) -> MycDoctorAggregateStatus {
+ if checks
+ .iter()
+ .any(|result| result.definition.required && result.status != MycDoctorCheckStatus::Pass)
+ {
+ MycDoctorAggregateStatus::Fail
+ } else if checks
+ .iter()
+ .any(|result| result.status != MycDoctorCheckStatus::Pass)
+ {
+ MycDoctorAggregateStatus::Degraded
+ } else {
+ MycDoctorAggregateStatus::Pass
+ }
+}
+
+#[derive(Serialize)]
+struct DoctorWireReport<'a> {
+ contract_version: u32,
+ service: &'static str,
+ instance: &'a str,
+ status: &'static str,
+ checks: Vec<DoctorWireCheck>,
+}
+
+#[derive(Serialize)]
+struct DoctorWireCheck {
+ id: &'static str,
+ status: &'static str,
+ required: bool,
+ deadline_ms: u64,
+ summary: &'static str,
+ remediation_code: &'static str,
+}
+
+fn encode_report(
+ instance: &InstanceId,
+ status: MycDoctorAggregateStatus,
+ checks: &[MycDoctorCheckResult],
+) -> Result<Box<[u8]>, MycDoctorError> {
+ let checks = checks
+ .iter()
+ .map(|result| DoctorWireCheck {
+ id: result.definition.id.as_str(),
+ status: result.status.as_str(),
+ required: result.definition.required,
+ deadline_ms: result.definition.deadline_ms,
+ summary: result.status.summary(),
+ remediation_code: result.definition.remediation_code.as_str(),
+ })
+ .collect();
+ let encoded = serde_json::to_vec(&DoctorWireReport {
+ contract_version: MYC_DOCTOR_CONTRACT_VERSION,
+ service: MYC_SERVICE,
+ instance: instance.as_str(),
+ status: status.as_str(),
+ checks,
+ })
+ .map_err(|_| MycDoctorError::new(MycDoctorErrorKind::Encoding))?;
+ if encoded.len() > MYC_DOCTOR_REPORT_MAX_UTF8_BYTES {
+ return Err(MycDoctorError::new(MycDoctorErrorKind::OutputTooLarge));
+ }
+ Ok(encoded.into_boxed_slice())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::error::Error;
+
+ use super::{MycDoctorError, MycDoctorErrorKind};
+
+ #[test]
+ fn errors_are_source_free_and_content_free() {
+ for kind in [
+ MycDoctorErrorKind::Encoding,
+ MycDoctorErrorKind::OutputTooLarge,
+ ] {
+ let error = MycDoctorError::new(kind);
+ assert!(Error::source(&error).is_none());
+ let rendered = format!("{error} {error:?}");
+ for forbidden in ["/private", "secret", "relay", "sqlite"] {
+ assert!(!rendered.to_ascii_lowercase().contains(forbidden));
+ }
+ }
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -5,6 +5,7 @@
mod admin_v1;
mod cli_v1;
mod config_v1;
+mod doctor_v1;
mod nip46_admission;
mod nip46_authorization;
mod nip46_replay;
@@ -53,6 +54,13 @@ pub use config_v1::{
MycConfigDocumentV1, MycConfigProfile, MycConfigV1Error, MycConfigV1ErrorKind,
MycConfigValueSource, MycEffectiveConfigV1, parse_myc_config_v1,
};
+pub use doctor_v1::{
+ MYC_DOCTOR_CHECK_COUNT, MYC_DOCTOR_CONTRACT_VERSION, MYC_DOCTOR_REPORT_MAX_UTF8_BYTES,
+ MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES, MycDoctorAggregateStatus, MycDoctorCheckDefinition,
+ MycDoctorCheckId, MycDoctorCheckResult, MycDoctorCheckStatus, MycDoctorError,
+ MycDoctorErrorKind, MycDoctorFuture, MycDoctorObservation, MycDoctorProbe,
+ MycDoctorRemediationCode, MycDoctorReport, myc_doctor_check_definitions, run_myc_doctor,
+};
pub use nip46_admission::{
MYC_NIP46_EVENT_ID_MAX_BYTES, MYC_NIP46_PUBLIC_KEY_MAX_BYTES, MYC_NIP46_SIGNATURE_MAX_BYTES,
MycBoundedNip46Event, MycBoundedNip46Request, MycNip46AdmissionError,
diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs
@@ -14,6 +14,7 @@ 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_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 DISCOVERY_STATE: &str = include_str!("../src/state_discovery.rs");
const NIP46_VERIFICATION_CONTRACT: &str =
include_str!("../contracts/services_hardening/nip46_verification.v1.json");
@@ -35,6 +36,7 @@ const SOURCES: &[&str] = &[
include_str!("../src/admin_v1.rs"),
include_str!("../src/cli_v1.rs"),
include_str!("../src/config_v1.rs"),
+ include_str!("../src/doctor_v1.rs"),
include_str!("../src/nip46_admission.rs"),
include_str!("../src/nip46_authorization.rs"),
include_str!("../src/nip46_replay.rs"),
@@ -73,6 +75,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() {
"admin_v1",
"cli_v1",
"config_v1",
+ "doctor_v1",
"nip46_admission",
"nip46_authorization",
"nip46_replay",
@@ -124,6 +127,16 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
"pub enum myc::MycCliOfflineOperationV1",
"pub enum myc::MycCliAdminOperationV1",
"pub const fn myc::plan_myc_cli_v1",
+ "pub struct myc::MycDoctorReport",
+ "pub struct myc::MycDoctorCheckDefinition",
+ "pub struct myc::MycDoctorCheckResult",
+ "pub enum myc::MycDoctorCheckId",
+ "pub enum myc::MycDoctorCheckStatus",
+ "pub enum myc::MycDoctorAggregateStatus",
+ "pub enum myc::MycDoctorObservation",
+ "pub enum myc::MycDoctorRemediationCode",
+ "pub trait myc::MycDoctorProbe",
+ "pub async fn myc::run_myc_doctor",
"pub struct myc::MycAdminRequestDocument",
"pub struct myc::MycAdminResponseDocument",
"pub enum myc::MycAdminMethod",
@@ -196,6 +209,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
"admin_v1",
"cli_v1",
"config_v1",
+ "doctor_v1",
"nip46_admission",
"nip46_authorization",
"nip46_replay",
@@ -238,6 +252,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
"sqlx::",
"serde::",
"serde_json::",
+ "futures_util::",
"toml::",
"url::",
"nostr::",
@@ -256,6 +271,32 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
}
#[test]
+fn doctor_boundary_is_closed_bounded_and_dependency_neutral() {
+ for required in [
+ "MYC_DOCTOR_CHECK_COUNT: usize = 13",
+ "MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES: usize = 256",
+ "MYC_DOCTOR_REPORT_MAX_UTF8_BYTES: usize = 8_192",
+ "for definition in CHECK_DEFINITIONS",
+ "tokio::time::timeout(",
+ "MycDoctorObservation::Skipped) if !definition.required",
+ "MycDoctorObservation::Skipped) => MycDoctorCheckStatus::Fail",
+ ] {
+ assert!(DOCTOR_V1.contains(required), "missing `{required}`");
+ }
+ for forbidden in [
+ "std::fs::",
+ "sqlx::",
+ "reqwest::",
+ "url::Url",
+ "std::env::",
+ "raw_error",
+ "PathBuf",
+ ] {
+ assert!(!DOCTOR_V1.contains(forbidden), "found `{forbidden}`");
+ }
+}
+
+#[test]
fn step148_response_commit_is_one_atomic_exact_byte_authority() {
let contract: serde_json::Value =
serde_json::from_str(NIP46_RESPONSE_CONTRACT).expect("Step 148 contract");
@@ -601,7 +642,8 @@ 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, 27);
+ assert_eq!(public_error_count, 28);
+ 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_contracts.rs b/tests/services_hardening_contracts.rs
@@ -340,22 +340,42 @@ fn doctor_exit_and_tcp_contracts_are_exact() {
"radroots.service.doctor.v1"
);
assert_eq!(value["doctor"]["contract_version"], 1);
+ assert_eq!(value["doctor"]["execution"], "ordered");
+ assert_eq!(value["doctor"]["pass_requires_all_scope"], true);
+ assert_eq!(
+ value["doctor"]["probe_future_cancellation"],
+ "drop_stops_or_owns_cleanup"
+ );
+ assert_eq!(value["doctor"]["detached_probe_work"], false);
+ assert_eq!(
+ value["doctor"]["statuses"],
+ serde_json::json!(["pass", "fail", "timeout", "skipped"])
+ );
+ assert_eq!(
+ value["doctor"]["aggregate_statuses"],
+ serde_json::json!(["pass", "degraded", "fail"])
+ );
+ assert_eq!(value["doctor"]["required_skipped"], "forbidden");
+ assert_eq!(value["doctor"]["summary_max_utf8_bytes"], 256);
+ assert_eq!(value["doctor"]["report_max_utf8_bytes"], 8192);
+ assert_eq!(value["doctor"]["raw_error_or_path_allowed"], false);
+ assert_eq!(value["doctor"]["required_fail_or_timeout_exit"], 6);
assert_eq!(
value["doctor"]["checks"],
serde_json::json!([
- { "id": "paths_permissions", "required": true },
- { "id": "writer_lock", "required": true },
- { "id": "sqlite_schema", "required": true },
- { "id": "sqlite_integrity", "required": true },
- { "id": "sqlite_free_space", "required": true },
- { "id": "identity_binding", "required": true },
- { "id": "signer_provider", "required": true },
- { "id": "admin_bind_policy", "required": true },
- { "id": "operations_bind_policy", "required": true },
- { "id": "network_policy", "required": true },
- { "id": "required_relays", "required": true },
- { "id": "outbox_invariants", "required": true },
- { "id": "clock_skew", "required": false }
+ { "id": "paths_permissions", "required": true, "deadline_ms": 2000, "remediation_code": "correct_path_policy", "scope": ["resolved_path_containment", "owner", "type", "mode"] },
+ { "id": "writer_lock", "required": true, "deadline_ms": 2000, "remediation_code": "release_writer_lock", "scope": ["state_directory_binding", "writer_lock_state"] },
+ { "id": "sqlite_schema", "required": true, "deadline_ms": 5000, "remediation_code": "repair_schema", "scope": ["metadata_identity", "migration_history", "schema_catalog"] },
+ { "id": "sqlite_integrity", "required": true, "deadline_ms": 15000, "remediation_code": "restore_verified_state", "scope": ["integrity_check", "foreign_key_check"] },
+ { "id": "sqlite_free_space", "required": true, "deadline_ms": 2000, "remediation_code": "free_state_disk_space", "scope": ["state_filesystem_capacity", "minimum_free_bytes"] },
+ { "id": "identity_binding", "required": true, "deadline_ms": 2000, "remediation_code": "restore_identity_binding", "scope": ["envelope_contract", "credential_reference", "public_identity"] },
+ { "id": "signer_provider", "required": true, "deadline_ms": 15000, "remediation_code": "repair_signer_provider", "scope": ["capability", "contract_version", "identity", "correlation", "deadline"] },
+ { "id": "admin_bind_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_admin_bind_policy", "scope": ["unix_socket_path", "socket_mode", "peer_authorization"] },
+ { "id": "operations_bind_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_operations_bind_policy", "scope": ["enabled_posture", "listen_address", "bind_policy"] },
+ { "id": "network_policy", "required": true, "deadline_ms": 2000, "remediation_code": "correct_network_policy", "scope": ["dns_policy", "tls_policy", "relay_url_policy"] },
+ { "id": "required_relays", "required": true, "deadline_ms": 15000, "remediation_code": "restore_required_relays", "scope": ["required_read_relays", "required_write_relays", "connect_deadline"] },
+ { "id": "outbox_invariants", "required": true, "deadline_ms": 5000, "remediation_code": "repair_outbox_state", "scope": ["claim_invariants", "retry_state", "exact_response_bytes"] },
+ { "id": "clock_skew", "required": false, "deadline_ms": 5000, "remediation_code": "correct_clock", "scope": ["wall_clock_skew"] }
])
);
assert_eq!(
diff --git a/tests/services_hardening_doctor.rs b/tests/services_hardening_doctor.rs
@@ -0,0 +1,297 @@
+#![forbid(unsafe_code)]
+
+use std::{
+ collections::BTreeMap,
+ future::pending,
+ sync::{Arc, Mutex},
+};
+
+use myc::{
+ MYC_DOCTOR_CHECK_COUNT, MYC_DOCTOR_CONTRACT_VERSION, MYC_DOCTOR_REPORT_MAX_UTF8_BYTES,
+ MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES, MycDoctorAggregateStatus, MycDoctorCheckDefinition,
+ MycDoctorCheckId, MycDoctorCheckStatus, MycDoctorFuture, MycDoctorObservation, MycDoctorProbe,
+ RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, myc_doctor_check_definitions,
+ parse_myc_cli_v1_from, resolve_myc_runtime_context, run_myc_doctor,
+};
+use sha2::{Digest, Sha256};
+
+const OPERATOR_CONTRACT: &str =
+ include_str!("../contracts/services_hardening/operator_contract.v1.json");
+
+struct TestProbe {
+ outcomes: BTreeMap<MycDoctorCheckId, MycDoctorObservation>,
+ pending: Option<MycDoctorCheckId>,
+ calls: Arc<Mutex<Vec<MycDoctorCheckId>>>,
+}
+
+impl TestProbe {
+ fn all(outcome: MycDoctorObservation) -> Self {
+ Self {
+ outcomes: myc_doctor_check_definitions()
+ .iter()
+ .map(|definition| (definition.id(), outcome))
+ .collect(),
+ pending: None,
+ calls: Arc::new(Mutex::new(Vec::new())),
+ }
+ }
+
+ fn with(mut self, id: MycDoctorCheckId, outcome: MycDoctorObservation) -> Self {
+ self.outcomes.insert(id, outcome);
+ self
+ }
+
+ fn pending(mut self, id: MycDoctorCheckId) -> Self {
+ self.pending = Some(id);
+ self
+ }
+}
+
+impl MycDoctorProbe for TestProbe {
+ fn probe(&self, definition: MycDoctorCheckDefinition) -> MycDoctorFuture<'_> {
+ let id = definition.id();
+ self.calls.lock().expect("calls lock").push(id);
+ if self.pending == Some(id) {
+ return Box::pin(pending());
+ }
+ let outcome = self.outcomes[&id];
+ Box::pin(async move { outcome })
+ }
+}
+
+fn runtime() -> (tempfile::TempDir, myc::MycRuntimeContext) {
+ let directory = tempfile::tempdir().expect("temporary root");
+ let root = directory.path().to_str().expect("UTF-8 path");
+ let invocation = parse_myc_cli_v1_from([
+ "myc",
+ "--profile",
+ "repo-local",
+ "--instance",
+ "primary",
+ "--repo-local-root",
+ root,
+ "doctor",
+ ])
+ .expect("doctor invocation");
+ let context = resolve_myc_runtime_context(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
+ &invocation,
+ )
+ .expect("runtime context");
+ (directory, context)
+}
+
+#[test]
+fn exact_inventory_matches_the_operator_contract() {
+ let contract: serde_json::Value = serde_json::from_str(OPERATOR_CONTRACT).expect("contract");
+ let doctor = contract["doctor"].as_object().expect("doctor");
+ assert_eq!(
+ doctor
+ .keys()
+ .map(String::as_str)
+ .collect::<std::collections::BTreeSet<_>>(),
+ std::collections::BTreeSet::from([
+ "aggregate_statuses",
+ "checks",
+ "contract_version",
+ "detached_probe_work",
+ "execution",
+ "pass_requires_all_scope",
+ "probe_future_cancellation",
+ "raw_error_or_path_allowed",
+ "report_max_utf8_bytes",
+ "required_fail_or_timeout_exit",
+ "required_skipped",
+ "shared_schema",
+ "statuses",
+ "summary_max_utf8_bytes",
+ ])
+ );
+ assert_eq!(MYC_DOCTOR_CONTRACT_VERSION, 1);
+ assert_eq!(MYC_DOCTOR_CHECK_COUNT, 13);
+ assert_eq!(MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES, 256);
+ assert_eq!(MYC_DOCTOR_REPORT_MAX_UTF8_BYTES, 8_192);
+ assert_eq!(doctor["execution"], "ordered");
+ assert_eq!(doctor["pass_requires_all_scope"], true);
+ assert_eq!(
+ doctor["probe_future_cancellation"],
+ "drop_stops_or_owns_cleanup"
+ );
+ assert_eq!(doctor["detached_probe_work"], false);
+ assert_eq!(doctor["required_skipped"], "forbidden");
+ assert_eq!(doctor["raw_error_or_path_allowed"], false);
+ assert_eq!(doctor["required_fail_or_timeout_exit"], 6);
+
+ let rows = doctor["checks"].as_array().expect("checks");
+ assert_eq!(rows.len(), MYC_DOCTOR_CHECK_COUNT);
+ for (definition, row) in myc_doctor_check_definitions().iter().zip(rows) {
+ assert_eq!(row["id"], id_name(definition.id()));
+ assert_eq!(row["required"], definition.required());
+ assert_eq!(row["deadline_ms"], definition.deadline_ms());
+ assert_eq!(
+ row["remediation_code"],
+ remediation_name(definition.remediation_code())
+ );
+ assert_eq!(
+ row["scope"],
+ serde_json::to_value(definition.scope()).expect("scope")
+ );
+ }
+}
+
+#[tokio::test]
+async fn all_pass_is_canonical_bounded_and_exit_zero() {
+ let (_directory, context) = runtime();
+ let probe = TestProbe::all(MycDoctorObservation::Pass);
+ let report = run_myc_doctor(&context, &probe).await.expect("report");
+ assert_eq!(report.service(), "myc");
+ assert_eq!(report.instance().as_str(), "primary");
+ assert_eq!(report.status(), MycDoctorAggregateStatus::Pass);
+ assert_eq!(report.exit_code(), 0);
+ assert_eq!(report.checks().len(), MYC_DOCTOR_CHECK_COUNT);
+ assert!(
+ report
+ .checks()
+ .iter()
+ .all(|result| result.status() == MycDoctorCheckStatus::Pass)
+ );
+ assert_eq!(
+ probe.calls.lock().expect("calls").len(),
+ MYC_DOCTOR_CHECK_COUNT
+ );
+
+ let bytes = report.canonical_json();
+ assert!(bytes.len() <= MYC_DOCTOR_REPORT_MAX_UTF8_BYTES);
+ assert!(!bytes.contains(&b'\n'));
+ let wire: serde_json::Value = serde_json::from_slice(bytes).expect("JSON");
+ assert_eq!(wire["contract_version"], 1);
+ assert_eq!(wire["service"], "myc");
+ assert_eq!(wire["instance"], "primary");
+ assert_eq!(wire["status"], "pass");
+ assert_eq!(wire["checks"].as_array().expect("checks").len(), 13);
+ assert!(String::from_utf8_lossy(bytes).starts_with(
+ "{\"contract_version\":1,\"service\":\"myc\",\"instance\":\"primary\",\"status\":\"pass\",\"checks\":["
+ ));
+ assert_eq!(
+ hex::encode(Sha256::digest(bytes)),
+ "19d7b33a205ed26fa6cf8c0c77ada75cdea7a1e01bca45efa72f95c65ac645ce"
+ );
+}
+
+#[tokio::test]
+async fn optional_nonpass_is_degraded_but_successful() {
+ let (_directory, context) = runtime();
+ for outcome in [MycDoctorObservation::Fail, MycDoctorObservation::Skipped] {
+ let probe =
+ TestProbe::all(MycDoctorObservation::Pass).with(MycDoctorCheckId::ClockSkew, outcome);
+ let report = run_myc_doctor(&context, &probe).await.expect("report");
+ assert_eq!(report.status(), MycDoctorAggregateStatus::Degraded);
+ assert_eq!(report.exit_code(), 0);
+ assert_ne!(report.checks()[12].status(), MycDoctorCheckStatus::Pass);
+ }
+}
+
+#[tokio::test]
+async fn required_fail_and_skip_are_fail_closed() {
+ let (_directory, context) = runtime();
+ for outcome in [MycDoctorObservation::Fail, MycDoctorObservation::Skipped] {
+ let probe =
+ TestProbe::all(MycDoctorObservation::Pass).with(MycDoctorCheckId::WriterLock, outcome);
+ let report = run_myc_doctor(&context, &probe).await.expect("report");
+ assert_eq!(report.status(), MycDoctorAggregateStatus::Fail);
+ assert_eq!(report.exit_code(), 6);
+ assert_eq!(report.checks()[1].status(), MycDoctorCheckStatus::Fail);
+ }
+}
+
+#[tokio::test]
+async fn required_timeout_is_bounded_and_remaining_checks_continue_in_order() {
+ let (_directory, context) = runtime();
+ let probe =
+ TestProbe::all(MycDoctorObservation::Pass).pending(MycDoctorCheckId::PathsPermissions);
+ let report = run_myc_doctor(&context, &probe).await.expect("report");
+ assert_eq!(report.status(), MycDoctorAggregateStatus::Fail);
+ assert_eq!(report.exit_code(), 6);
+ assert_eq!(report.checks()[0].status(), MycDoctorCheckStatus::Timeout);
+ let calls = probe.calls.lock().expect("calls").clone();
+ assert_eq!(
+ calls,
+ myc_doctor_check_definitions()
+ .iter()
+ .map(|definition| definition.id())
+ .collect::<Vec<_>>()
+ );
+}
+
+#[tokio::test]
+async fn report_debug_and_error_surface_retain_no_sensitive_values() {
+ let directory = tempfile::tempdir().expect("temporary root");
+ let root = directory.path().join("secret-root");
+ let root = root.to_str().expect("UTF-8 path");
+ let invocation = parse_myc_cli_v1_from([
+ "myc",
+ "--profile",
+ "repo-local",
+ "--instance",
+ "secret-instance",
+ "--repo-local-root",
+ root,
+ "doctor",
+ ])
+ .expect("doctor invocation");
+ let context = resolve_myc_runtime_context(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
+ &invocation,
+ )
+ .expect("runtime context");
+ let report = run_myc_doctor(&context, &TestProbe::all(MycDoctorObservation::Pass))
+ .await
+ .expect("report");
+ let debug = format!("{report:?}");
+ assert!(!debug.contains("secret-instance"));
+ assert!(!debug.contains("secret-root"));
+ for result in report.checks() {
+ assert!(result.summary().len() <= MYC_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
+ }
+
+ let rendered = format!("{:?}", myc::MycDoctorErrorKind::OutputTooLarge);
+ assert!(!rendered.contains("secret"));
+}
+
+fn id_name(id: MycDoctorCheckId) -> &'static str {
+ match id {
+ MycDoctorCheckId::PathsPermissions => "paths_permissions",
+ MycDoctorCheckId::WriterLock => "writer_lock",
+ MycDoctorCheckId::SqliteSchema => "sqlite_schema",
+ MycDoctorCheckId::SqliteIntegrity => "sqlite_integrity",
+ MycDoctorCheckId::SqliteFreeSpace => "sqlite_free_space",
+ MycDoctorCheckId::IdentityBinding => "identity_binding",
+ MycDoctorCheckId::SignerProvider => "signer_provider",
+ MycDoctorCheckId::AdminBindPolicy => "admin_bind_policy",
+ MycDoctorCheckId::OperationsBindPolicy => "operations_bind_policy",
+ MycDoctorCheckId::NetworkPolicy => "network_policy",
+ MycDoctorCheckId::RequiredRelays => "required_relays",
+ MycDoctorCheckId::OutboxInvariants => "outbox_invariants",
+ MycDoctorCheckId::ClockSkew => "clock_skew",
+ }
+}
+
+fn remediation_name(code: myc::MycDoctorRemediationCode) -> &'static str {
+ match code {
+ myc::MycDoctorRemediationCode::CorrectPathPolicy => "correct_path_policy",
+ myc::MycDoctorRemediationCode::ReleaseWriterLock => "release_writer_lock",
+ myc::MycDoctorRemediationCode::RepairSchema => "repair_schema",
+ myc::MycDoctorRemediationCode::RestoreVerifiedState => "restore_verified_state",
+ myc::MycDoctorRemediationCode::FreeStateDiskSpace => "free_state_disk_space",
+ myc::MycDoctorRemediationCode::RestoreIdentityBinding => "restore_identity_binding",
+ myc::MycDoctorRemediationCode::RepairSignerProvider => "repair_signer_provider",
+ myc::MycDoctorRemediationCode::CorrectAdminBindPolicy => "correct_admin_bind_policy",
+ myc::MycDoctorRemediationCode::CorrectOperationsBindPolicy => {
+ "correct_operations_bind_policy"
+ }
+ myc::MycDoctorRemediationCode::CorrectNetworkPolicy => "correct_network_policy",
+ myc::MycDoctorRemediationCode::RestoreRequiredRelays => "restore_required_relays",
+ myc::MycDoctorRemediationCode::RepairOutboxState => "repair_outbox_state",
+ myc::MycDoctorRemediationCode::CorrectClock => "correct_clock",
+ }
+}