myc

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

commit b20797d61766228fcf51bb06b141231592d5369f
parent 6661f7ef67d3a97b337a038bf6b80a64b369705f
Author: triesap <tyson@radroots.org>
Date:   Sat, 22 Aug 2026 06:45:19 +0000

diagnostics: freeze structured stderr and exits

Diffstat:
MAGENTS.md | 5+++++
MREADME | 11+++++++++++
Mcontracts/api_baselines/myc.txt | 45+++++++++++++++++++++++++++++++++++++++++++++
Acontracts/services_hardening/diagnostics.v1.json | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/diagnostics_v1.rs | 236+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 5+++++
Msrc/main.rs | 14+++++++++-----
Mtests/package_boundary.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/services_hardening_diagnostics.rs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 573 insertions(+), 5 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -248,6 +248,11 @@ - Keep logs as safe structured stderr output. Keep result data on stdout and diagnostics on stderr. Use stable bounded public codes and messages, bounded metric labels, and explicit redaction. +- Emit only the sealed `MycLogRecord` vocabulary. Do not log caller text, raw + errors or sources, paths, SQL, relay URLs, identifiers, credentials, + protected content, or decrypted payloads. Keep the exact 0-6 process result + mapping synchronized with the operator contract; do not call process exit + from library code or add file logging. - Backup and restore must preserve lock, manifest, integrity, schema, service, instance, identity, permission, fsync, atomic-rename, and protected-material exclusion invariants. diff --git a/README b/README @@ -108,6 +108,17 @@ disabled unless the validated configuration explicitly enables and binds it, and its parser floor, headers, response, concurrency, deadline, and idle bounds remain enforced by the shared server. +Process outcomes use one exact seven-value vocabulary with exit codes 0 +through 6. Diagnostics are compact single-line JSON on stderr; result data is +reserved for stdout, and Myc never writes log files. The structured record +admits only the closed level, event, result, lifecycle, task-failure, and signal +codes. It accepts no caller text, path, SQL, raw cause, identity, relay URL, +credential, secret, or decrypted content. Every public Myc error remains a +crate-owned source-free classification, so ordinary `Display`, `Debug`, and +whole-chain traversal cannot bypass redaction. Current binary dispatch reports +invalid input as exit 2 and the intentionally unavailable later executor as +exit 3; Steps 158 and 159 own real daemon/task/signal execution. + `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 @@ -395,6 +395,22 @@ pub myc::MycLocalSignerTransportErrorKind::Transport pub myc::MycLocalSignerTransportErrorKind::UnsupportedPlatform impl myc::MycLocalSignerTransportErrorKind pub const fn myc::MycLocalSignerTransportErrorKind::code(self) -> &'static str +pub enum myc::MycLogEvent +pub myc::MycLogEvent::CriticalTaskFailed +pub myc::MycLogEvent::Lifecycle +pub myc::MycLogEvent::ProcessResult +pub myc::MycLogEvent::ShutdownForced +pub myc::MycLogEvent::ShutdownRequested +impl myc::MycLogEvent +pub const fn myc::MycLogEvent::as_str(self) -> &'static str +pub enum myc::MycLogLevel +pub myc::MycLogLevel::Debug +pub myc::MycLogLevel::Error +pub myc::MycLogLevel::Info +pub myc::MycLogLevel::Trace +pub myc::MycLogLevel::Warn +impl myc::MycLogLevel +pub const fn myc::MycLogLevel::as_str(self) -> &'static str pub enum myc::MycNip05ExportSelection pub myc::MycNip05ExportSelection::Current pub myc::MycNip05ExportSelection::Desired @@ -499,6 +515,18 @@ pub myc::MycPersistenceHealthV1::ReadOnly pub myc::MycPersistenceHealthV1::Ready pub myc::MycPersistenceHealthV1::RepairRequired pub myc::MycPersistenceHealthV1::Unavailable +pub enum myc::MycProcessResult +pub myc::MycProcessResult::DoctorRequiredCheckFailed +pub myc::MycProcessResult::InputOrConfiguration +pub myc::MycProcessResult::OperationRejectedOrConflict +pub myc::MycProcessResult::ServiceOrDependencyUnavailable +pub myc::MycProcessResult::StateOrIdentityUnavailable +pub myc::MycProcessResult::Success +pub myc::MycProcessResult::UnexpectedInternal +impl myc::MycProcessResult +pub const fn myc::MycProcessResult::code(self) -> &'static str +pub fn myc::MycProcessResult::exit_code(self) -> std::process::ExitCode +pub const fn myc::MycProcessResult::exit_code_u8(self) -> u8 pub enum myc::MycProviderCapability pub myc::MycProviderCapability::Describe pub myc::MycProviderCapability::Nip04Decrypt @@ -1249,6 +1277,21 @@ impl myc::MycLocalSignerUntrustedResponse pub fn myc::MycLocalSignerUntrustedResponse::verify(self, &myc::MycProviderBinding, &myc::MycProviderOperation, myc::MycProviderResponseObservedAtUnixMs) -> core::result::Result<myc::MycVerifiedProviderResponse, myc::MycProviderVerificationError> impl core::fmt::Debug for myc::MycLocalSignerUntrustedResponse pub fn myc::MycLocalSignerUntrustedResponse::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycLogRecord +impl myc::MycLogRecord +pub const fn myc::MycLogRecord::code(&self) -> &'static str +pub const fn myc::MycLogRecord::critical_task_failed() -> Self +pub const fn myc::MycLogRecord::event(&self) -> myc::MycLogEvent +pub const fn myc::MycLogRecord::level(&self) -> myc::MycLogLevel +pub const fn myc::MycLogRecord::lifecycle(myc::MycServicePhase) -> Self +pub const fn myc::MycLogRecord::process_exit(&self) -> core::option::Option<myc::MycProcessResult> +pub const fn myc::MycLogRecord::process_result(myc::MycProcessResult) -> Self +pub const fn myc::MycLogRecord::shutdown_forced() -> Self +pub const fn myc::MycLogRecord::shutdown_requested() -> Self +impl core::fmt::Debug for myc::MycLogRecord +pub fn myc::MycLogRecord::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for myc::MycLogRecord +pub fn myc::MycLogRecord::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycNip05Document impl myc::MycNip05Document pub fn myc::MycNip05Document::bytes(&self) -> &[u8] @@ -1926,6 +1969,7 @@ pub const myc::MYC_DELIVERY_RELAY_ID_MAX_BYTES: usize pub const myc::MYC_DELIVERY_RETRY_JITTER_MAX_MS: u64 pub const myc::MYC_DELIVERY_TARGET_MAX_COUNT: usize pub const myc::MYC_DETAILED_STATUS_MAX_UTF8_BYTES: usize +pub const myc::MYC_DIAGNOSTICS_CONTRACT_VERSION: u32 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 @@ -1937,6 +1981,7 @@ pub const myc::MYC_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES: usize pub const myc::MYC_LIVEZ_PATH: &str pub const myc::MYC_LOCAL_SIGNER_ENDPOINT: &str pub const myc::MYC_LOCAL_SIGNER_TRANSPORT_CONTRACT_VERSION: u32 +pub const myc::MYC_LOG_RECORD_MAX_UTF8_BYTES: usize pub const myc::MYC_METRICS_PATH: &str pub const myc::MYC_MIGRATION_CATALOG_SHA256: [u8; 32] pub const myc::MYC_NIP05_DOCUMENT_MAX_BYTES: usize diff --git a/contracts/services_hardening/diagnostics.v1.json b/contracts/services_hardening/diagnostics.v1.json @@ -0,0 +1,61 @@ +{ + "schema": "radroots.myc.diagnostics.v1", + "contract_version": 1, + "step": 156, + "stream_policy": { + "result_data": "stdout", + "logs_and_diagnostics": "stderr", + "file_logging": false + }, + "log_record": { + "schema": "radroots.myc.log.v1", + "maximum_utf8_bytes_excluding_newline": 512, + "field_order": ["schema", "contract_version", "service", "level", "event", "code", "exit_code"], + "service": "myc", + "levels": ["trace", "debug", "info", "warn", "error"], + "events": ["process_result", "lifecycle", "critical_task_failed", "shutdown_requested", "shutdown_forced"], + "caller_controlled_text": false, + "timestamp": "not_owned_by_this_record", + "newline": "stderr_writer_owned" + }, + "exit_codes": [ + { "code": 0, "name": "success" }, + { "code": 1, "name": "unexpected_internal" }, + { "code": 2, "name": "input_or_configuration" }, + { "code": 3, "name": "service_or_dependency_unavailable" }, + { "code": 4, "name": "state_or_identity_unavailable" }, + { "code": 5, "name": "operation_rejected_or_conflict" }, + { "code": 6, "name": "doctor_required_check_failed" } + ], + "public_error_policy": { + "crate_owned_classification": true, + "error_source": "none", + "display_and_debug": "path_secret_content_and_raw_cause_free" + }, + "forbidden_fields": [ + "path", + "sql", + "raw_error", + "source", + "credential", + "secret", + "private_key", + "decrypted_payload", + "relay_url", + "public_key", + "event_id", + "request_id", + "connection_id" + ], + "deferrals": [ + "command_execution", + "task_graph", + "signal_installation", + "shutdown_execution", + "integration_wave", + "rcld_promotion", + "nix", + "oci", + "terminal_consumer_convergence" + ] +} diff --git a/src/diagnostics_v1.rs b/src/diagnostics_v1.rs @@ -0,0 +1,236 @@ +//! Closed process-result and structured stderr diagnostic contract. + +use core::fmt; +use std::process::ExitCode; + +use crate::MycServicePhase; + +/// Exact Myc diagnostics contract version. +pub const MYC_DIAGNOSTICS_CONTRACT_VERSION: u32 = 1; + +/// Hard maximum for one canonical Myc structured log record, excluding newline. +pub const MYC_LOG_RECORD_MAX_UTF8_BYTES: usize = 512; + +const LOG_SCHEMA: &str = "radroots.myc.log.v1"; + +/// Exact stable Myc process result and exit-code inventory. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycProcessResult { + Success, + UnexpectedInternal, + InputOrConfiguration, + ServiceOrDependencyUnavailable, + StateOrIdentityUnavailable, + OperationRejectedOrConflict, + DoctorRequiredCheckFailed, +} + +impl MycProcessResult { + #[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, + } + } + + #[must_use] + pub fn exit_code(self) -> ExitCode { + ExitCode::from(self.exit_code_u8()) + } + + #[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", + } + } +} + +/// Closed structured-log severity vocabulary admitted by Myc. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycLogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl MycLogLevel { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Trace => "trace", + Self::Debug => "debug", + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + } + } +} + +/// Closed structured-log event vocabulary required by the current runtime plan. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycLogEvent { + ProcessResult, + Lifecycle, + CriticalTaskFailed, + ShutdownRequested, + ShutdownForced, +} + +impl MycLogEvent { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ProcessResult => "process_result", + Self::Lifecycle => "lifecycle", + Self::CriticalTaskFailed => "critical_task_failed", + Self::ShutdownRequested => "shutdown_requested", + Self::ShutdownForced => "shutdown_forced", + } + } +} + +/// One sealed canonical structured log record containing only governed values. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycLogRecord { + level: MycLogLevel, + event: MycLogEvent, + code: &'static str, + process_result: Option<MycProcessResult>, +} + +impl MycLogRecord { + /// Builds the exact terminal record for one governed process result. + #[must_use] + pub const fn process_result(result: MycProcessResult) -> Self { + let level = match result { + MycProcessResult::Success => MycLogLevel::Info, + MycProcessResult::OperationRejectedOrConflict => MycLogLevel::Warn, + MycProcessResult::UnexpectedInternal + | MycProcessResult::InputOrConfiguration + | MycProcessResult::ServiceOrDependencyUnavailable + | MycProcessResult::StateOrIdentityUnavailable + | MycProcessResult::DoctorRequiredCheckFailed => MycLogLevel::Error, + }; + Self { + level, + event: MycLogEvent::ProcessResult, + code: result.code(), + process_result: Some(result), + } + } + + /// Builds the exact phase record from an already-published lifecycle value. + #[must_use] + pub const fn lifecycle(phase: MycServicePhase) -> Self { + let (level, code) = match phase { + MycServicePhase::Starting => (MycLogLevel::Info, "starting"), + MycServicePhase::Ready => (MycLogLevel::Info, "ready"), + MycServicePhase::Degraded => (MycLogLevel::Warn, "degraded"), + MycServicePhase::Unready => (MycLogLevel::Warn, "unready"), + MycServicePhase::Stopping => (MycLogLevel::Info, "stopping"), + MycServicePhase::Failed => (MycLogLevel::Error, "failed"), + }; + Self { + level, + event: MycLogEvent::Lifecycle, + code, + process_result: None, + } + } + + #[must_use] + pub const fn critical_task_failed() -> Self { + Self { + level: MycLogLevel::Error, + event: MycLogEvent::CriticalTaskFailed, + code: "critical_task_failed", + process_result: None, + } + } + + #[must_use] + pub const fn shutdown_requested() -> Self { + Self { + level: MycLogLevel::Info, + event: MycLogEvent::ShutdownRequested, + code: "first_signal", + process_result: None, + } + } + + #[must_use] + pub const fn shutdown_forced() -> Self { + Self { + level: MycLogLevel::Error, + event: MycLogEvent::ShutdownForced, + code: "second_signal", + process_result: None, + } + } + + #[must_use] + pub const fn level(&self) -> MycLogLevel { + self.level + } + + #[must_use] + pub const fn event(&self) -> MycLogEvent { + self.event + } + + #[must_use] + pub const fn code(&self) -> &'static str { + self.code + } + + #[must_use] + pub const fn process_exit(&self) -> Option<MycProcessResult> { + self.process_result + } +} + +impl fmt::Debug for MycLogRecord { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycLogRecord") + .field("level", &self.level) + .field("event", &self.event) + .field("code", &self.code) + .field( + "exit_code", + &self.process_result.map(MycProcessResult::exit_code_u8), + ) + .finish() + } +} + +impl fmt::Display for MycLogRecord { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{{\"schema\":\"{LOG_SCHEMA}\",\"contract_version\":{MYC_DIAGNOSTICS_CONTRACT_VERSION},\"service\":\"myc\",\"level\":\"{}\",\"event\":\"{}\",\"code\":\"{}\"", + self.level.as_str(), + self.event.as_str(), + self.code, + )?; + if let Some(result) = self.process_result { + write!(formatter, ",\"exit_code\":{}", result.exit_code_u8())?; + } + formatter.write_str("}") + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -5,6 +5,7 @@ mod admin_v1; mod cli_v1; mod config_v1; +mod diagnostics_v1; mod doctor_v1; mod nip46_admission; mod nip46_authorization; @@ -56,6 +57,10 @@ pub use config_v1::{ MycConfigDocumentV1, MycConfigProfile, MycConfigV1Error, MycConfigV1ErrorKind, MycConfigValueSource, MycEffectiveConfigV1, parse_myc_config_v1, }; +pub use diagnostics_v1::{ + MYC_DIAGNOSTICS_CONTRACT_VERSION, MYC_LOG_RECORD_MAX_UTF8_BYTES, MycLogEvent, MycLogLevel, + MycLogRecord, MycProcessResult, +}; 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, diff --git a/src/main.rs b/src/main.rs @@ -2,16 +2,20 @@ use std::process::ExitCode; +use myc::{MycLogRecord, MycProcessResult}; + fn main() -> ExitCode { match myc::parse_myc_cli_v1_from(std::env::args_os()) { Ok(invocation) => { let _plan = myc::plan_myc_cli_v1(&invocation); - eprintln!("myc: command execution is unavailable"); - ExitCode::FAILURE + let result = MycProcessResult::ServiceOrDependencyUnavailable; + eprintln!("{}", MycLogRecord::process_result(result)); + result.exit_code() } - Err(error) => { - eprintln!("myc: {error}"); - ExitCode::from(2) + Err(_) => { + let result = MycProcessResult::InputOrConfiguration; + eprintln!("{}", MycLogRecord::process_result(result)); + result.exit_code() } } } diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -15,6 +15,10 @@ 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 DIAGNOSTICS_V1: &str = include_str!("../src/diagnostics_v1.rs"); +const DIAGNOSTICS_CONTRACT: &str = + include_str!("../contracts/services_hardening/diagnostics.v1.json"); +const MAIN: &str = include_str!("../src/main.rs"); const STATUS_V1: &str = include_str!("../src/status_v1.rs"); const STATUS_CONTRACT: &str = include_str!("../contracts/services_hardening/status_cache.v1.json"); const OPERATIONS_V1: &str = include_str!("../src/operations_v1.rs"); @@ -42,6 +46,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/cli_v1.rs"), include_str!("../src/config_v1.rs"), include_str!("../src/doctor_v1.rs"), + include_str!("../src/diagnostics_v1.rs"), include_str!("../src/nip46_admission.rs"), include_str!("../src/nip46_authorization.rs"), include_str!("../src/nip46_replay.rs"), @@ -83,6 +88,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "cli_v1", "config_v1", "doctor_v1", + "diagnostics_v1", "nip46_admission", "nip46_authorization", "nip46_replay", @@ -138,6 +144,10 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "pub enum myc::MycCliAdminOperationV1", "pub const fn myc::plan_myc_cli_v1", "pub struct myc::MycDoctorReport", + "pub struct myc::MycLogRecord", + "pub enum myc::MycLogEvent", + "pub enum myc::MycLogLevel", + "pub enum myc::MycProcessResult", "pub struct myc::MycDoctorCheckDefinition", "pub struct myc::MycDoctorCheckResult", "pub enum myc::MycDoctorCheckId", @@ -231,6 +241,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "cli_v1", "config_v1", "doctor_v1", + "diagnostics_v1", "nip46_admission", "nip46_authorization", "nip46_replay", @@ -421,6 +432,52 @@ fn doctor_boundary_is_closed_bounded_and_dependency_neutral() { } #[test] +fn step156_diagnostics_are_closed_stderr_only_and_whole_chain_redacted() { + let contract: serde_json::Value = + serde_json::from_str(DIAGNOSTICS_CONTRACT).expect("Step 156 diagnostics contract"); + assert_eq!(contract["schema"], "radroots.myc.diagnostics.v1"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["step"], 156); + assert_eq!(contract["stream_policy"]["result_data"], "stdout"); + assert_eq!(contract["stream_policy"]["logs_and_diagnostics"], "stderr"); + assert_eq!(contract["public_error_policy"]["error_source"], "none"); + for required in [ + "MYC_LOG_RECORD_MAX_UTF8_BYTES: usize = 512", + "Self::Success => 0", + "Self::DoctorRequiredCheckFailed => 6", + "formatter.write_str(\"}\")", + "MycLogRecord::process_result(result)", + ] { + assert!( + DIAGNOSTICS_V1.contains(required) || MAIN.contains(required), + "Step 156 implementation is missing `{required}`" + ); + } + assert!(MAIN.contains("eprintln!(\"{}\", MycLogRecord::process_result(result))")); + for forbidden in [ + "process::exit", + "{error}", + "source()", + "raw_error", + "std::fs::", + "sqlx::", + "SystemTime", + ] { + assert!( + !DIAGNOSTICS_V1.contains(forbidden) && !MAIN.contains(forbidden), + "Step 156 diagnostic boundary gained `{forbidden}`" + ); + } + assert!( + !MAIN + .lines() + .any(|line| line.trim_start().starts_with("println!(")) + ); + assert!(!SOURCES.join("\n").contains("fn source(")); + assert!(!PUBLIC_API.contains("std::io::Error")); +} + +#[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"); diff --git a/tests/services_hardening_diagnostics.rs b/tests/services_hardening_diagnostics.rs @@ -0,0 +1,144 @@ +#![forbid(unsafe_code)] + +use std::process::Command; + +use myc::{ + MYC_DIAGNOSTICS_CONTRACT_VERSION, MYC_LOG_RECORD_MAX_UTF8_BYTES, MycLogEvent, MycLogLevel, + MycLogRecord, MycProcessResult, MycServicePhase, +}; + +const CONTRACT: &str = include_str!("../contracts/services_hardening/diagnostics.v1.json"); +const OPERATOR_CONTRACT: &str = + include_str!("../contracts/services_hardening/operator_contract.v1.json"); + +fn process_results() -> [MycProcessResult; 7] { + [ + MycProcessResult::Success, + MycProcessResult::UnexpectedInternal, + MycProcessResult::InputOrConfiguration, + MycProcessResult::ServiceOrDependencyUnavailable, + MycProcessResult::StateOrIdentityUnavailable, + MycProcessResult::OperationRejectedOrConflict, + MycProcessResult::DoctorRequiredCheckFailed, + ] +} + +#[test] +fn machine_contract_exit_inventory_matches_the_operator_contract_exactly() { + let contract: serde_json::Value = serde_json::from_str(CONTRACT).expect("contract"); + let operator: serde_json::Value = + serde_json::from_str(OPERATOR_CONTRACT).expect("operator contract"); + assert_eq!(contract["schema"], "radroots.myc.diagnostics.v1"); + assert_eq!( + contract["contract_version"], + MYC_DIAGNOSTICS_CONTRACT_VERSION + ); + assert_eq!(contract["step"], 156); + assert_eq!( + contract["log_record"]["maximum_utf8_bytes_excluding_newline"], + MYC_LOG_RECORD_MAX_UTF8_BYTES + ); + + let diagnostics = contract["exit_codes"].as_array().expect("exit codes"); + let operator = operator["exit_codes"].as_array().expect("operator exits"); + assert_eq!(diagnostics.len(), operator.len()); + for ((result, diagnostic), operator) in + process_results().into_iter().zip(diagnostics).zip(operator) + { + assert_eq!(diagnostic["code"], result.exit_code_u8()); + assert_eq!(diagnostic["name"], result.code()); + assert_eq!(diagnostic["code"], operator["code"]); + assert_eq!(diagnostic["name"], operator["name"]); + } + assert_eq!(contract["public_error_policy"]["error_source"], "none"); + assert_eq!(contract["stream_policy"]["logs_and_diagnostics"], "stderr"); + assert_eq!(contract["stream_policy"]["file_logging"], false); +} + +#[test] +fn process_lifecycle_task_and_signal_records_are_exact_and_bounded() { + for result in process_results() { + let record = MycLogRecord::process_result(result); + let rendered = record.to_string(); + assert_eq!(record.event(), MycLogEvent::ProcessResult); + assert_eq!(record.code(), result.code()); + assert_eq!(record.process_exit(), Some(result)); + assert!(rendered.len() <= MYC_LOG_RECORD_MAX_UTF8_BYTES); + assert_eq!( + serde_json::from_str::<serde_json::Value>(&rendered).expect("record")["exit_code"], + result.exit_code_u8() + ); + } + + for (phase, level, code) in [ + (MycServicePhase::Starting, MycLogLevel::Info, "starting"), + (MycServicePhase::Ready, MycLogLevel::Info, "ready"), + (MycServicePhase::Degraded, MycLogLevel::Warn, "degraded"), + (MycServicePhase::Unready, MycLogLevel::Warn, "unready"), + (MycServicePhase::Stopping, MycLogLevel::Info, "stopping"), + (MycServicePhase::Failed, MycLogLevel::Error, "failed"), + ] { + let record = MycLogRecord::lifecycle(phase); + assert_eq!(record.level(), level); + assert_eq!(record.event(), MycLogEvent::Lifecycle); + assert_eq!(record.code(), code); + assert_eq!(record.process_exit(), None); + assert!(!record.to_string().contains("exit_code")); + } + + for (record, event, code) in [ + ( + MycLogRecord::critical_task_failed(), + MycLogEvent::CriticalTaskFailed, + "critical_task_failed", + ), + ( + MycLogRecord::shutdown_requested(), + MycLogEvent::ShutdownRequested, + "first_signal", + ), + ( + MycLogRecord::shutdown_forced(), + MycLogEvent::ShutdownForced, + "second_signal", + ), + ] { + assert_eq!(record.event(), event); + assert_eq!(record.code(), code); + assert!(record.to_string().len() <= MYC_LOG_RECORD_MAX_UTF8_BYTES); + } +} + +#[test] +fn binary_writes_only_fixed_json_diagnostics_to_stderr() { + let canary = "secret-canary-private-key-path-sql-relay-url"; + let invalid = Command::new(env!("CARGO_BIN_EXE_myc")) + .arg(format!("--credential={canary}")) + .output() + .expect("invalid invocation"); + assert_eq!(invalid.status.code(), Some(2)); + assert!(invalid.stdout.is_empty()); + let invalid_stderr = String::from_utf8(invalid.stderr).expect("invalid stderr"); + assert_eq!( + invalid_stderr, + format!( + "{}\n", + MycLogRecord::process_result(MycProcessResult::InputOrConfiguration) + ) + ); + assert!(!invalid_stderr.contains(canary)); + + let unavailable = Command::new(env!("CARGO_BIN_EXE_myc")) + .args(["--profile", "service-host", "--instance", "primary", "run"]) + .output() + .expect("admitted invocation"); + assert_eq!(unavailable.status.code(), Some(3)); + assert!(unavailable.stdout.is_empty()); + assert_eq!( + String::from_utf8(unavailable.stderr).expect("unavailable stderr"), + format!( + "{}\n", + MycLogRecord::process_result(MycProcessResult::ServiceOrDependencyUnavailable) + ) + ); +}