myc

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

commit 77b381648ed1e586efb696888beb05b9215c69cf
parent 6d1e1ea218ad4d5a5213bedc88972f1d156bd6e0
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 17:37:14 +0000

test: qualify Myc process resilience

- freeze the exact Step 161 executable and component evidence matrix
- exercise bounded process saturation, outage, and reopen soak behavior
- prove backup and restore SIGKILL boundaries fail closed and recover
- keep failpoints private and defer promotion, Nix, OCI, and publication

Diffstat:
MAGENTS.md | 9+++++++++
MREADME | 21+++++++++++++++++++++
Acontracts/services_hardening/process_qualification.v1.json | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/package_boundary.rs | 33+++++++++++++++++++++++++++++++++
Atests/services_hardening_process_qualification.rs | 462+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 604 insertions(+), 0 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -194,6 +194,15 @@ root, arbitrary member name, Nix/NixOS/OCI input or output, signing key, parent-owned human document, private harness, protected material, or publication/deployment authority. +- Step 161 owns the closed executable qualification matrix in + `contracts/services_hardening/process_qualification.v1.json`. Keep its + process deadlines, parallelism, soak count, crash fixture, output bound, + actual-process corpus, and component evidence exact. Crash injection is an + external test-process termination only; do not add a production failpoint, + hidden command, environment selector, detached test worker, or public test + API. Failed backup and restore work must retain collision/recovery evidence, + preserve the live database, and recover only through the governed next + process open. - 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 @@ -483,6 +483,27 @@ restore authority. Cancellation, explicit close, and cleanup behavior remain owned by the source-locked shared SQLx host; Myc maps every public failure to a stable source-free classification. +## Executable qualification + +The Step 161 process qualification is frozen by +`contracts/services_hardening/process_qualification.v1.json`. It runs the +actual `myc` executable under fixed deadlines, bounded captured output, eight +concurrent inspection processes, 32 deterministic reopen iterations, and one +64 MiB crash fixture. External process termination exercises interrupted +backup and pre-marker restore boundaries without adding a production +failpoint, hidden command, environment selector, feature, or detached test +worker. An interrupted backup preserves the live database and leaves explicit +collision evidence. An orphan restore stage prevents admission until an +operator removes the owned test fixture, while a durable replacement marker is +reconciled only by the next governed writable process open. + +The executable corpus composes with the exact Myc component-level atomicity, +backlog, saturation, recovery, property, and adversarial tests and the +source-locked shared SQLx initialization, transaction, backup, close, marker, +restore, and `SIGKILL` failpoint corpus named by that contract. Qualification +adds no runtime authority and makes no RCLD promotion, parent-alignment, Nix, +OCI, signing, publication, or deployment claim. + ## NIP-46 runtime contract Myc listens for encrypted kind-24133 requests on the exact configured relay diff --git a/contracts/services_hardening/process_qualification.v1.json b/contracts/services_hardening/process_qualification.v1.json @@ -0,0 +1,79 @@ +{ + "schema": "radroots.myc.process-qualification.v1", + "contract_version": 1, + "step": 161, + "service": "myc", + "binary": "myc", + "source_lock": { + "schema": "radroots.service.source-lock.v2", + "lib_revision": "7d7b454b4c9ed86569671993bd03ca868b676665" + }, + "bounds": { + "process_deadline_ms": 30000, + "poll_interval_ms": 2, + "parallel_inspection_processes": 8, + "soak_iterations": 32, + "crash_fixture_bytes": 67108864, + "maximum_captured_output_bytes": 8192 + }, + "actual_process_corpus": [ + "offline_state_and_diagnostics", + "parallel_inspection_saturation", + "bounded_reopen_soak", + "backup_copy_sigkill_collision", + "pre_marker_restore_sigkill_refusal", + "durable_restore_recovery", + "required_dependency_outage", + "secret_and_path_redaction" + ], + "component_corpus": { + "myc_atomicity": [ + "request_admission_is_atomic_idempotent_conflict_aware_and_restart_stable", + "delivery_jobs_are_config_bound_idempotent_restart_safe_and_unknown_aware", + "backup_integrity_and_offline_restore_obey_one_exact_myc_authority" + ], + "backlog_and_saturation": [ + "concurrent_identical_admission_creates_one_request_and_bounded_replay_evidence", + "configured_denial_precedes_saturated_unknown_client_rate_windows", + "challenge_creation_and_authorization_use_distinct_durable_rate_budgets" + ], + "recovery_and_crash": [ + "restart_recovery_is_bounded_jittered_and_idempotent", + "terminal_unknown_is_not_relabelled_as_failure_and_sql_guards_preserve_evidence", + "early_success_and_panic_are_fatal_and_join_their_cancelled_peer" + ], + "property_and_adversarial": [ + "duplicate_replay_conflict_and_distinct_relations_are_total", + "event_object_is_closed_duplicate_free_nonnull_and_exactly_consumed", + "exact_open_rejects_migration_history_drift_without_repair" + ] + }, + "source_locked_shared_sqlite_corpus": [ + "every_initialization_durability_edge_fails_once_and_rolls_back", + "transaction_durability_edges_preserve_exact_commit_semantics", + "backup_durability_edges_fail_once_clean_exact_stage_and_recover", + "close_durability_edges_are_once_only_retryable_or_terminal", + "every_marker_and_restore_durability_edge_is_wired_once", + "sigkill_restore_boundaries_recover_exact_topologies_and_preserve_permissions" + ], + "invariants": { + "actual_executable_required": true, + "production_failpoint_surface": false, + "test_environment_selector": false, + "detached_test_worker": false, + "live_database_unchanged_by_failed_backup": true, + "orphan_restore_evidence_fails_closed": true, + "durable_restore_evidence_reconciled_on_writable_reopen": true, + "captured_output_bounded": true, + "diagnostics_path_secret_free": true + }, + "deferred": [ + "rcld_promotion", + "parent_pin_alignment", + "nix", + "oci", + "signing", + "publication", + "deployment" + ] +} diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -61,6 +61,8 @@ const NIP46_RESPONSE_CONTRACT: &str = include_str!("../contracts/services_hardening/nip46_response_commit.v1.json"); const DELIVERY_RECOVERY_EXPORT_CONTRACT: &str = include_str!("../contracts/services_hardening/delivery_recovery_export.v1.json"); +const PROCESS_QUALIFICATION_CONTRACT: &str = + include_str!("../contracts/services_hardening/process_qualification.v1.json"); const SOURCES: &[&str] = &[ include_str!("../src/admin_v1.rs"), include_str!("../src/cli_bootstrap.rs"), @@ -76,6 +78,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/nip46_replay.rs"), include_str!("../src/nip46_verification.rs"), include_str!("../src/nip46_work.rs"), + include_str!("../src/nip46_wave_080_b.rs"), include_str!("../src/operations_v1.rs"), include_str!("../src/process_v1.rs"), include_str!("../src/process_v1_unsupported.rs"), @@ -197,12 +200,42 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "The production `run` path owns the exact five-role bounded graph", "Required relay subscriptions and provider handshakes complete before\nReady", "one configured absolute graceful-shutdown deadline", + "## Executable qualification", + "eight\nconcurrent inspection processes, 32 deterministic reopen iterations, and one\n64 MiB crash fixture", + "without adding a production\nfailpoint, hidden command, environment selector, feature, or detached test\nworker", ] { assert!(README.contains(required), "README is missing `{required}`"); } } #[test] +fn step161_qualification_is_machine_bound_without_a_production_test_surface() { + let contract: serde_json::Value = + serde_json::from_str(PROCESS_QUALIFICATION_CONTRACT).expect("qualification contract"); + assert_eq!(contract["schema"], "radroots.myc.process-qualification.v1"); + assert_eq!(contract["step"], 161); + assert_eq!(contract["invariants"]["actual_executable_required"], true); + assert_eq!( + contract["invariants"]["production_failpoint_surface"], + false + ); + assert_eq!(contract["invariants"]["test_environment_selector"], false); + for source in SOURCES.iter().copied().chain([ROOT, MAIN]) { + for forbidden in [ + "MYC_TEST_", + "MYC_FAILPOINT", + "process_qualification_failpoint", + "qualification-only-command", + ] { + assert!( + !source.contains(forbidden), + "production source contains `{forbidden}`" + ); + } + } +} + +#[test] fn step159_runtime_graph_is_fixed_joined_and_binary_signal_owned() { for required in [ "const TASK_ADMIN_SERVER: &str = \"admin_server\"", diff --git a/tests/services_hardening_process_qualification.rs b/tests/services_hardening_process_qualification.rs @@ -0,0 +1,462 @@ +#![forbid(unsafe_code)] +#![cfg(any(target_os = "linux", target_os = "macos"))] + +use std::{ + collections::BTreeSet, + fs, + io::Write as _, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, + process::{Child, Command, Output, Stdio}, + thread, + time::{Duration, Instant}, +}; + +use serde_json::Value; +use sha2::{Digest, Sha256}; +use sqlx::{ConnectOptions, Connection, SqliteConnection, sqlite::SqliteConnectOptions}; + +const CONTRACT: &str = + include_str!("../contracts/services_hardening/process_qualification.v1.json"); +const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); +const PROCESS_DEADLINE: Duration = Duration::from_millis(30_000); +const POLL_INTERVAL: Duration = Duration::from_millis(2); +const PARALLEL_INSPECTIONS: usize = 8; +const SOAK_ITERATIONS: usize = 32; +const CRASH_FIXTURE_BYTES: i64 = 67_108_864; +const MAXIMUM_CAPTURED_OUTPUT_BYTES: usize = 8_192; + +struct ProcessFixture { + root: tempfile::TempDir, + config: PathBuf, +} + +impl ProcessFixture { + fn new() -> Self { + let root = tempfile::tempdir().expect("repo-local root"); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)) + .expect("secure repo-local root"); + let config = root.path().join("myc.toml"); + Self { root, config } + } + + fn command(&self, command: &[&str]) -> Command { + let mut process = Command::new(env!("CARGO_BIN_EXE_myc")); + process + .args(["--profile", "repo-local", "--instance", "primary"]) + .arg("--repo-local-root") + .arg(self.root.path()) + .arg("--config") + .arg(&self.config) + .args(command) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + process + } + + fn run(&self, command: &[&str]) -> Output { + let child = self.command(command).spawn().expect("spawn Myc process"); + wait_bounded(child) + } + + fn run_with_stdin(&self, command: &[&str], bytes: &[u8]) -> Output { + let mut process = self.command(command); + process.stdin(Stdio::piped()); + let mut child = process.spawn().expect("spawn Myc process"); + child + .stdin + .take() + .expect("stdin") + .write_all(bytes) + .expect("write bounded stdin"); + wait_bounded(child) + } + + fn state_directory(&self) -> PathBuf { + self.root.path().join("data/services/myc/primary") + } + + fn state_database(&self) -> PathBuf { + self.state_directory().join("state.sqlite") + } + + fn initialize(&self) { + let initialized = self.run_with_stdin(&["config", "init"], repo_local_config().as_bytes()); + assert_success(&initialized); + fs::create_dir_all(self.state_directory()).expect("state directory"); + fs::set_permissions(self.state_directory(), fs::Permissions::from_mode(0o700)) + .expect("secure state directory"); + assert_success(&self.run(&["state", "init"])); + } +} + +fn repo_local_config() -> String { + CONFIG_EXAMPLE + .replace("wss://relay-primary.example.test/", "ws://127.0.0.1:9/") + .replace("wss://relay-secondary.example.test/", "ws://127.0.0.1:10/") + .replace("connect_deadline_ms = 10000", "connect_deadline_ms = 100") +} + +fn wait_bounded(mut child: Child) -> Output { + let deadline = Instant::now() + PROCESS_DEADLINE; + loop { + if child.try_wait().expect("poll Myc process").is_some() { + let output = child.wait_with_output().expect("collect Myc process"); + assert!(output.stdout.len() <= MAXIMUM_CAPTURED_OUTPUT_BYTES); + assert!(output.stderr.len() <= MAXIMUM_CAPTURED_OUTPUT_BYTES); + return output; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let output = child + .wait_with_output() + .expect("reap timed-out Myc process"); + panic!( + "Myc process exceeded the qualification deadline: {:?}", + output.stderr + ); + } + thread::sleep(POLL_INTERVAL); + } +} + +fn wait_for_created_member(child: &mut Child, path: &Path) { + let deadline = Instant::now() + PROCESS_DEADLINE; + loop { + if path + .metadata() + .map(|metadata| metadata.is_file() && metadata.len() > 0) + .unwrap_or(false) + { + return; + } + assert!( + child.try_wait().expect("poll crash target").is_none(), + "process exited before the crash boundary was observable" + ); + assert!( + Instant::now() < deadline, + "crash boundary was not observed before the deadline" + ); + thread::sleep(POLL_INTERVAL); + } +} + +fn diagnostic_code(output: &Output) -> String { + serde_json::from_slice::<Value>(&output.stderr).expect("bounded diagnostic JSON")["code"] + .as_str() + .expect("diagnostic code") + .to_owned() +} + +fn assert_success(output: &Output) { + assert_eq!(output.status.code(), Some(0), "stderr: {:?}", output.stderr); + assert_eq!(diagnostic_code(output), "success"); +} + +async fn inflate_database(path: &Path) { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false) + .disable_statement_logging(); + let mut connection = SqliteConnection::connect_with(&options) + .await + .expect("open qualification database"); + sqlx::query("CREATE TABLE process_qualification_padding (payload BLOB NOT NULL)") + .execute(&mut connection) + .await + .expect("create temporary padding table"); + sqlx::query("INSERT INTO process_qualification_padding(payload) VALUES (zeroblob(?))") + .bind(CRASH_FIXTURE_BYTES) + .execute(&mut connection) + .await + .expect("write bounded padding"); + sqlx::query("DROP TABLE process_qualification_padding") + .execute(&mut connection) + .await + .expect("remove temporary padding schema"); + connection + .close() + .await + .expect("close qualification database"); + assert!( + fs::metadata(path).expect("database metadata").len() + >= u64::try_from(CRASH_FIXTURE_BYTES).expect("positive fixture bound") + ); +} + +#[test] +fn qualification_contract_freezes_the_exact_process_and_component_corpus() { + let contract: Value = serde_json::from_str(CONTRACT).expect("qualification contract"); + assert_eq!( + contract + .as_object() + .expect("qualification object") + .keys() + .map(String::as_str) + .collect::<BTreeSet<_>>(), + BTreeSet::from([ + "actual_process_corpus", + "binary", + "bounds", + "component_corpus", + "contract_version", + "deferred", + "invariants", + "schema", + "service", + "source_lock", + "source_locked_shared_sqlite_corpus", + "step", + ]) + ); + assert_eq!(contract["schema"], "radroots.myc.process-qualification.v1"); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["step"], 161); + assert_eq!(contract["service"], "myc"); + assert_eq!(contract["binary"], "myc"); + assert_eq!( + contract["source_lock"], + serde_json::json!({ + "schema": "radroots.service.source-lock.v2", + "lib_revision": "7d7b454b4c9ed86569671993bd03ca868b676665" + }) + ); + assert_eq!( + contract["bounds"], + serde_json::json!({ + "process_deadline_ms": 30_000, + "poll_interval_ms": 2, + "parallel_inspection_processes": 8, + "soak_iterations": 32, + "crash_fixture_bytes": 67_108_864, + "maximum_captured_output_bytes": 8_192 + }) + ); + assert_eq!( + contract["actual_process_corpus"], + serde_json::json!([ + "offline_state_and_diagnostics", + "parallel_inspection_saturation", + "bounded_reopen_soak", + "backup_copy_sigkill_collision", + "pre_marker_restore_sigkill_refusal", + "durable_restore_recovery", + "required_dependency_outage", + "secret_and_path_redaction" + ]) + ); + assert_eq!( + contract["component_corpus"], + serde_json::json!({ + "myc_atomicity": [ + "request_admission_is_atomic_idempotent_conflict_aware_and_restart_stable", + "delivery_jobs_are_config_bound_idempotent_restart_safe_and_unknown_aware", + "backup_integrity_and_offline_restore_obey_one_exact_myc_authority" + ], + "backlog_and_saturation": [ + "concurrent_identical_admission_creates_one_request_and_bounded_replay_evidence", + "configured_denial_precedes_saturated_unknown_client_rate_windows", + "challenge_creation_and_authorization_use_distinct_durable_rate_budgets" + ], + "recovery_and_crash": [ + "restart_recovery_is_bounded_jittered_and_idempotent", + "terminal_unknown_is_not_relabelled_as_failure_and_sql_guards_preserve_evidence", + "early_success_and_panic_are_fatal_and_join_their_cancelled_peer" + ], + "property_and_adversarial": [ + "duplicate_replay_conflict_and_distinct_relations_are_total", + "event_object_is_closed_duplicate_free_nonnull_and_exactly_consumed", + "exact_open_rejects_migration_history_drift_without_repair" + ] + }) + ); + assert_eq!( + contract["source_locked_shared_sqlite_corpus"], + serde_json::json!([ + "every_initialization_durability_edge_fails_once_and_rolls_back", + "transaction_durability_edges_preserve_exact_commit_semantics", + "backup_durability_edges_fail_once_clean_exact_stage_and_recover", + "close_durability_edges_are_once_only_retryable_or_terminal", + "every_marker_and_restore_durability_edge_is_wired_once", + "sigkill_restore_boundaries_recover_exact_topologies_and_preserve_permissions" + ]) + ); + assert_eq!( + contract["invariants"], + serde_json::json!({ + "actual_executable_required": true, + "production_failpoint_surface": false, + "test_environment_selector": false, + "detached_test_worker": false, + "live_database_unchanged_by_failed_backup": true, + "orphan_restore_evidence_fails_closed": true, + "durable_restore_evidence_reconciled_on_writable_reopen": true, + "captured_output_bounded": true, + "diagnostics_path_secret_free": true + }) + ); + assert_eq!( + contract["deferred"], + serde_json::json!([ + "rcld_promotion", + "parent_pin_alignment", + "nix", + "oci", + "signing", + "publication", + "deployment" + ]) + ); +} + +#[test] +fn actual_process_is_bounded_under_parallel_inspection_soak_and_outage() { + let fixture = ProcessFixture::new(); + fixture.initialize(); + + let children = (0..PARALLEL_INSPECTIONS) + .map(|_| { + fixture + .command(&["state", "status"]) + .spawn() + .expect("inspection process") + }) + .collect::<Vec<_>>(); + for child in children { + assert_success(&wait_bounded(child)); + } + + for iteration in 0..SOAK_ITERATIONS { + let command = if iteration % 2 == 0 { + &["state", "status"][..] + } else { + &["state", "verify"][..] + }; + assert_success(&fixture.run(command)); + } + + let outage = fixture.run(&["run"]); + assert_eq!(outage.status.code(), Some(3)); + assert!(outage.stdout.is_empty()); + assert_eq!( + diagnostic_code(&outage), + "service_or_dependency_unavailable" + ); + let diagnostic = String::from_utf8(outage.stderr).expect("diagnostic UTF-8"); + assert!(!diagnostic.contains(fixture.root.path().to_str().expect("UTF-8 root"))); + assert!(!diagnostic.contains("relay-primary")); +} + +#[tokio::test] +async fn actual_process_sigkill_boundaries_fail_closed_and_recover_exactly() { + let fixture = ProcessFixture::new(); + fixture.initialize(); + inflate_database(&fixture.state_database()).await; + assert_success(&fixture.run(&["state", "verify"])); + + let interrupted_bundle = fixture.root.path().join("interrupted-backup"); + let mut backup = fixture + .command(&["state", "backup", "--operation-id", "crash-backup-01"]) + .arg("--target") + .arg(&interrupted_bundle) + .args(["--expected-generation", "1", "--confirm"]) + .spawn() + .expect("spawn backup process"); + wait_for_created_member(&mut backup, &interrupted_bundle.join("state.sqlite")); + backup.kill().expect("SIGKILL backup process"); + let backup_output = backup.wait_with_output().expect("reap backup process"); + assert!(!backup_output.status.success()); + assert_success(&fixture.run(&["state", "verify"])); + assert!(interrupted_bundle.exists(), "crash collision evidence"); + fs::remove_dir_all(&interrupted_bundle).expect("remove owned crash fixture"); + + let bundle = fixture.root.path().join("backup"); + let backup = fixture + .command(&["state", "backup", "--operation-id", "crash-backup-02"]) + .arg("--target") + .arg(&bundle) + .args(["--expected-generation", "1", "--confirm"]) + .spawn() + .expect("spawn complete backup"); + let backup_output = wait_bounded(backup); + assert_success(&backup_output); + let manifest_bytes = backup_output.stdout; + let manifest_digest = hex::encode(Sha256::digest(&manifest_bytes)); + let manifest_path = fixture.root.path().join("manifest.json"); + fs::write(&manifest_path, &manifest_bytes).expect("manifest file"); + fs::set_permissions(&manifest_path, fs::Permissions::from_mode(0o600)) + .expect("secure manifest"); + + let staged_path = fixture + .state_directory() + .join("state.restore-staged.sqlite"); + let mut restore = fixture + .command(&["state", "restore"]) + .arg("--manifest") + .arg(&manifest_path) + .arg("--manifest-sha256") + .arg(&manifest_digest) + .arg("--bundle") + .arg(&bundle) + .args(["--maximum-state-bytes", "134217728", "--confirm"]) + .spawn() + .expect("spawn restore process"); + wait_for_created_member(&mut restore, &staged_path); + restore.kill().expect("SIGKILL restore process"); + let restore_output = restore.wait_with_output().expect("reap restore process"); + assert!(!restore_output.status.success()); + assert!(staged_path.exists(), "orphan stage is retained as evidence"); + + let refused = fixture.run(&["state", "verify"]); + assert_eq!(refused.status.code(), Some(4)); + assert_eq!(diagnostic_code(&refused), "state_or_identity_unavailable"); + fs::remove_file(&staged_path).expect("remove owned orphan stage fixture"); + assert_success(&fixture.run(&["state", "verify"])); + + let mut corrupted = fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(fixture.state_database()) + .expect("open live database"); + corrupted + .write_all(b"corrupt-live-database") + .and_then(|()| corrupted.sync_all()) + .expect("persist corruption fixture"); + drop(corrupted); + + let restored = fixture + .command(&["state", "restore"]) + .arg("--manifest") + .arg(&manifest_path) + .arg("--manifest-sha256") + .arg(&manifest_digest) + .arg("--bundle") + .arg(&bundle) + .args(["--maximum-state-bytes", "134217728", "--confirm"]) + .spawn() + .expect("spawn final restore"); + assert_success(&wait_bounded(restored)); + assert!( + fixture + .state_directory() + .join("state.restore-marker.v1") + .exists() + ); + assert!( + fixture + .state_directory() + .join("state.restore-backup.sqlite") + .exists() + ); + assert_success(&fixture.run(&["state", "verify"])); + for artifact in [ + "state.restore-marker.v1", + "state.restore-marker.v1.next", + "state.restore-staged.sqlite", + "state.restore-backup.sqlite", + ] { + assert!(!fixture.state_directory().join(artifact).exists()); + } +}