commit d622a3569eda032cc425bc4d853fb1ccdcf4631e
parent b27c561a885b5904d270848eed5c71d8855fc7e4
Author: triesap <tyson@radroots.org>
Date: Mon, 24 Aug 2026 17:33:38 +0000
doctor: freeze bounded RHI diagnostics
Diffstat:
12 files changed, 1356 insertions(+), 54 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -434,6 +434,14 @@
identity/credential, bind/network policy, required source reachability,
checkpoint plausibility, leases/backlog, publication invariants, and clock
skew without leaking protected details.
+- Step 211 freezes the exact fifteen-check doctor inventory, its ordered
+ per-check deadlines and safe remediation codes, the `pass`, `degraded`, and
+ `fail` aggregate meanings, and process exit codes zero through six. A required
+ skipped result is failure; required failure or timeout is exit six; optional
+ non-pass is degraded success. Dropping a timed-out probe future must stop its
+ work or leave cleanup owned synchronously by that future, with no detached
+ probe task. Keep paths, raw errors, arbitrary summaries, and protected values
+ out of the bounded canonical report and process diagnostics.
- Keep plaintext keys, decrypted identity, wrapping credentials, tokens, raw
sensitive evidence, private identifiers, paths, upstream errors, and
equivalent protected material out of config, logs, status, metrics, audit,
diff --git a/Cargo.toml b/Cargo.toml
@@ -69,11 +69,12 @@ futures-executor = { version = "0.3" }
jsonschema = { version = "0.48.1", default-features = false }
nostr = { version = "0.44.7" }
rustix = { version = "1", features = ["fs", "process", "std"] }
-serde = { version = "1", default-features = false }
+serde = { version = "1", default-features = false, features = ["derive"] }
serde_json = { version = "1", default-features = false, features = ["raw_value"] }
sha2 = { version = "0.10" }
sqlx = { version = "0.9.0", default-features = false, features = ["sqlite-bundled"] }
thiserror = { version = "2" }
+tokio = { version = "1", default-features = false, features = ["time"] }
tempfile = { version = "3" }
toml = { version = "0.8" }
url = "2"
diff --git a/README b/README
@@ -555,6 +555,25 @@ database-path, worker, environment-file, or arbitrary path-leaf flag. Identity
rekey and replacement are not commands; rotation is a create-new offline
artifact plus governed configuration apply.
+## Bounded active doctor and stable process results
+
+The doctor runs the governed fifteen checks in exact contract order. Each
+check has one fixed deadline, required or optional authority, a closed evidence
+scope, and a safe remediation code. Required checks must pass; required
+failure, timeout, or an invalid skipped result makes the aggregate `fail`.
+Optional non-pass makes the aggregate `degraded`, while every passing check
+makes it `pass`. A timed-out probe is cancelled by dropping its future, and no
+detached probe work is permitted.
+
+The compact canonical JSON report is capped at 8,192 UTF-8 bytes. Its summaries
+come only from the fixed content-free vocabulary and cannot contain paths, raw
+errors, relay text, identifiers, credentials, or other protected material.
+Required failure or timeout returns exit code `6`; the complete process-result
+contract is the closed zero-through-six inventory in
+[`operator_contract.v1.json`](contracts/services_hardening/operator_contract.v1.json).
+The executable emits only the stable result code on stderr and never renders a
+parser, path-resolution, dependency, or internal error.
+
## Unix-admin boundary
Step 206 bound the seven common RHI routes for detailed status, redacted
diff --git a/contracts/api_baselines/rhi.txt b/contracts/api_baselines/rhi.txt
@@ -192,6 +192,54 @@ pub rhi::RhiCredentialResolutionErrorKind::UnsupportedPlatform
pub rhi::RhiCredentialResolutionErrorKind::UnsupportedProfile
impl rhi::RhiCredentialResolutionErrorKind
pub const fn rhi::RhiCredentialResolutionErrorKind::code(self) -> &'static str
+pub enum rhi::RhiDoctorAggregateStatus
+pub rhi::RhiDoctorAggregateStatus::Degraded
+pub rhi::RhiDoctorAggregateStatus::Fail
+pub rhi::RhiDoctorAggregateStatus::Pass
+pub enum rhi::RhiDoctorCheckId
+pub rhi::RhiDoctorCheckId::AdminBindPolicy
+pub rhi::RhiDoctorCheckId::ClockSkew
+pub rhi::RhiDoctorCheckId::CursorCheckpoint
+pub rhi::RhiDoctorCheckId::IdentityBinding
+pub rhi::RhiDoctorCheckId::NetworkPolicy
+pub rhi::RhiDoctorCheckId::OperationsBindPolicy
+pub rhi::RhiDoctorCheckId::PathsPermissions
+pub rhi::RhiDoctorCheckId::PublicationInvariants
+pub rhi::RhiDoctorCheckId::ReconciliationBacklog
+pub rhi::RhiDoctorCheckId::ReconciliationLeases
+pub rhi::RhiDoctorCheckId::RequiredSources
+pub rhi::RhiDoctorCheckId::SqliteFreeSpace
+pub rhi::RhiDoctorCheckId::SqliteIntegrity
+pub rhi::RhiDoctorCheckId::SqliteSchema
+pub rhi::RhiDoctorCheckId::WriterLock
+pub enum rhi::RhiDoctorCheckStatus
+pub rhi::RhiDoctorCheckStatus::Fail
+pub rhi::RhiDoctorCheckStatus::Pass
+pub rhi::RhiDoctorCheckStatus::Skipped
+pub rhi::RhiDoctorCheckStatus::Timeout
+pub enum rhi::RhiDoctorErrorKind
+pub rhi::RhiDoctorErrorKind::Encoding
+pub rhi::RhiDoctorErrorKind::OutputTooLarge
+pub enum rhi::RhiDoctorObservation
+pub rhi::RhiDoctorObservation::Fail
+pub rhi::RhiDoctorObservation::Pass
+pub rhi::RhiDoctorObservation::Skipped
+pub enum rhi::RhiDoctorRemediationCode
+pub rhi::RhiDoctorRemediationCode::CorrectAdminBindPolicy
+pub rhi::RhiDoctorRemediationCode::CorrectClock
+pub rhi::RhiDoctorRemediationCode::CorrectNetworkPolicy
+pub rhi::RhiDoctorRemediationCode::CorrectOperationsBindPolicy
+pub rhi::RhiDoctorRemediationCode::CorrectPathPolicy
+pub rhi::RhiDoctorRemediationCode::FreeStateDiskSpace
+pub rhi::RhiDoctorRemediationCode::ReduceReconciliationBacklog
+pub rhi::RhiDoctorRemediationCode::ReleaseWriterLock
+pub rhi::RhiDoctorRemediationCode::RepairCursorCheckpoint
+pub rhi::RhiDoctorRemediationCode::RepairPublicationState
+pub rhi::RhiDoctorRemediationCode::RepairReconciliationLeases
+pub rhi::RhiDoctorRemediationCode::RepairSchema
+pub rhi::RhiDoctorRemediationCode::RestoreIdentityBinding
+pub rhi::RhiDoctorRemediationCode::RestoreRequiredSources
+pub rhi::RhiDoctorRemediationCode::RestoreVerifiedState
pub enum rhi::RhiEncryptedIdentityEnvelopeErrorKind
pub rhi::RhiEncryptedIdentityEnvelopeErrorKind::AlreadyExists
pub rhi::RhiEncryptedIdentityEnvelopeErrorKind::IdentityMismatch
@@ -290,6 +338,18 @@ pub rhi::RhiPresenceTargetState::Submitted
pub rhi::RhiPresenceTargetState::Unknown
impl rhi::RhiPresenceTargetState
pub const fn rhi::RhiPresenceTargetState::code(self) -> &'static str
+pub enum rhi::RhiProcessResult
+pub rhi::RhiProcessResult::DoctorRequiredCheckFailed
+pub rhi::RhiProcessResult::InputOrConfiguration
+pub rhi::RhiProcessResult::OperationRejectedOrConflict
+pub rhi::RhiProcessResult::ServiceOrDependencyUnavailable
+pub rhi::RhiProcessResult::StateOrIdentityUnavailable
+pub rhi::RhiProcessResult::Success
+pub rhi::RhiProcessResult::UnexpectedInternal
+impl rhi::RhiProcessResult
+pub const fn rhi::RhiProcessResult::code(self) -> &'static str
+pub fn rhi::RhiProcessResult::exit_code(self) -> std::process::ExitCode
+pub const fn rhi::RhiProcessResult::exit_code_u8(self) -> u8
pub enum rhi::RhiPublicationAttemptEvidenceErrorKind
pub rhi::RhiPublicationAttemptEvidenceErrorKind::InvalidAttemptNumber
pub rhi::RhiPublicationAttemptEvidenceErrorKind::InvalidTargetOrdinal
@@ -863,6 +923,36 @@ pub const fn rhi::RhiDirtyTradeRepository<'_>::descriptor(&self) -> rhi::RhiStat
pub const fn rhi::RhiDirtyTradeRepository<'_>::kind(&self) -> rhi::RhiStateRepositoryKind
impl core::fmt::Debug for rhi::RhiDirtyTradeRepository<'_>
pub fn rhi::RhiDirtyTradeRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiDoctorCheckDefinition
+impl rhi::RhiDoctorCheckDefinition
+pub const fn rhi::RhiDoctorCheckDefinition::deadline_ms(self) -> u64
+pub const fn rhi::RhiDoctorCheckDefinition::id(self) -> rhi::RhiDoctorCheckId
+pub const fn rhi::RhiDoctorCheckDefinition::remediation_code(self) -> rhi::RhiDoctorRemediationCode
+pub const fn rhi::RhiDoctorCheckDefinition::required(self) -> bool
+pub const fn rhi::RhiDoctorCheckDefinition::scope(self) -> &'static [&'static str]
+pub struct rhi::RhiDoctorCheckResult
+impl rhi::RhiDoctorCheckResult
+pub const fn rhi::RhiDoctorCheckResult::definition(self) -> rhi::RhiDoctorCheckDefinition
+pub const fn rhi::RhiDoctorCheckResult::status(self) -> rhi::RhiDoctorCheckStatus
+pub const fn rhi::RhiDoctorCheckResult::summary(self) -> &'static str
+pub struct rhi::RhiDoctorError
+impl rhi::RhiDoctorError
+pub const fn rhi::RhiDoctorError::kind(self) -> rhi::RhiDoctorErrorKind
+impl core::error::Error for rhi::RhiDoctorError
+impl core::fmt::Debug for rhi::RhiDoctorError
+pub fn rhi::RhiDoctorError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+impl core::fmt::Display for rhi::RhiDoctorError
+pub fn rhi::RhiDoctorError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiDoctorReport
+impl rhi::RhiDoctorReport
+pub fn rhi::RhiDoctorReport::canonical_json(&self) -> &[u8]
+pub fn rhi::RhiDoctorReport::checks(&self) -> &[rhi::RhiDoctorCheckResult]
+pub const fn rhi::RhiDoctorReport::exit_code(&self) -> u8
+pub const fn rhi::RhiDoctorReport::instance(&self) -> &radroots_runtime_paths::identifier::InstanceId
+pub const fn rhi::RhiDoctorReport::service(&self) -> &'static str
+pub const fn rhi::RhiDoctorReport::status(&self) -> rhi::RhiDoctorAggregateStatus
+impl core::fmt::Debug for rhi::RhiDoctorReport
+pub fn rhi::RhiDoctorReport::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiEffectiveConfigV1
impl rhi::RhiEffectiveConfigV1
pub fn rhi::RhiEffectiveConfigV1::canonical_json(&self) -> &str
@@ -1948,6 +2038,10 @@ pub const rhi::RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES: usize
pub const rhi::RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES: usize
pub const rhi::RHI_CONFIG_SCHEMA: &str
pub const rhi::RHI_CONFIG_SCHEMA_VERSION: u32
+pub const rhi::RHI_DOCTOR_CHECK_COUNT: usize
+pub const rhi::RHI_DOCTOR_CONTRACT_VERSION: u32
+pub const rhi::RHI_DOCTOR_REPORT_MAX_UTF8_BYTES: usize
+pub const rhi::RHI_DOCTOR_SUMMARY_MAX_UTF8_BYTES: usize
pub const rhi::RHI_ENCRYPTED_IDENTITY_BACKUP_INCLUDED: bool
pub const rhi::RHI_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION: u32
pub const rhi::RHI_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES: usize
@@ -2037,6 +2131,8 @@ pub trait rhi::RhiCredentialAccess: core::marker::Send + core::marker::Sync
pub fn rhi::RhiCredentialAccess::resolve_existing(&self, &rhi::RhiRuntimeContext, &rhi::RhiIdentityEnvelopeBinding) -> core::result::Result<rhi::RhiWrappingCredential, rhi::RhiCredentialResolutionError>
impl rhi::RhiCredentialAccess for rhi::CanonicalRhiCredentialAccess
pub fn rhi::CanonicalRhiCredentialAccess::resolve_existing(&self, &rhi::RhiRuntimeContext, &rhi::RhiIdentityEnvelopeBinding) -> core::result::Result<rhi::RhiWrappingCredential, rhi::RhiCredentialResolutionError>
+pub trait rhi::RhiDoctorProbe: core::marker::Send + core::marker::Sync
+pub fn rhi::RhiDoctorProbe::probe(&self, rhi::RhiDoctorCheckDefinition) -> rhi::RhiDoctorFuture<'_>
pub trait rhi::RhiExactPresenceSink: core::marker::Send + core::marker::Sync
pub fn rhi::RhiExactPresenceSink::submit_exact<'a>(&'a self, &'a rhi::RhiPreparedPresenceAttempt) -> radroots_transport::source::BoxFuture<'a, rhi::RhiPresenceAttemptOutcome>
pub trait rhi::RhiExactPublicationSink: core::marker::Send + core::marker::Sync
@@ -2067,9 +2163,11 @@ pub fn rhi::provision_rhi_encrypted_identity(&rhi::RhiIdentityEnvelopeBinding, &
pub fn rhi::reduce_rhi_reconciliation_manifest(rhi::RhiReconciliationManifest) -> core::result::Result<rhi::RhiReconciliationProjection, rhi::RhiReconciliationReducerError>
pub fn rhi::resolve_rhi_runtime_context(&radroots_runtime_paths::roots::RadrootsPathResolver, &rhi::RhiCliInvocationV1) -> core::result::Result<rhi::RhiRuntimeContext, rhi::RhiRuntimeContextError>
pub fn rhi::resolve_rhi_wrapping_credential(&rhi::RhiRuntimeContext, &rhi::RhiIdentityEnvelopeBinding) -> core::result::Result<rhi::RhiWrappingCredential, rhi::RhiCredentialResolutionError>
+pub const fn rhi::rhi_doctor_check_definitions() -> &'static [rhi::RhiDoctorCheckDefinition; 15]
pub fn rhi::rhi_migration_catalog() -> core::result::Result<radroots_service_sqlite::migration::MigrationCatalog, rhi::RhiStateCatalogError>
pub fn rhi::rhi_schema_catalog() -> core::result::Result<radroots_service_sqlite::integrity::catalog::SchemaCatalog, rhi::RhiStateCatalogError>
pub const fn rhi::rhi_state_repository_descriptors() -> &'static [rhi::RhiStateRepositoryDescriptor; 21]
+pub async fn rhi::run_rhi_doctor(&rhi::RhiRuntimeContext, &impl rhi::RhiDoctorProbe + ?core::marker::Sized) -> core::result::Result<rhi::RhiDoctorReport, rhi::RhiDoctorError>
pub async fn rhi::stage_rhi_state_restore(&rhi::RhiRuntimeContext, &rhi::RhiStateMetadata, rhi::RhiVerifiedStateBackup) -> core::result::Result<rhi::RhiStagedStateRestore, rhi::RhiStateMaintenanceError>
pub fn rhi::trade_mutation_subscription_kinds() -> alloc::vec::Vec<u32>
pub fn rhi::validate_rhi_presence_desired_authority(&rhi::RhiConfigDocumentV1, &rhi::RhiPresenceDesiredAuthority) -> core::result::Result<(), rhi::RhiPresenceDesiredError>
@@ -2077,5 +2175,6 @@ pub fn rhi::validate_rhi_signed_presence_documents(&rhi::RhiSignedPresenceDocume
pub fn rhi::validate_rhi_state_catalogs(&radroots_service_sqlite::migration::MigrationCatalog, &radroots_service_sqlite::integrity::catalog::SchemaCatalog) -> core::result::Result<(), rhi::RhiStateCatalogError>
pub fn rhi::verify_rhi_state_backup(&[u8], radroots_service_sqlite::backup::manifest::BackupManifestSha256, &std::path::Path, &rhi::RhiStateMetadata, core::num::nonzero::NonZeroU64) -> core::result::Result<rhi::RhiVerifiedStateBackup, rhi::RhiStateMaintenanceError>
pub type rhi::RhiAdminFuture<'a> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = core::result::Result<rhi::RhiAdminResponseDocument, rhi::RhiAdminHandlerError>> + core::marker::Send + 'a)>>
+pub type rhi::RhiDoctorFuture<'a> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = rhi::RhiDoctorObservation> + core::marker::Send + 'a)>>
pub type rhi::RhiReconciliationCoverage = radroots_trade::evidence::RadrootsTradeEvidenceCoverageV1
pub type rhi::RhiReconciliationOutcome = radroots_trade::evidence::RadrootsTradeEvidenceOutcomeV1
diff --git a/contracts/services_hardening/operator_contract.v1.json b/contracts/services_hardening/operator_contract.v1.json
@@ -250,22 +250,33 @@
"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": "admin_bind_policy", "required": true },
- { "id": "operations_bind_policy", "required": true },
- { "id": "network_policy", "required": true },
- { "id": "required_sources", "required": true },
- { "id": "cursor_checkpoint", "required": true },
- { "id": "reconciliation_leases", "required": true },
- { "id": "reconciliation_backlog", "required": true },
- { "id": "publication_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": "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_sources", "required": true, "deadline_ms": 15000, "remediation_code": "restore_required_sources", "scope": ["required_source_inventory", "reachability", "source_deadline"] },
+ { "id": "cursor_checkpoint", "required": true, "deadline_ms": 5000, "remediation_code": "repair_cursor_checkpoint", "scope": ["selector_binding", "cursor_plausibility", "completion_evidence"] },
+ { "id": "reconciliation_leases", "required": true, "deadline_ms": 5000, "remediation_code": "repair_reconciliation_leases", "scope": ["lease_ownership", "lease_expiry", "retry_state"] },
+ { "id": "reconciliation_backlog", "required": true, "deadline_ms": 5000, "remediation_code": "reduce_reconciliation_backlog", "scope": ["queue_bound", "attempt_bound", "schedule_plausibility"] },
+ { "id": "publication_invariants", "required": true, "deadline_ms": 5000, "remediation_code": "repair_publication_state", "scope": ["exact_signed_bytes", "target_inventory", "outbox_schedule"] },
+ { "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,647 @@
+//! 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::RhiRuntimeContext;
+
+/// RHI doctor wire-contract version.
+pub const RHI_DOCTOR_CONTRACT_VERSION: u32 = 1;
+/// Exact number of governed RHI doctor checks.
+pub const RHI_DOCTOR_CHECK_COUNT: usize = 15;
+/// Maximum encoded size of one safe summary.
+pub const RHI_DOCTOR_SUMMARY_MAX_UTF8_BYTES: usize = 256;
+/// Maximum encoded size of the complete canonical doctor report.
+pub const RHI_DOCTOR_REPORT_MAX_UTF8_BYTES: usize = 8_192;
+
+const RHI_SERVICE: &str = "rhi";
+const DOCTOR_FAILURE_EXIT_CODE: u8 = 6;
+const _: () = {
+ assert!("check passed".len() <= RHI_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
+ assert!("check failed".len() <= RHI_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
+ assert!("check timed out".len() <= RHI_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
+ assert!("optional check skipped".len() <= RHI_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
+};
+
+/// The closed RHI doctor inventory.
+#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
+pub enum RhiDoctorCheckId {
+ PathsPermissions,
+ WriterLock,
+ SqliteSchema,
+ SqliteIntegrity,
+ SqliteFreeSpace,
+ IdentityBinding,
+ AdminBindPolicy,
+ OperationsBindPolicy,
+ NetworkPolicy,
+ RequiredSources,
+ CursorCheckpoint,
+ ReconciliationLeases,
+ ReconciliationBacklog,
+ PublicationInvariants,
+ ClockSkew,
+}
+
+impl RhiDoctorCheckId {
+ 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::AdminBindPolicy => "admin_bind_policy",
+ Self::OperationsBindPolicy => "operations_bind_policy",
+ Self::NetworkPolicy => "network_policy",
+ Self::RequiredSources => "required_sources",
+ Self::CursorCheckpoint => "cursor_checkpoint",
+ Self::ReconciliationLeases => "reconciliation_leases",
+ Self::ReconciliationBacklog => "reconciliation_backlog",
+ Self::PublicationInvariants => "publication_invariants",
+ Self::ClockSkew => "clock_skew",
+ }
+ }
+}
+
+/// Stable operator action associated with one doctor check.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiDoctorRemediationCode {
+ CorrectPathPolicy,
+ ReleaseWriterLock,
+ RepairSchema,
+ RestoreVerifiedState,
+ FreeStateDiskSpace,
+ RestoreIdentityBinding,
+ CorrectAdminBindPolicy,
+ CorrectOperationsBindPolicy,
+ CorrectNetworkPolicy,
+ RestoreRequiredSources,
+ RepairCursorCheckpoint,
+ RepairReconciliationLeases,
+ ReduceReconciliationBacklog,
+ RepairPublicationState,
+ CorrectClock,
+}
+
+impl RhiDoctorRemediationCode {
+ 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::CorrectAdminBindPolicy => "correct_admin_bind_policy",
+ Self::CorrectOperationsBindPolicy => "correct_operations_bind_policy",
+ Self::CorrectNetworkPolicy => "correct_network_policy",
+ Self::RestoreRequiredSources => "restore_required_sources",
+ Self::RepairCursorCheckpoint => "repair_cursor_checkpoint",
+ Self::RepairReconciliationLeases => "repair_reconciliation_leases",
+ Self::ReduceReconciliationBacklog => "reduce_reconciliation_backlog",
+ Self::RepairPublicationState => "repair_publication_state",
+ Self::CorrectClock => "correct_clock",
+ }
+ }
+}
+
+/// Immutable authority for one check's requirement, deadline, and remediation.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct RhiDoctorCheckDefinition {
+ id: RhiDoctorCheckId,
+ required: bool,
+ deadline_ms: u64,
+ remediation_code: RhiDoctorRemediationCode,
+ scope: &'static [&'static str],
+}
+
+impl RhiDoctorCheckDefinition {
+ const fn new(
+ id: RhiDoctorCheckId,
+ required: bool,
+ deadline_ms: u64,
+ remediation_code: RhiDoctorRemediationCode,
+ 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) -> RhiDoctorCheckId {
+ 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) -> RhiDoctorRemediationCode {
+ 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: [RhiDoctorCheckDefinition; RHI_DOCTOR_CHECK_COUNT] = [
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::PathsPermissions,
+ true,
+ 2_000,
+ RhiDoctorRemediationCode::CorrectPathPolicy,
+ &["resolved_path_containment", "owner", "type", "mode"],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::WriterLock,
+ true,
+ 2_000,
+ RhiDoctorRemediationCode::ReleaseWriterLock,
+ &["state_directory_binding", "writer_lock_state"],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::SqliteSchema,
+ true,
+ 5_000,
+ RhiDoctorRemediationCode::RepairSchema,
+ &["metadata_identity", "migration_history", "schema_catalog"],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::SqliteIntegrity,
+ true,
+ 15_000,
+ RhiDoctorRemediationCode::RestoreVerifiedState,
+ &["integrity_check", "foreign_key_check"],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::SqliteFreeSpace,
+ true,
+ 2_000,
+ RhiDoctorRemediationCode::FreeStateDiskSpace,
+ &["state_filesystem_capacity", "minimum_free_bytes"],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::IdentityBinding,
+ true,
+ 2_000,
+ RhiDoctorRemediationCode::RestoreIdentityBinding,
+ &[
+ "envelope_contract",
+ "credential_reference",
+ "public_identity",
+ ],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::AdminBindPolicy,
+ true,
+ 2_000,
+ RhiDoctorRemediationCode::CorrectAdminBindPolicy,
+ &["unix_socket_path", "socket_mode", "peer_authorization"],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::OperationsBindPolicy,
+ true,
+ 2_000,
+ RhiDoctorRemediationCode::CorrectOperationsBindPolicy,
+ &["enabled_posture", "listen_address", "bind_policy"],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::NetworkPolicy,
+ true,
+ 2_000,
+ RhiDoctorRemediationCode::CorrectNetworkPolicy,
+ &["dns_policy", "tls_policy", "relay_url_policy"],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::RequiredSources,
+ true,
+ 15_000,
+ RhiDoctorRemediationCode::RestoreRequiredSources,
+ &[
+ "required_source_inventory",
+ "reachability",
+ "source_deadline",
+ ],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::CursorCheckpoint,
+ true,
+ 5_000,
+ RhiDoctorRemediationCode::RepairCursorCheckpoint,
+ &[
+ "selector_binding",
+ "cursor_plausibility",
+ "completion_evidence",
+ ],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::ReconciliationLeases,
+ true,
+ 5_000,
+ RhiDoctorRemediationCode::RepairReconciliationLeases,
+ &["lease_ownership", "lease_expiry", "retry_state"],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::ReconciliationBacklog,
+ true,
+ 5_000,
+ RhiDoctorRemediationCode::ReduceReconciliationBacklog,
+ &["queue_bound", "attempt_bound", "schedule_plausibility"],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::PublicationInvariants,
+ true,
+ 5_000,
+ RhiDoctorRemediationCode::RepairPublicationState,
+ &["exact_signed_bytes", "target_inventory", "outbox_schedule"],
+ ),
+ RhiDoctorCheckDefinition::new(
+ RhiDoctorCheckId::ClockSkew,
+ false,
+ 5_000,
+ RhiDoctorRemediationCode::CorrectClock,
+ &["wall_clock_skew"],
+ ),
+];
+
+/// Returns the exact ordered doctor inventory.
+#[must_use]
+pub const fn rhi_doctor_check_definitions()
+-> &'static [RhiDoctorCheckDefinition; RHI_DOCTOR_CHECK_COUNT] {
+ &CHECK_DEFINITIONS
+}
+
+/// A closed result supplied by one bounded check implementation.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiDoctorObservation {
+ Pass,
+ Fail,
+ Skipped,
+}
+
+/// Future returned by one doctor probe.
+pub type RhiDoctorFuture<'a> = Pin<Box<dyn Future<Output = RhiDoctorObservation> + Send + 'a>>;
+
+/// Executes each active check without receiving report-construction authority.
+///
+/// `Pass` is permitted only after every facet in
+/// [`RhiDoctorCheckDefinition::scope`] is proven. Implementations must be
+/// cancellation-safe: dropping the future at its deadline must stop work or
+/// leave synchronous cleanup owned by that future, never detached mutation.
+pub trait RhiDoctorProbe: Send + Sync {
+ /// Runs one exact check. Raw errors, paths, and arbitrary summaries cannot
+ /// cross this boundary.
+ fn probe(&self, definition: RhiDoctorCheckDefinition) -> RhiDoctorFuture<'_>;
+}
+
+/// Stable status of one completed check.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiDoctorCheckStatus {
+ Pass,
+ Fail,
+ Timeout,
+ Skipped,
+}
+
+impl RhiDoctorCheckStatus {
+ 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 RhiDoctorAggregateStatus {
+ Pass,
+ Degraded,
+ Fail,
+}
+
+impl RhiDoctorAggregateStatus {
+ 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 RhiDoctorCheckResult {
+ definition: RhiDoctorCheckDefinition,
+ status: RhiDoctorCheckStatus,
+}
+
+impl RhiDoctorCheckResult {
+ /// Returns the exact check definition.
+ #[must_use]
+ pub const fn definition(self) -> RhiDoctorCheckDefinition {
+ self.definition
+ }
+
+ /// Returns the admitted check status.
+ #[must_use]
+ pub const fn status(self) -> RhiDoctorCheckStatus {
+ 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 RhiDoctorErrorKind {
+ Encoding,
+ OutputTooLarge,
+}
+
+impl RhiDoctorErrorKind {
+ const fn message(self) -> &'static str {
+ match self {
+ Self::Encoding => "RHI doctor output encoding failed",
+ Self::OutputTooLarge => "RHI doctor output exceeds its byte limit",
+ }
+ }
+}
+
+/// One redacted doctor construction failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct RhiDoctorError {
+ kind: RhiDoctorErrorKind,
+}
+
+impl RhiDoctorError {
+ const fn new(kind: RhiDoctorErrorKind) -> Self {
+ Self { kind }
+ }
+
+ /// Returns the stable error classification.
+ #[must_use]
+ pub const fn kind(self) -> RhiDoctorErrorKind {
+ self.kind
+ }
+}
+
+impl fmt::Debug for RhiDoctorError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiDoctorError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl fmt::Display for RhiDoctorError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.kind.message())
+ }
+}
+
+impl Error for RhiDoctorError {}
+
+/// One immutable, bounded, canonical RHI doctor report.
+///
+/// Construction remains inside [`run_rhi_doctor`]:
+///
+/// ```compile_fail
+/// use rhi::{RhiDoctorAggregateStatus, RhiDoctorReport};
+///
+/// let _ = RhiDoctorReport {
+/// instance: todo!(),
+/// status: RhiDoctorAggregateStatus::Pass,
+/// checks: Box::new([]),
+/// canonical_json: Box::new([]),
+/// };
+/// ```
+pub struct RhiDoctorReport {
+ instance: InstanceId,
+ status: RhiDoctorAggregateStatus,
+ checks: Box<[RhiDoctorCheckResult]>,
+ canonical_json: Box<[u8]>,
+}
+
+impl RhiDoctorReport {
+ /// Returns the fixed service identifier.
+ #[must_use]
+ pub const fn service(&self) -> &'static str {
+ RHI_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) -> RhiDoctorAggregateStatus {
+ self.status
+ }
+
+ /// Returns the ordered complete check inventory.
+ #[must_use]
+ pub fn checks(&self) -> &[RhiDoctorCheckResult] {
+ &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 {
+ RhiDoctorAggregateStatus::Fail => DOCTOR_FAILURE_EXIT_CODE,
+ RhiDoctorAggregateStatus::Pass | RhiDoctorAggregateStatus::Degraded => 0,
+ }
+ }
+}
+
+impl fmt::Debug for RhiDoctorReport {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiDoctorReport")
+ .field("service", &RHI_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,
+/// identity, listener, network, source, reconciliation, publication, and clock
+/// authority. This orchestrator accepts only a closed result and cannot
+/// serialize their paths or raw errors.
+pub async fn run_rhi_doctor(
+ context: &RhiRuntimeContext,
+ probe: &(impl RhiDoctorProbe + ?Sized),
+) -> Result<RhiDoctorReport, RhiDoctorError> {
+ let mut checks = Vec::with_capacity(RHI_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(RhiDoctorObservation::Pass) => RhiDoctorCheckStatus::Pass,
+ Ok(RhiDoctorObservation::Fail) => RhiDoctorCheckStatus::Fail,
+ Ok(RhiDoctorObservation::Skipped) if !definition.required => {
+ RhiDoctorCheckStatus::Skipped
+ }
+ Ok(RhiDoctorObservation::Skipped) => RhiDoctorCheckStatus::Fail,
+ Err(_) => RhiDoctorCheckStatus::Timeout,
+ };
+ checks.push(RhiDoctorCheckResult { 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(RhiDoctorReport {
+ instance,
+ status,
+ checks,
+ canonical_json,
+ })
+}
+
+fn aggregate_status(checks: &[RhiDoctorCheckResult]) -> RhiDoctorAggregateStatus {
+ if checks
+ .iter()
+ .any(|result| result.definition.required && result.status != RhiDoctorCheckStatus::Pass)
+ {
+ RhiDoctorAggregateStatus::Fail
+ } else if checks
+ .iter()
+ .any(|result| result.status != RhiDoctorCheckStatus::Pass)
+ {
+ RhiDoctorAggregateStatus::Degraded
+ } else {
+ RhiDoctorAggregateStatus::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: RhiDoctorAggregateStatus,
+ checks: &[RhiDoctorCheckResult],
+) -> Result<Box<[u8]>, RhiDoctorError> {
+ 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: RHI_DOCTOR_CONTRACT_VERSION,
+ service: RHI_SERVICE,
+ instance: instance.as_str(),
+ status: status.as_str(),
+ checks,
+ })
+ .map_err(|_| RhiDoctorError::new(RhiDoctorErrorKind::Encoding))?;
+ if encoded.len() > RHI_DOCTOR_REPORT_MAX_UTF8_BYTES {
+ return Err(RhiDoctorError::new(RhiDoctorErrorKind::OutputTooLarge));
+ }
+ Ok(encoded.into_boxed_slice())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::error::Error;
+
+ use super::{RhiDoctorError, RhiDoctorErrorKind};
+
+ #[test]
+ fn errors_are_source_free_and_content_free() {
+ for kind in [
+ RhiDoctorErrorKind::Encoding,
+ RhiDoctorErrorKind::OutputTooLarge,
+ ] {
+ let error = RhiDoctorError::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
@@ -6,11 +6,13 @@ mod adapters;
mod admin_v1;
mod cli_v1;
mod config_v1;
+mod doctor_v1;
mod features;
mod identity_credential;
mod identity_envelope;
mod presence_desired;
mod presence_publication;
+mod process_result_v1;
mod publication;
mod publication_attempt;
mod publication_execution;
@@ -58,6 +60,13 @@ pub use config_v1::{
RhiConfigV1Error, RhiConfigV1ErrorKind, RhiConfigValueSource, RhiEffectiveConfigV1,
RhiRuntimeThreadLimitsV1, parse_rhi_config_v1,
};
+pub use doctor_v1::{
+ RHI_DOCTOR_CHECK_COUNT, RHI_DOCTOR_CONTRACT_VERSION, RHI_DOCTOR_REPORT_MAX_UTF8_BYTES,
+ RHI_DOCTOR_SUMMARY_MAX_UTF8_BYTES, RhiDoctorAggregateStatus, RhiDoctorCheckDefinition,
+ RhiDoctorCheckId, RhiDoctorCheckResult, RhiDoctorCheckStatus, RhiDoctorError,
+ RhiDoctorErrorKind, RhiDoctorFuture, RhiDoctorObservation, RhiDoctorProbe,
+ RhiDoctorRemediationCode, RhiDoctorReport, rhi_doctor_check_definitions, run_rhi_doctor,
+};
pub use features::trade_agreement_attestation::{
RHI_AGREEMENT_ATTESTATION_PROOF_SYSTEM_LOCAL_STATEMENT_HASH,
RHI_AGREEMENT_ATTESTATION_PROTOCOL_ID, RHI_AGREEMENT_ATTESTATION_REPORT_VERSION,
@@ -96,6 +105,7 @@ pub use presence_publication::{
RhiSignedPresenceDocument, RhiSignedPresenceDocuments, build_rhi_signed_presence_documents,
validate_rhi_signed_presence_documents,
};
+pub use process_result_v1::RhiProcessResult;
pub use publication::{
RHI_PUBLICATION_CONTRACT_VERSION, RHI_PUBLICATION_MAX_ATTEMPTS, RHI_PUBLICATION_MAX_TARGETS,
RhiPublicationAuthority, RhiPublicationError, RhiPublicationErrorKind, RhiPublicationMode,
diff --git a/src/main.rs b/src/main.rs
@@ -4,33 +4,36 @@ use std::path::PathBuf;
use std::process::ExitCode;
use rhi::{
- RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, parse_rhi_cli_v1_from,
- plan_rhi_cli_v1, resolve_rhi_runtime_context,
+ RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiProcessResult,
+ parse_rhi_cli_v1_from, plan_rhi_cli_v1, resolve_rhi_runtime_context,
};
fn main() -> ExitCode {
let invocation = match parse_rhi_cli_v1_from(std::env::args_os()) {
Ok(invocation) => invocation,
- Err(_) => return ExitCode::FAILURE,
+ Err(_) => return emit_failure(RhiProcessResult::InputOrConfiguration),
};
exit_code_from_run(execute(invocation))
}
-fn exit_code_from_run(result: Result<(), ()>) -> ExitCode {
+fn exit_code_from_run(result: Result<(), RhiProcessResult>) -> ExitCode {
match result {
- Ok(()) => ExitCode::SUCCESS,
- Err(_) => {
- eprintln!("RHI command failed");
- ExitCode::FAILURE
- }
+ Ok(()) => RhiProcessResult::Success.exit_code(),
+ Err(result) => emit_failure(result),
}
}
-fn execute(invocation: rhi::RhiCliInvocationV1) -> Result<(), ()> {
+fn emit_failure(result: RhiProcessResult) -> ExitCode {
+ eprintln!("RHI command failed: {}", result.code());
+ result.exit_code()
+}
+
+fn execute(invocation: rhi::RhiCliInvocationV1) -> Result<(), RhiProcessResult> {
let _plan = plan_rhi_cli_v1(&invocation);
let resolver = RadrootsPathResolver::new(RadrootsPlatform::current(), host_environment());
- let _context = resolve_rhi_runtime_context(&resolver, &invocation).map_err(|_| ())?;
- Err(())
+ let _context = resolve_rhi_runtime_context(&resolver, &invocation)
+ .map_err(|_| RhiProcessResult::InputOrConfiguration)?;
+ Err(RhiProcessResult::InputOrConfiguration)
}
fn host_environment() -> RadrootsHostEnvironment {
@@ -54,13 +57,16 @@ fn host_environment() -> RadrootsHostEnvironment {
#[cfg(test)]
mod tests {
use super::{execute, exit_code_from_run};
- use rhi::parse_rhi_cli_v1_from;
+ use rhi::{RhiProcessResult, parse_rhi_cli_v1_from};
use std::process::ExitCode;
#[test]
fn process_result_is_stable() {
assert_eq!(exit_code_from_run(Ok(())), ExitCode::SUCCESS);
- assert_eq!(exit_code_from_run(Err(())), ExitCode::FAILURE);
+ assert_eq!(
+ exit_code_from_run(Err(RhiProcessResult::UnexpectedInternal)),
+ ExitCode::FAILURE
+ );
}
#[test]
@@ -78,7 +84,10 @@ mod tests {
])
.expect("valid invocation");
- assert_eq!(execute(invocation), Err(()));
+ assert_eq!(
+ execute(invocation),
+ Err(RhiProcessResult::InputOrConfiguration)
+ );
assert_eq!(
std::fs::read_dir(root.path()).expect("read root").count(),
0
diff --git a/src/process_result_v1.rs b/src/process_result_v1.rs
@@ -0,0 +1,51 @@
+//! Closed process-result and exit-code contract.
+
+use std::process::ExitCode;
+
+/// Exact stable RHI process result and exit-code inventory.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiProcessResult {
+ Success,
+ UnexpectedInternal,
+ InputOrConfiguration,
+ ServiceOrDependencyUnavailable,
+ StateOrIdentityUnavailable,
+ OperationRejectedOrConflict,
+ DoctorRequiredCheckFailed,
+}
+
+impl RhiProcessResult {
+ /// Returns the exact stable process exit code.
+ #[must_use]
+ pub const fn exit_code_u8(self) -> u8 {
+ match self {
+ Self::Success => 0,
+ Self::UnexpectedInternal => 1,
+ Self::InputOrConfiguration => 2,
+ Self::ServiceOrDependencyUnavailable => 3,
+ Self::StateOrIdentityUnavailable => 4,
+ Self::OperationRejectedOrConflict => 5,
+ Self::DoctorRequiredCheckFailed => 6,
+ }
+ }
+
+ /// Returns the stable machine code.
+ #[must_use]
+ pub const fn code(self) -> &'static str {
+ match self {
+ Self::Success => "success",
+ Self::UnexpectedInternal => "unexpected_internal",
+ Self::InputOrConfiguration => "input_or_configuration",
+ Self::ServiceOrDependencyUnavailable => "service_or_dependency_unavailable",
+ Self::StateOrIdentityUnavailable => "state_or_identity_unavailable",
+ Self::OperationRejectedOrConflict => "operation_rejected_or_conflict",
+ Self::DoctorRequiredCheckFailed => "doctor_required_check_failed",
+ }
+ }
+
+ /// Returns the standard-library process exit value.
+ #[must_use]
+ pub fn exit_code(self) -> ExitCode {
+ ExitCode::from(self.exit_code_u8())
+ }
+}
diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs
@@ -4,7 +4,12 @@ const MANIFEST: &str = include_str!("../Cargo.toml");
const README: &str = include_str!("../README");
const AGENTS: &str = include_str!("../AGENTS.md");
const ROOT: &str = include_str!("../src/lib.rs");
+const MAIN: &str = include_str!("../src/main.rs");
const ADMIN: &str = include_str!("../src/admin_v1.rs");
+const DOCTOR: &str = include_str!("../src/doctor_v1.rs");
+const PROCESS_RESULT: &str = include_str!("../src/process_result_v1.rs");
+const OPERATOR_CONTRACT: &str =
+ include_str!("../contracts/services_hardening/operator_contract.v1.json");
const ADMIN_IDENTITY_OFFLINE_CONTRACT: &str =
include_str!("../contracts/services_hardening/admin_identity_offline.v1.json");
const ADMIN_WAVE_QUALIFICATION_CONTRACT: &str =
@@ -78,11 +83,13 @@ 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/features/trade_agreement_attestation.rs"),
include_str!("../src/identity_credential.rs"),
include_str!("../src/identity_envelope.rs"),
include_str!("../src/presence_desired.rs"),
include_str!("../src/presence_publication.rs"),
+ include_str!("../src/process_result_v1.rs"),
include_str!("../src/publication.rs"),
include_str!("../src/publication_attempt.rs"),
include_str!("../src/publication_execution.rs"),
@@ -160,11 +167,13 @@ fn state_catalog_module_is_private_and_root_api_is_curated() {
"admin_v1",
"cli_v1",
"config_v1",
+ "doctor_v1",
"features",
"identity_credential",
"identity_envelope",
"presence_desired",
"presence_publication",
+ "process_result_v1",
"publication",
"publication_attempt",
"publication_execution",
@@ -247,6 +256,14 @@ fn state_catalog_module_is_private_and_root_api_is_curated() {
"RhiCliAdminOperationV1",
"RhiCliExecutionPlanV1",
"plan_rhi_cli_v1",
+ "RhiDoctorCheckId",
+ "RhiDoctorCheckDefinition",
+ "RhiDoctorCheckResult",
+ "RhiDoctorReport",
+ "RhiDoctorProbe",
+ "run_rhi_doctor",
+ "rhi_doctor_check_definitions",
+ "RhiProcessResult",
"RhiPublicationErrorKind",
"RhiPublicationMode",
"RhiPublicationRetryPolicy",
@@ -375,6 +392,77 @@ fn state_catalog_module_is_private_and_root_api_is_curated() {
assert!(!PUBLIC_API.contains("rhi::presence_publication::"));
assert!(!PUBLIC_API.contains("rhi::admin_v1::"));
assert!(!PUBLIC_API.contains("rhi::cli_v1::"));
+ assert!(!PUBLIC_API.contains("rhi::doctor_v1::"));
+ assert!(!PUBLIC_API.contains("rhi::process_result_v1::"));
+}
+
+#[test]
+fn doctor_and_process_results_are_closed_bounded_and_process_safe() {
+ let contract: serde_json::Value =
+ serde_json::from_str(OPERATOR_CONTRACT).expect("operator contract");
+ assert_eq!(contract["doctor"]["contract_version"], 1);
+ assert_eq!(contract["doctor"]["execution"], "ordered");
+ assert_eq!(contract["doctor"]["checks"].as_array().unwrap().len(), 15);
+ assert_eq!(contract["doctor"]["report_max_utf8_bytes"], 8_192);
+ assert_eq!(contract["doctor"]["required_fail_or_timeout_exit"], 6);
+ assert_eq!(contract["doctor"]["detached_probe_work"], false);
+ assert_eq!(contract["exit_codes"].as_array().unwrap().len(), 7);
+
+ for required in [
+ "pub const RHI_DOCTOR_CHECK_COUNT: usize = 15",
+ "pub const RHI_DOCTOR_REPORT_MAX_UTF8_BYTES: usize = 8_192",
+ "tokio::time::timeout(",
+ "probe.probe(definition)",
+ "Ok(RhiDoctorObservation::Skipped) if !definition.required",
+ "RhiDoctorCheckStatus::Timeout",
+ "canonical_json: Box<[u8]>",
+ ".field(\"canonical_json\", &\"[redacted]\")",
+ ] {
+ assert!(DOCTOR.contains(required), "doctor is missing {required}");
+ }
+ for forbidden in [
+ "std::fs",
+ "std::net",
+ "sqlx::",
+ "tokio::spawn",
+ "thread::spawn",
+ "SystemTime",
+ "serde_json::Value",
+ "pub fn into_inner",
+ ] {
+ assert!(
+ !DOCTOR.contains(forbidden),
+ "doctor orchestrator gained forbidden authority {forbidden}"
+ );
+ }
+ for required in [
+ "pub enum RhiProcessResult",
+ "Self::Success => 0",
+ "Self::DoctorRequiredCheckFailed => 6",
+ "pub const fn code(self) -> &'static str",
+ ] {
+ assert!(
+ PROCESS_RESULT.contains(required),
+ "process result is missing {required}"
+ );
+ }
+ assert!(MAIN.contains("parse_rhi_cli_v1_from(std::env::args_os())"));
+ assert!(MAIN.contains("RhiProcessResult::InputOrConfiguration"));
+ assert!(MAIN.contains("eprintln!(\"RHI command failed: {}\", result.code())"));
+ for forbidden in ["{error}", "{error:?}", "process::exit", "tokio::runtime"] {
+ assert!(
+ !MAIN.contains(forbidden),
+ "binary exposes forbidden process behavior {forbidden}"
+ );
+ }
+ assert!(
+ MANIFEST.contains(
+ "tokio = { version = \"1\", default-features = false, features = [\"time\"] }"
+ )
+ );
+ assert!(MANIFEST.contains(
+ "serde = { version = \"1\", default-features = false, features = [\"derive\"] }"
+ ));
}
#[test]
@@ -1035,7 +1123,7 @@ fn public_errors_are_crate_owned_redacted_and_source_free() {
.lines()
.filter(|line| line.starts_with("pub struct rhi::") && line.ends_with("Error"))
.count();
- assert_eq!(public_error_count, 35);
+ assert_eq!(public_error_count, 36);
}
#[test]
@@ -1530,6 +1618,10 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() {
"[`admin_domain.v1.json`](contracts/services_hardening/admin_domain.v1.json)",
"[`admin_identity_offline.v1.json`](contracts/services_hardening/admin_identity_offline.v1.json)",
"[`admin_wave_qualification.v1.json`](contracts/services_hardening/admin_wave_qualification.v1.json)",
+ "## Bounded active doctor and stable process results",
+ "governed fifteen checks in exact contract order",
+ "Required failure or timeout returns exit code `6`",
+ "no\ndetached probe work is permitted",
] {
assert!(README.contains(required), "README is missing {required}");
}
@@ -1558,6 +1650,7 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() {
"Build service-profile and application-handler presence only through the",
"Preserve the\n caller's sealed exact-byte capability",
"Recover an expired stale",
+ "Step 211 freezes the exact fifteen-check doctor inventory",
] {
assert!(AGENTS.contains(required), "AGENTS is missing {required}");
}
diff --git a/tests/services_hardening_doctor.rs b/tests/services_hardening_doctor.rs
@@ -0,0 +1,364 @@
+#![forbid(unsafe_code)]
+
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ future::pending,
+ process::Command,
+ sync::{
+ Arc, Mutex,
+ atomic::{AtomicBool, Ordering},
+ },
+};
+
+use rhi::{
+ RHI_DOCTOR_CHECK_COUNT, RHI_DOCTOR_CONTRACT_VERSION, RHI_DOCTOR_REPORT_MAX_UTF8_BYTES,
+ RHI_DOCTOR_SUMMARY_MAX_UTF8_BYTES, RadrootsHostEnvironment, RadrootsPathResolver,
+ RadrootsPlatform, RhiDoctorAggregateStatus, RhiDoctorCheckDefinition, RhiDoctorCheckId,
+ RhiDoctorCheckStatus, RhiDoctorFuture, RhiDoctorObservation, RhiDoctorProbe,
+ RhiDoctorRemediationCode, RhiProcessResult, parse_rhi_cli_v1_from, resolve_rhi_runtime_context,
+ rhi_doctor_check_definitions, run_rhi_doctor,
+};
+use sha2::{Digest, Sha256};
+
+const OPERATOR_CONTRACT: &str =
+ include_str!("../contracts/services_hardening/operator_contract.v1.json");
+
+struct PendingGuard(Arc<AtomicBool>);
+
+impl Drop for PendingGuard {
+ fn drop(&mut self) {
+ self.0.store(true, Ordering::SeqCst);
+ }
+}
+
+struct TestProbe {
+ outcomes: BTreeMap<RhiDoctorCheckId, RhiDoctorObservation>,
+ pending: Option<RhiDoctorCheckId>,
+ calls: Arc<Mutex<Vec<RhiDoctorCheckId>>>,
+ pending_dropped: Arc<AtomicBool>,
+}
+
+impl TestProbe {
+ fn all(outcome: RhiDoctorObservation) -> Self {
+ Self {
+ outcomes: rhi_doctor_check_definitions()
+ .iter()
+ .map(|definition| (definition.id(), outcome))
+ .collect(),
+ pending: None,
+ calls: Arc::new(Mutex::new(Vec::new())),
+ pending_dropped: Arc::new(AtomicBool::new(false)),
+ }
+ }
+
+ fn with(mut self, id: RhiDoctorCheckId, outcome: RhiDoctorObservation) -> Self {
+ self.outcomes.insert(id, outcome);
+ self
+ }
+
+ fn pending(mut self, id: RhiDoctorCheckId) -> Self {
+ self.pending = Some(id);
+ self
+ }
+}
+
+impl RhiDoctorProbe for TestProbe {
+ fn probe(&self, definition: RhiDoctorCheckDefinition) -> RhiDoctorFuture<'_> {
+ let id = definition.id();
+ self.calls.lock().expect("calls lock").push(id);
+ if self.pending == Some(id) {
+ let dropped = Arc::clone(&self.pending_dropped);
+ return Box::pin(async move {
+ let _guard = PendingGuard(dropped);
+ pending().await
+ });
+ }
+ let outcome = self.outcomes[&id];
+ Box::pin(async move { outcome })
+ }
+}
+
+fn runtime() -> (tempfile::TempDir, rhi::RhiRuntimeContext) {
+ let directory = tempfile::tempdir().expect("temporary root");
+ let invocation = parse_rhi_cli_v1_from([
+ "rhi",
+ "--profile",
+ "repo-local",
+ "--instance",
+ "primary",
+ "--repo-local-root",
+ directory.path().to_str().expect("UTF-8 path"),
+ "doctor",
+ ])
+ .expect("doctor invocation");
+ let context = resolve_rhi_runtime_context(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
+ &invocation,
+ )
+ .expect("runtime context");
+ (directory, context)
+}
+
+#[test]
+fn exact_inventory_and_exit_meanings_match_the_operator_contract() {
+ let contract: serde_json::Value =
+ serde_json::from_str(OPERATOR_CONTRACT).expect("operator contract");
+ let doctor = contract["doctor"].as_object().expect("doctor");
+ assert_eq!(
+ doctor.keys().map(String::as_str).collect::<BTreeSet<_>>(),
+ 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!(RHI_DOCTOR_CONTRACT_VERSION, 1);
+ assert_eq!(RHI_DOCTOR_CHECK_COUNT, 15);
+ assert_eq!(RHI_DOCTOR_SUMMARY_MAX_UTF8_BYTES, 256);
+ assert_eq!(RHI_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(), RHI_DOCTOR_CHECK_COUNT);
+ for (definition, row) in rhi_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")
+ );
+ }
+
+ let process_results = [
+ RhiProcessResult::Success,
+ RhiProcessResult::UnexpectedInternal,
+ RhiProcessResult::InputOrConfiguration,
+ RhiProcessResult::ServiceOrDependencyUnavailable,
+ RhiProcessResult::StateOrIdentityUnavailable,
+ RhiProcessResult::OperationRejectedOrConflict,
+ RhiProcessResult::DoctorRequiredCheckFailed,
+ ];
+ for (result, row) in process_results
+ .into_iter()
+ .zip(contract["exit_codes"].as_array().expect("exit codes"))
+ {
+ assert_eq!(row["code"], result.exit_code_u8());
+ assert_eq!(row["name"], result.code());
+ }
+}
+
+#[tokio::test]
+async fn all_pass_is_canonical_bounded_and_exit_zero() {
+ let (_directory, context) = runtime();
+ let probe = TestProbe::all(RhiDoctorObservation::Pass);
+ let report = run_rhi_doctor(&context, &probe).await.expect("report");
+ assert_eq!(report.service(), "rhi");
+ assert_eq!(report.instance().as_str(), "primary");
+ assert_eq!(report.status(), RhiDoctorAggregateStatus::Pass);
+ assert_eq!(report.exit_code(), 0);
+ assert_eq!(report.checks().len(), RHI_DOCTOR_CHECK_COUNT);
+ assert!(
+ report
+ .checks()
+ .iter()
+ .all(|result| result.status() == RhiDoctorCheckStatus::Pass)
+ );
+ assert_eq!(
+ probe.calls.lock().expect("calls").len(),
+ RHI_DOCTOR_CHECK_COUNT
+ );
+
+ let bytes = report.canonical_json();
+ assert!(bytes.len() <= RHI_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"], "rhi");
+ assert_eq!(wire["instance"], "primary");
+ assert_eq!(wire["status"], "pass");
+ assert_eq!(wire["checks"].as_array().expect("checks").len(), 15);
+ assert!(String::from_utf8_lossy(bytes).starts_with(
+ "{\"contract_version\":1,\"service\":\"rhi\",\"instance\":\"primary\",\"status\":\"pass\",\"checks\":["
+ ));
+ assert_eq!(
+ sha256_hex(bytes),
+ "5aabfc84927e36bb877c289f8d0ed2be8f4c3115deedad7def3f3e56daab399b"
+ );
+}
+
+#[tokio::test]
+async fn optional_nonpass_is_degraded_while_required_nonpass_fails() {
+ let (_directory, context) = runtime();
+ for outcome in [RhiDoctorObservation::Fail, RhiDoctorObservation::Skipped] {
+ let optional =
+ TestProbe::all(RhiDoctorObservation::Pass).with(RhiDoctorCheckId::ClockSkew, outcome);
+ let report = run_rhi_doctor(&context, &optional)
+ .await
+ .expect("optional report");
+ assert_eq!(report.status(), RhiDoctorAggregateStatus::Degraded);
+ assert_eq!(report.exit_code(), 0);
+ assert_ne!(report.checks()[14].status(), RhiDoctorCheckStatus::Pass);
+
+ let required =
+ TestProbe::all(RhiDoctorObservation::Pass).with(RhiDoctorCheckId::WriterLock, outcome);
+ let report = run_rhi_doctor(&context, &required)
+ .await
+ .expect("required report");
+ assert_eq!(report.status(), RhiDoctorAggregateStatus::Fail);
+ assert_eq!(report.exit_code(), 6);
+ assert_eq!(report.checks()[1].status(), RhiDoctorCheckStatus::Fail);
+ }
+}
+
+#[tokio::test]
+async fn required_timeout_drops_work_and_remaining_checks_continue_in_order() {
+ let (_directory, context) = runtime();
+ let probe =
+ TestProbe::all(RhiDoctorObservation::Pass).pending(RhiDoctorCheckId::PathsPermissions);
+ let report = run_rhi_doctor(&context, &probe).await.expect("report");
+ assert_eq!(report.status(), RhiDoctorAggregateStatus::Fail);
+ assert_eq!(report.exit_code(), 6);
+ assert_eq!(report.checks()[0].status(), RhiDoctorCheckStatus::Timeout);
+ assert!(probe.pending_dropped.load(Ordering::SeqCst));
+ assert_eq!(
+ *probe.calls.lock().expect("calls"),
+ rhi_doctor_check_definitions()
+ .iter()
+ .map(|definition| definition.id())
+ .collect::<Vec<_>>()
+ );
+}
+
+#[tokio::test]
+async fn report_debug_and_public_errors_retain_no_sensitive_values() {
+ let directory = tempfile::tempdir().expect("temporary root");
+ let root = directory.path().join("secret-root");
+ let invocation = parse_rhi_cli_v1_from([
+ "rhi",
+ "--profile",
+ "repo-local",
+ "--instance",
+ "secret-instance",
+ "--repo-local-root",
+ root.to_str().expect("UTF-8 path"),
+ "doctor",
+ ])
+ .expect("doctor invocation");
+ let context = resolve_rhi_runtime_context(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
+ &invocation,
+ )
+ .expect("runtime context");
+ let report = run_rhi_doctor(&context, &TestProbe::all(RhiDoctorObservation::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() <= RHI_DOCTOR_SUMMARY_MAX_UTF8_BYTES);
+ }
+ let rendered = format!("{:?}", rhi::RhiDoctorErrorKind::OutputTooLarge);
+ assert!(!rendered.contains("secret"));
+}
+
+#[test]
+fn binary_uses_stable_safe_nonzero_results() {
+ let canary = "secret-canary-private-key-path-sql-relay-url";
+ let invalid = Command::new(env!("CARGO_BIN_EXE_rhi"))
+ .arg(format!("--credential={canary}"))
+ .output()
+ .expect("invalid invocation");
+ assert_eq!(invalid.status.code(), Some(2));
+ assert!(invalid.stdout.is_empty());
+ let stderr = String::from_utf8(invalid.stderr).expect("invalid stderr");
+ assert_eq!(stderr, "RHI command failed: input_or_configuration\n");
+ assert!(!stderr.contains(canary));
+
+ let repo_local = tempfile::tempdir().expect("repo-local root");
+ let admitted = Command::new(env!("CARGO_BIN_EXE_rhi"))
+ .args(["--profile", "repo-local", "--instance", "primary"])
+ .arg("--repo-local-root")
+ .arg(repo_local.path())
+ .arg("run")
+ .output()
+ .expect("admitted invocation");
+ assert_eq!(admitted.status.code(), Some(2));
+ assert!(admitted.stdout.is_empty());
+ assert_eq!(
+ String::from_utf8(admitted.stderr).expect("admitted stderr"),
+ "RHI command failed: input_or_configuration\n"
+ );
+}
+
+fn sha256_hex(bytes: &[u8]) -> String {
+ Sha256::digest(bytes)
+ .iter()
+ .map(|byte| format!("{byte:02x}"))
+ .collect()
+}
+
+fn id_name(id: RhiDoctorCheckId) -> &'static str {
+ match id {
+ RhiDoctorCheckId::PathsPermissions => "paths_permissions",
+ RhiDoctorCheckId::WriterLock => "writer_lock",
+ RhiDoctorCheckId::SqliteSchema => "sqlite_schema",
+ RhiDoctorCheckId::SqliteIntegrity => "sqlite_integrity",
+ RhiDoctorCheckId::SqliteFreeSpace => "sqlite_free_space",
+ RhiDoctorCheckId::IdentityBinding => "identity_binding",
+ RhiDoctorCheckId::AdminBindPolicy => "admin_bind_policy",
+ RhiDoctorCheckId::OperationsBindPolicy => "operations_bind_policy",
+ RhiDoctorCheckId::NetworkPolicy => "network_policy",
+ RhiDoctorCheckId::RequiredSources => "required_sources",
+ RhiDoctorCheckId::CursorCheckpoint => "cursor_checkpoint",
+ RhiDoctorCheckId::ReconciliationLeases => "reconciliation_leases",
+ RhiDoctorCheckId::ReconciliationBacklog => "reconciliation_backlog",
+ RhiDoctorCheckId::PublicationInvariants => "publication_invariants",
+ RhiDoctorCheckId::ClockSkew => "clock_skew",
+ }
+}
+
+fn remediation_name(code: RhiDoctorRemediationCode) -> &'static str {
+ match code {
+ RhiDoctorRemediationCode::CorrectPathPolicy => "correct_path_policy",
+ RhiDoctorRemediationCode::ReleaseWriterLock => "release_writer_lock",
+ RhiDoctorRemediationCode::RepairSchema => "repair_schema",
+ RhiDoctorRemediationCode::RestoreVerifiedState => "restore_verified_state",
+ RhiDoctorRemediationCode::FreeStateDiskSpace => "free_state_disk_space",
+ RhiDoctorRemediationCode::RestoreIdentityBinding => "restore_identity_binding",
+ RhiDoctorRemediationCode::CorrectAdminBindPolicy => "correct_admin_bind_policy",
+ RhiDoctorRemediationCode::CorrectOperationsBindPolicy => "correct_operations_bind_policy",
+ RhiDoctorRemediationCode::CorrectNetworkPolicy => "correct_network_policy",
+ RhiDoctorRemediationCode::RestoreRequiredSources => "restore_required_sources",
+ RhiDoctorRemediationCode::RepairCursorCheckpoint => "repair_cursor_checkpoint",
+ RhiDoctorRemediationCode::RepairReconciliationLeases => "repair_reconciliation_leases",
+ RhiDoctorRemediationCode::ReduceReconciliationBacklog => "reduce_reconciliation_backlog",
+ RhiDoctorRemediationCode::RepairPublicationState => "repair_publication_state",
+ RhiDoctorRemediationCode::CorrectClock => "correct_clock",
+ }
+}
diff --git a/tests/services_hardening_operator_contract.rs b/tests/services_hardening_operator_contract.rs
@@ -309,28 +309,18 @@ fn admin_inventory_is_closed_unique_and_model_complete() {
fn doctor_exit_and_tcp_contracts_are_exact() {
let value = contract();
assert_eq!(
- value["doctor"],
- serde_json::json!({
- "shared_schema": "radroots.service.doctor.v1",
- "contract_version": 1,
- "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": "admin_bind_policy", "required": true },
- { "id": "operations_bind_policy", "required": true },
- { "id": "network_policy", "required": true },
- { "id": "required_sources", "required": true },
- { "id": "cursor_checkpoint", "required": true },
- { "id": "reconciliation_leases", "required": true },
- { "id": "reconciliation_backlog", "required": true },
- { "id": "publication_invariants", "required": true },
- { "id": "clock_skew", "required": false }
- ]
- })
+ value["doctor"]["shared_schema"],
+ "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"]["checks"]
+ .as_array()
+ .expect("doctor checks")
+ .len(),
+ 15
);
assert_eq!(
value["exit_codes"],