From ccfa79f21767e8a2a5ad229244c6d57d97cb261f Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 15 Sep 2026 16:52:21 +0200 Subject: [PATCH 01/18] =?UTF-8?q?test(PMAT-565):=20RED=20=E2=80=94=20the?= =?UTF-8?q?=20lock=20file=20keeps=20the=20first=20writer's=20version=20on?= =?UTF-8?q?=20every=20write=20(Refs=20#565)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StateLock gains created_by (serde default, absent on every fleet lock today); 106+31 literal constructions patched with created_by: None. Five cases: a write stamps the real writer and keeps the creator; the creator is set once and the writer every time; a legacy lock migrates its stale generator into created_by; apply rewrites the writer; lock --restamp converges every lock in one run (dry-run writes nothing, idempotent, sidecars rewritten). 5 of 5 RED. Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- benches/core_bench.rs | 2 + benches/memory_bench.rs | 2 + examples/drift_detection.rs | 1 + examples/planner_proof_sat_why.rs | 1 + src/cli/infra_bench.rs | 1 + src/cli/lock_repair.rs | 1 + src/cli/status_drift_intel.rs | 1 + src/cli/status_drift_intel2.rs | 1 + src/cli/status_fleet_insight_b.rs | 1 + src/cli/status_maturity_b.rs | 1 + src/cli/status_maturity_c.rs | 1 + src/cli/status_operational_ext2_b.rs | 1 + src/cli/status_quality_b.rs | 1 + src/cli/status_resilience_b.rs | 1 + src/cli/status_resource_intel_b.rs | 1 + src/cli/status_security_b.rs | 2 + src/cli/test_fixtures.rs | 2 + src/cli/tests_apply_drift_gate.rs | 1 + src/cli/tests_check.rs | 2 + src/cli/tests_destroy_b.rs | 1 + src/cli/tests_diff_cmd.rs | 3 + src/cli/tests_drift.rs | 4 + src/cli/tests_generation_b.rs | 5 + src/cli/tests_helpers_state_b.rs | 1 + src/cli/tests_helpers_time_b.rs | 4 + src/cli/tests_lock_core_cov.rs | 1 + src/cli/tests_show.rs | 1 + src/cli/tests_status_core.rs | 1 + src/cli/tests_status_core_b.rs | 1 + src/cli/tests_status_counts_cov.rs | 1 + src/core/executor/refresh.rs | 1 + src/core/executor/tests_filters.rs | 1 + src/core/executor/tests_filters_b.rs | 1 + src/core/planner/moved.rs | 1 + src/core/planner/tests_advanced.rs | 7 + src/core/planner/tests_determine.rs | 4 + src/core/planner/tests_filter.rs | 1 + src/core/planner/tests_hash_completeness.rs | 1 + src/core/planner/tests_lifecycle.rs | 1 + src/core/planner/tests_plan.rs | 7 + src/core/planner/tests_plan_secrets.rs | 1 + src/core/planner/tests_unprobed.rs | 1 + src/core/planner/tests_why.rs | 1 + src/core/planner/tests_why_cov.rs | 1 + src/core/state/mod.rs | 1 + src/core/state/reconstruct.rs | 1 + src/core/state/tests_basic.rs | 1 + src/core/state/tests_helpers.rs | 1 + src/core/tests_proptest_convergence.rs | 1 + src/core/tests_proptest_handlers.rs | 1 + src/core/tests_proptest_idempotency.rs | 1 + src/core/types/state_types.rs | 11 +- src/core/types/tests_state.rs | 1 + src/tripwire/drift/tests_basic.rs | 8 + src/tripwire/drift/tests_basic_b.rs | 5 + src/tripwire/drift/tests_edge_fj131.rs | 6 + src/tripwire/drift/tests_edge_fj132.rs | 6 + src/tripwire/drift/tests_edge_fj132_b.rs | 6 + src/tripwire/drift/tests_fj036.rs | 4 + src/tripwire/drift/tests_full.rs | 6 + src/tripwire/drift/tests_full_b.rs | 1 + src/tripwire/drift/tests_image_drift.rs | 1 + src/tripwire/drift/tests_task_checks.rs | 1 + src/tripwire/drift/tests_transport.rs | 2 + tests/falsification_lock_names_its_writer.rs | 281 ++++++++++++++++++ tests/falsification_planner.rs | 3 + tests/falsification_planner_b.rs | 2 + ...lsification_planner_proof_reversibility.rs | 1 + tests/falsification_planner_sat_why.rs | 1 + 69 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 tests/falsification_lock_names_its_writer.rs diff --git a/benches/core_bench.rs b/benches/core_bench.rs index 5695d7ba0..85c604e4b 100644 --- a/benches/core_bench.rs +++ b/benches/core_bench.rs @@ -362,6 +362,7 @@ fn populate_converged_locks( hostname: hostname.to_string(), generated_at: "2026-02-26T00:00:00Z".to_string(), generator: "forjar-bench".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -432,6 +433,7 @@ fn bench_spec9_drift(c: &mut Criterion) { hostname: "bench-host.example.com".to_string(), generated_at: "2026-02-26T00:00:00Z".to_string(), generator: "forjar-bench".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/benches/memory_bench.rs b/benches/memory_bench.rs index 9adf3e74f..3dc6ff8d1 100644 --- a/benches/memory_bench.rs +++ b/benches/memory_bench.rs @@ -90,6 +90,7 @@ fn write_bench_lock(state_dir: &std::path::Path, n: usize) { hostname: "bench-host".to_string(), generated_at: "2026-02-26T00:00:00Z".to_string(), generator: "forjar-bench".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -302,6 +303,7 @@ fn mem_store_lock_save_load_100r() { hostname: "bench-host".to_string(), generated_at: "2026-02-26T00:00:00Z".to_string(), generator: "forjar-bench".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/examples/drift_detection.rs b/examples/drift_detection.rs index 591330929..91b8b0f64 100644 --- a/examples/drift_detection.rs +++ b/examples/drift_detection.rs @@ -54,6 +54,7 @@ fn main() { hostname: "localhost".to_string(), generated_at: "2026-02-25T12:00:00Z".to_string(), generator: "forjar-example".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/examples/planner_proof_sat_why.rs b/examples/planner_proof_sat_why.rs index 8550b85c3..b7be42f8a 100644 --- a/examples/planner_proof_sat_why.rs +++ b/examples/planner_proof_sat_why.rs @@ -198,6 +198,7 @@ fn main() { hostname: "web-01".into(), generated_at: "2026-03-09T00:00:00Z".into(), generator: "forjar".into(), + created_by: None, blake3_version: "1".into(), resources: lock_resources, }; diff --git a/src/cli/infra_bench.rs b/src/cli/infra_bench.rs index d1da1d1d5..09aa1d883 100644 --- a/src/cli/infra_bench.rs +++ b/src/cli/infra_bench.rs @@ -223,6 +223,7 @@ fn setup_bench_state(dir: &std::path::Path) -> Result Result<(), String format!("{ts}Z") }, generator: "forjar-repair".to_string(), + created_by: None, blake3_version: "1.5".to_string(), resources: indexmap::IndexMap::new(), }; diff --git a/src/cli/status_drift_intel.rs b/src/cli/status_drift_intel.rs index f68438c0e..6d4809b21 100644 --- a/src/cli/status_drift_intel.rs +++ b/src/cli/status_drift_intel.rs @@ -407,6 +407,7 @@ mod tests { hostname: "test".to_string(), generated_at: "2024-01-01T00:00:00Z".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.0".to_string(), resources: indexmap::IndexMap::new(), }; diff --git a/src/cli/status_drift_intel2.rs b/src/cli/status_drift_intel2.rs index cf44a2d9d..1566aaa55 100644 --- a/src/cli/status_drift_intel2.rs +++ b/src/cli/status_drift_intel2.rs @@ -218,6 +218,7 @@ mod tests { hostname: machine.into(), generated_at: ts.into(), generator: "test".into(), + created_by: None, blake3_version: "1.0".into(), resources: m, } diff --git a/src/cli/status_fleet_insight_b.rs b/src/cli/status_fleet_insight_b.rs index 7ac13e398..03809fc2b 100644 --- a/src/cli/status_fleet_insight_b.rs +++ b/src/cli/status_fleet_insight_b.rs @@ -34,6 +34,7 @@ mod tests { hostname: machine.into(), generated_at: ts.into(), generator: "test".into(), + created_by: None, blake3_version: "1.0".into(), resources: m, } diff --git a/src/cli/status_maturity_b.rs b/src/cli/status_maturity_b.rs index f456f0a8c..2e27c82e1 100644 --- a/src/cli/status_maturity_b.rs +++ b/src/cli/status_maturity_b.rs @@ -34,6 +34,7 @@ mod tests { hostname: machine.into(), generated_at: ts.into(), generator: "test".into(), + created_by: None, blake3_version: "1.0".into(), resources: m, } diff --git a/src/cli/status_maturity_c.rs b/src/cli/status_maturity_c.rs index e01779d97..f422fbfeb 100644 --- a/src/cli/status_maturity_c.rs +++ b/src/cli/status_maturity_c.rs @@ -34,6 +34,7 @@ mod tests { hostname: machine.into(), generated_at: ts.into(), generator: "test".into(), + created_by: None, blake3_version: "1.0".into(), resources: m, } diff --git a/src/cli/status_operational_ext2_b.rs b/src/cli/status_operational_ext2_b.rs index 66b407659..bbf40da78 100644 --- a/src/cli/status_operational_ext2_b.rs +++ b/src/cli/status_operational_ext2_b.rs @@ -33,6 +33,7 @@ mod tests { hostname: machine.into(), generated_at: "2026-01-15T10:00:00Z".into(), generator: "test".into(), + created_by: None, blake3_version: "1.0".into(), resources: m, } diff --git a/src/cli/status_quality_b.rs b/src/cli/status_quality_b.rs index a44ced42e..bdbb8947e 100644 --- a/src/cli/status_quality_b.rs +++ b/src/cli/status_quality_b.rs @@ -34,6 +34,7 @@ mod tests { hostname: machine.into(), generated_at: ts.into(), generator: "test".into(), + created_by: None, blake3_version: "1.0".into(), resources: m, } diff --git a/src/cli/status_resilience_b.rs b/src/cli/status_resilience_b.rs index e050be39b..7c6b7b7a8 100644 --- a/src/cli/status_resilience_b.rs +++ b/src/cli/status_resilience_b.rs @@ -34,6 +34,7 @@ mod tests { hostname: machine.into(), generated_at: ts.into(), generator: "test".into(), + created_by: None, blake3_version: "1.0".into(), resources: m, } diff --git a/src/cli/status_resource_intel_b.rs b/src/cli/status_resource_intel_b.rs index 191d350ac..812519191 100644 --- a/src/cli/status_resource_intel_b.rs +++ b/src/cli/status_resource_intel_b.rs @@ -34,6 +34,7 @@ mod tests { hostname: machine.into(), generated_at: ts.into(), generator: "test".into(), + created_by: None, blake3_version: "1.0".into(), resources: m, } diff --git a/src/cli/status_security_b.rs b/src/cli/status_security_b.rs index 720291f90..b81915891 100644 --- a/src/cli/status_security_b.rs +++ b/src/cli/status_security_b.rs @@ -32,6 +32,7 @@ mod tests { hostname: machine.into(), generated_at: "2026-01-15T10:00:00Z".into(), generator: "test".into(), + created_by: None, blake3_version: "1.0".into(), resources: m, } @@ -59,6 +60,7 @@ mod tests { hostname: machine.into(), generated_at: "2026-01-15T10:00:00Z".into(), generator: "test".into(), + created_by: None, blake3_version: "1.0".into(), resources: m, } diff --git a/src/cli/test_fixtures.rs b/src/cli/test_fixtures.rs index f18156eb8..88778ca77 100644 --- a/src/cli/test_fixtures.rs +++ b/src/cli/test_fixtures.rs @@ -31,6 +31,7 @@ pub(crate) fn make_state_dir_with_lock( hostname: "test-host".to_string(), generated_at: "2026-02-25T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: res_map, }; @@ -48,6 +49,7 @@ pub(crate) fn make_test_lock( hostname: machine.to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, } diff --git a/src/cli/tests_apply_drift_gate.rs b/src/cli/tests_apply_drift_gate.rs index 74e657031..bb13cf9bf 100644 --- a/src/cli/tests_apply_drift_gate.rs +++ b/src/cli/tests_apply_drift_gate.rs @@ -49,6 +49,7 @@ mod tests { hostname: "localhost".to_string(), generated_at: "2026-03-03T12:00:00Z".to_string(), generator: "forjar-test".to_string(), + created_by: None, blake3_version: "1.5.5".to_string(), resources, }; diff --git a/src/cli/tests_check.rs b/src/cli/tests_check.rs index 3754d8998..590a8a3d3 100644 --- a/src/cli/tests_check.rs +++ b/src/cli/tests_check.rs @@ -167,6 +167,7 @@ resources: hostname: "alertbox".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -366,6 +367,7 @@ resources: hostname: "local".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/src/cli/tests_destroy_b.rs b/src/cli/tests_destroy_b.rs index 02e358cb8..e4af3d626 100644 --- a/src/cli/tests_destroy_b.rs +++ b/src/cli/tests_destroy_b.rs @@ -38,6 +38,7 @@ mod tests { hostname: "m1".into(), generated_at: "now".into(), generator: "forjar".into(), + created_by: None, blake3_version: "1.8".into(), resources, }; diff --git a/src/cli/tests_diff_cmd.rs b/src/cli/tests_diff_cmd.rs index f7a8097de..c41a102d7 100644 --- a/src/cli/tests_diff_cmd.rs +++ b/src/cli/tests_diff_cmd.rs @@ -42,6 +42,7 @@ mod tests { hostname: "web-box".to_string(), generated_at: "2026-02-25T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: { let mut r = indexmap::IndexMap::new(); @@ -80,6 +81,7 @@ mod tests { hostname: "web-box".to_string(), generated_at: "2026-02-25T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: indexmap::IndexMap::new(), }; @@ -119,6 +121,7 @@ mod tests { hostname: format!("{name}-box"), generated_at: "2026-02-25T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: indexmap::IndexMap::new(), }; diff --git a/src/cli/tests_drift.rs b/src/cli/tests_drift.rs index 1fb0daa47..285ea09d6 100644 --- a/src/cli/tests_drift.rs +++ b/src/cli/tests_drift.rs @@ -78,6 +78,7 @@ mod tests { hostname: "testbox".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -137,6 +138,7 @@ mod tests { hostname: "driftbox".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -248,6 +250,7 @@ mod tests { hostname: "driftbox2".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -312,6 +315,7 @@ mod tests { hostname: "jsonbox".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/src/cli/tests_generation_b.rs b/src/cli/tests_generation_b.rs index 0631aab7c..686227e14 100644 --- a/src/cli/tests_generation_b.rs +++ b/src/cli/tests_generation_b.rs @@ -43,6 +43,7 @@ fn lock_to_tuples_some_lock() { hostname: "host".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 1.0.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -66,6 +67,7 @@ fn lock_to_tuples_empty_resources() { hostname: "host".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 1.0.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: indexmap::IndexMap::new(), }; @@ -126,6 +128,7 @@ fn count_lock_resources_with_locks() { hostname: "host".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 1.0.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: { let mut r = indexmap::IndexMap::new(); @@ -241,6 +244,7 @@ fn load_gen_locks_with_machines() { hostname: "host".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 1.0.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: indexmap::IndexMap::new(), }; @@ -337,6 +341,7 @@ fn setup_with_generations(dir: &std::path::Path) -> std::path::PathBuf { hostname: "host".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 1.0.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: { let mut r = indexmap::IndexMap::new(); diff --git a/src/cli/tests_helpers_state_b.rs b/src/cli/tests_helpers_state_b.rs index c301d1e3b..7710e2323 100644 --- a/src/cli/tests_helpers_state_b.rs +++ b/src/cli/tests_helpers_state_b.rs @@ -59,6 +59,7 @@ fn make_lock(machine: &str) -> types::StateLock { hostname: "host".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 1.0.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: indexmap::IndexMap::new(), } diff --git a/src/cli/tests_helpers_time_b.rs b/src/cli/tests_helpers_time_b.rs index 6cb30622c..61fbfb9ee 100644 --- a/src/cli/tests_helpers_time_b.rs +++ b/src/cli/tests_helpers_time_b.rs @@ -124,6 +124,7 @@ fn cmd_diff_json_with_changes() { hostname: "web".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 1.0.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: { let mut r = indexmap::IndexMap::new(); @@ -181,6 +182,7 @@ fn cmd_diff_with_resource_filter() { hostname: "web".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 1.0.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: { let mut r = indexmap::IndexMap::new(); @@ -222,6 +224,7 @@ fn cmd_diff_removed_resource_text() { hostname: "web".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 1.0.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: { let mut r = indexmap::IndexMap::new(); @@ -313,6 +316,7 @@ fn cmd_env_diff_with_drift() { hostname: "web".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 1.0.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: { let mut r = indexmap::IndexMap::new(); diff --git a/src/cli/tests_lock_core_cov.rs b/src/cli/tests_lock_core_cov.rs index 98ca19123..a3d33809b 100644 --- a/src/cli/tests_lock_core_cov.rs +++ b/src/cli/tests_lock_core_cov.rs @@ -11,6 +11,7 @@ fn make_lock(machine: &str, resources: IndexMap) -> StateL hostname: "localhost".to_string(), generated_at: "2026-03-08T12:00:00Z".to_string(), generator: "forjar 1.0.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, } diff --git a/src/cli/tests_show.rs b/src/cli/tests_show.rs index a9631e738..82829fe81 100644 --- a/src/cli/tests_show.rs +++ b/src/cli/tests_show.rs @@ -189,6 +189,7 @@ resources: hostname: "web-box".to_string(), generated_at: "2026-02-25T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: indexmap::IndexMap::new(), }; diff --git a/src/cli/tests_status_core.rs b/src/cli/tests_status_core.rs index dede95184..f53ad6006 100644 --- a/src/cli/tests_status_core.rs +++ b/src/cli/tests_status_core.rs @@ -315,6 +315,7 @@ mod tests { hostname: "webbox.example.com".to_string(), generated_at: "2026-02-16T14:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/src/cli/tests_status_core_b.rs b/src/cli/tests_status_core_b.rs index addcd2cdb..cf8c0c89a 100644 --- a/src/cli/tests_status_core_b.rs +++ b/src/cli/tests_status_core_b.rs @@ -321,6 +321,7 @@ resources: {} hostname: "local".to_string(), generated_at: "2026-02-26T00:00:00Z".to_string(), generator: "forjar-test".to_string(), + created_by: None, blake3_version: "1.5.0".to_string(), resources, }; diff --git a/src/cli/tests_status_counts_cov.rs b/src/cli/tests_status_counts_cov.rs index 601f135e8..7e4d5e316 100644 --- a/src/cli/tests_status_counts_cov.rs +++ b/src/cli/tests_status_counts_cov.rs @@ -30,6 +30,7 @@ fn write_lock(state_dir: &std::path::Path, machine: &str, resources: Vec<(&str, hostname: machine.to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: res_map.clone(), }; diff --git a/src/core/executor/refresh.rs b/src/core/executor/refresh.rs index ab65c57c4..582ec9188 100644 --- a/src/core/executor/refresh.rs +++ b/src/core/executor/refresh.rs @@ -130,6 +130,7 @@ fn empty_lock(machine: &str) -> StateLock { hostname: machine.to_string(), generated_at: crate::tripwire::eventlog::now_iso8601(), generator: format!("forjar-refresh {}", env!("CARGO_PKG_VERSION")), + created_by: None, blake3_version: "1.5".to_string(), resources: indexmap::IndexMap::new(), } diff --git a/src/core/executor/tests_filters.rs b/src/core/executor/tests_filters.rs index 3a5ed2c1f..24ac476f2 100644 --- a/src/core/executor/tests_filters.rs +++ b/src/core/executor/tests_filters.rs @@ -459,6 +459,7 @@ fn make_test_lock(machine: &str, resource_ids: &[&str]) -> StateLock { hostname: machine.to_string(), generated_at: String::new(), generator: "test".to_string(), + created_by: None, blake3_version: "1".to_string(), resources, } diff --git a/src/core/executor/tests_filters_b.rs b/src/core/executor/tests_filters_b.rs index 55cb44d7a..53dddbfa3 100644 --- a/src/core/executor/tests_filters_b.rs +++ b/src/core/executor/tests_filters_b.rs @@ -26,6 +26,7 @@ fn make_test_lock(machine: &str, resource_ids: &[&str]) -> StateLock { hostname: machine.to_string(), generated_at: String::new(), generator: "test".to_string(), + created_by: None, blake3_version: "1".to_string(), resources, } diff --git a/src/core/planner/moved.rs b/src/core/planner/moved.rs index eedb66f61..90336bfd9 100644 --- a/src/core/planner/moved.rs +++ b/src/core/planner/moved.rs @@ -190,6 +190,7 @@ mod tests { hostname: machine.to_string(), generated_at: String::new(), generator: "test".to_string(), + created_by: None, blake3_version: "1".to_string(), resources: indexmap::IndexMap::new(), } diff --git a/src/core/planner/tests_advanced.rs b/src/core/planner/tests_advanced.rs index 38b95fc2b..f3c211e86 100644 --- a/src/core/planner/tests_advanced.rs +++ b/src/core/planner/tests_advanced.rs @@ -32,6 +32,7 @@ fn test_fj036_plan_all_noop_when_converged() { hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -100,6 +101,7 @@ fn test_fj036_plan_absent_resource_destroy() { hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }; @@ -153,6 +155,7 @@ fn test_gh229_absent_converged_to_absent_is_noop() { hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }; @@ -208,6 +211,7 @@ fn test_gh229_absent_converged_as_present_still_destroys() { hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }; @@ -252,6 +256,7 @@ fn test_gh229_absent_failed_destroy_is_retried() { hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }; @@ -313,6 +318,7 @@ fn test_plan_converged_hash_match() { hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -354,6 +360,7 @@ fn test_plan_converged_hash_mismatch() { hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/src/core/planner/tests_determine.rs b/src/core/planner/tests_determine.rs index d9d0cdf3b..053a8daf3 100644 --- a/src/core/planner/tests_determine.rs +++ b/src/core/planner/tests_determine.rs @@ -45,6 +45,7 @@ fn test_fj132_determine_action_converged_same_hash_noop() { hostname: "web".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }, @@ -86,6 +87,7 @@ fn test_fj132_determine_action_hash_changed_updates() { hostname: "web".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }, @@ -126,6 +128,7 @@ fn test_fj132_determine_action_absent_with_lock_destroys() { hostname: "web".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }, @@ -189,6 +192,7 @@ fn test_fj132_determine_action_failed_retries() { hostname: "web".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }, diff --git a/src/core/planner/tests_filter.rs b/src/core/planner/tests_filter.rs index ed07d2b2c..d8323de7b 100644 --- a/src/core/planner/tests_filter.rs +++ b/src/core/planner/tests_filter.rs @@ -73,6 +73,7 @@ fn test_fj004_arch_filter_with_existing_lock() { hostname: "arm".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: arm_resources, }, diff --git a/src/core/planner/tests_hash_completeness.rs b/src/core/planner/tests_hash_completeness.rs index 6f2aef347..aa106736e 100644 --- a/src/core/planner/tests_hash_completeness.rs +++ b/src/core/planner/tests_hash_completeness.rs @@ -233,6 +233,7 @@ fn converged_lock( hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }, diff --git a/src/core/planner/tests_lifecycle.rs b/src/core/planner/tests_lifecycle.rs index ab03ff78b..aa1934356 100644 --- a/src/core/planner/tests_lifecycle.rs +++ b/src/core/planner/tests_lifecycle.rs @@ -14,6 +14,7 @@ fn test_lock(machine: &str) -> StateLock { hostname: machine.to_string(), generated_at: String::new(), generator: "test".to_string(), + created_by: None, blake3_version: "1".to_string(), resources: indexmap::IndexMap::new(), } diff --git a/src/core/planner/tests_plan.rs b/src/core/planner/tests_plan.rs index c74962956..20b244027 100644 --- a/src/core/planner/tests_plan.rs +++ b/src/core/planner/tests_plan.rs @@ -44,6 +44,7 @@ fn test_fj004_plan_all_unchanged() { hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -79,6 +80,7 @@ fn test_fj004_plan_update_on_hash_mismatch() { hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -114,6 +116,7 @@ fn test_fj004_plan_destroy() { hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -148,6 +151,7 @@ fn test_fj004_plan_failed_resource_gets_retried() { hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -228,6 +232,7 @@ fn test_fj004_multi_machine_partial_lock() { hostname: "a".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: a_resources, }, @@ -354,6 +359,7 @@ fn test_fj132_plan_mixed_actions() { hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -410,6 +416,7 @@ fn test_gh97_second_plan_over_converged_locks_is_noop() { hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/src/core/planner/tests_plan_secrets.rs b/src/core/planner/tests_plan_secrets.rs index 577887041..656e922c7 100644 --- a/src/core/planner/tests_plan_secrets.rs +++ b/src/core/planner/tests_plan_secrets.rs @@ -97,6 +97,7 @@ resources: hostname: "m1".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/src/core/planner/tests_unprobed.rs b/src/core/planner/tests_unprobed.rs index 3bdb89ff6..d58ca16f8 100644 --- a/src/core/planner/tests_unprobed.rs +++ b/src/core/planner/tests_unprobed.rs @@ -65,6 +65,7 @@ fn converged_locks( hostname: machine.clone(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }, diff --git a/src/core/planner/tests_why.rs b/src/core/planner/tests_why.rs index d7240479c..0e732467b 100644 --- a/src/core/planner/tests_why.rs +++ b/src/core/planner/tests_why.rs @@ -28,6 +28,7 @@ mod tests { hostname: "localhost".to_string(), generated_at: "2026-03-03T12:00:00Z".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.5.5".to_string(), resources, }, diff --git a/src/core/planner/tests_why_cov.rs b/src/core/planner/tests_why_cov.rs index 9d581b77b..5ad9252a7 100644 --- a/src/core/planner/tests_why_cov.rs +++ b/src/core/planner/tests_why_cov.rs @@ -27,6 +27,7 @@ fn make_lock( hostname: "localhost".to_string(), generated_at: "2026-03-08T12:00:00Z".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.5.5".to_string(), resources, }, diff --git a/src/core/state/mod.rs b/src/core/state/mod.rs index cda895d83..3627fe905 100644 --- a/src/core/state/mod.rs +++ b/src/core/state/mod.rs @@ -269,6 +269,7 @@ pub fn new_lock(machine: &str, hostname: &str) -> StateLock { hostname: hostname.to_string(), generated_at: now_iso8601(), generator: format!("forjar {}", env!("CARGO_PKG_VERSION")), + created_by: None, blake3_version: "1.8".to_string(), resources: indexmap::IndexMap::new(), } diff --git a/src/core/state/reconstruct.rs b/src/core/state/reconstruct.rs index a5328c40b..a60cda533 100644 --- a/src/core/state/reconstruct.rs +++ b/src/core/state/reconstruct.rs @@ -63,6 +63,7 @@ pub fn reconstruct_at( last_ts }, generator: format!("forjar {} (reconstructed)", env!("CARGO_PKG_VERSION")), + created_by: None, blake3_version: "1.8".to_string(), resources, }) diff --git a/src/core/state/tests_basic.rs b/src/core/state/tests_basic.rs index 2e590f937..3899d3451 100644 --- a/src/core/state/tests_basic.rs +++ b/src/core/state/tests_basic.rs @@ -113,6 +113,7 @@ proptest! { hostname, generated_at: "2026-02-24T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: indexmap::IndexMap::new(), }; diff --git a/src/core/state/tests_helpers.rs b/src/core/state/tests_helpers.rs index e992f03b1..f39a30d9f 100644 --- a/src/core/state/tests_helpers.rs +++ b/src/core/state/tests_helpers.rs @@ -22,6 +22,7 @@ pub(super) fn make_lock() -> StateLock { hostname: "test-box".to_string(), generated_at: "2026-02-16T14:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, } diff --git a/src/core/tests_proptest_convergence.rs b/src/core/tests_proptest_convergence.rs index e7edf6af7..36df86d30 100644 --- a/src/core/tests_proptest_convergence.rs +++ b/src/core/tests_proptest_convergence.rs @@ -86,6 +86,7 @@ fn converged_lock(id: &str, resource: &Resource, machine: &str) -> StateLock { hostname: machine.to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar-proptest".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: indexmap::IndexMap::new(), }; diff --git a/src/core/tests_proptest_handlers.rs b/src/core/tests_proptest_handlers.rs index e98b46436..974454afc 100644 --- a/src/core/tests_proptest_handlers.rs +++ b/src/core/tests_proptest_handlers.rs @@ -115,6 +115,7 @@ proptest! { hostname: "localhost".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar-proptest".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: indexmap::IndexMap::new(), }; diff --git a/src/core/tests_proptest_idempotency.rs b/src/core/tests_proptest_idempotency.rs index 55afde8ee..3b4efe974 100644 --- a/src/core/tests_proptest_idempotency.rs +++ b/src/core/tests_proptest_idempotency.rs @@ -30,6 +30,7 @@ proptest! { hostname, generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar-proptest".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: indexmap::IndexMap::new(), }; diff --git a/src/core/types/state_types.rs b/src/core/types/state_types.rs index c4c70aa29..1b900f706 100644 --- a/src/core/types/state_types.rs +++ b/src/core/types/state_types.rs @@ -87,9 +87,18 @@ pub struct StateLock { /// When the lock was generated pub generated_at: String, - /// Generator version + /// The binary that WROTE this file, stamped on every write by + /// `state::save_lock` (PMAT-565). Until then it was the first writer's + /// version, never updated, while `generated_at` rolled — four fleet locks + /// under one 1.30.0 binary said 1.1.1, 1.13.1, 1.27.0 and 1.10.0. pub generator: String, + /// The first writer — whatever `generator` said before the first write + /// that stamped it (PMAT-565). Set once, never rolled. Absent on every + /// lock written before 1.31.0, so `default` keeps those files parsing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_by: Option, + /// BLAKE3 version pub blake3_version: String, diff --git a/src/core/types/tests_state.rs b/src/core/types/tests_state.rs index 72a0af42b..9b9afb62d 100644 --- a/src/core/types/tests_state.rs +++ b/src/core/types/tests_state.rs @@ -12,6 +12,7 @@ fn test_fj001_state_lock_roundtrip() { hostname: "test-box".to_string(), generated_at: "2026-02-16T14:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: IndexMap::from([( "test-pkg".to_string(), diff --git a/src/tripwire/drift/tests_basic.rs b/src/tripwire/drift/tests_basic.rs index 3e416aa67..58e05f9b4 100644 --- a/src/tripwire/drift/tests_basic.rs +++ b/src/tripwire/drift/tests_basic.rs @@ -46,6 +46,7 @@ fn test_fj016_detect_drift_empty_lock() { hostname: "test-box".to_string(), generated_at: "2026-02-16T14:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: indexmap::IndexMap::new(), }; @@ -89,6 +90,7 @@ fn test_fj016_detect_drift_converged_file_with_drift() { hostname: "test-box".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -135,6 +137,7 @@ fn test_fj016_detect_drift_no_drift_when_matching() { hostname: "test-box".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -177,6 +180,7 @@ fn test_fj016_detect_drift_skips_non_converged() { hostname: "test-box".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -210,6 +214,7 @@ fn test_fj016_detect_drift_skips_non_file_types() { hostname: "test-box".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -240,6 +245,7 @@ fn test_fj016_detect_drift_missing_path_detail() { hostname: "test-box".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -280,6 +286,7 @@ fn test_fj016_detect_drift_non_string_path_skipped() { hostname: "test-box".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -320,6 +327,7 @@ fn test_fj016_detect_drift_non_string_content_hash_skipped() { hostname: "test-box".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/src/tripwire/drift/tests_basic_b.rs b/src/tripwire/drift/tests_basic_b.rs index 23e985834..80c07dd2d 100644 --- a/src/tripwire/drift/tests_basic_b.rs +++ b/src/tripwire/drift/tests_basic_b.rs @@ -97,6 +97,7 @@ fn test_fj016_detect_drift_with_machine_local() { hostname: "local".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -153,6 +154,7 @@ fn test_fj016_detect_drift_with_machine_local_drift() { hostname: "local".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -218,6 +220,7 @@ fn test_fj016_detect_drift_multiple_files() { hostname: "test".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -279,6 +282,7 @@ fn test_fj016_missing_content_hash_skipped() { hostname: "test".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -318,6 +322,7 @@ fn test_fj016_full_drift_non_string_live_hash_skipped() { hostname: "test".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/src/tripwire/drift/tests_edge_fj131.rs b/src/tripwire/drift/tests_edge_fj131.rs index d96fa3626..534ba9e40 100644 --- a/src/tripwire/drift/tests_edge_fj131.rs +++ b/src/tripwire/drift/tests_edge_fj131.rs @@ -93,6 +93,7 @@ fn test_fj131_detect_drift_skips_failed_resources() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -133,6 +134,7 @@ fn test_fj131_detect_drift_skips_non_string_path() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -174,6 +176,7 @@ fn test_fj131_detect_drift_skips_non_string_content_hash() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -217,6 +220,7 @@ fn test_fj131_detect_drift_no_content_hash_skipped() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -250,6 +254,7 @@ fn test_fj131_detect_drift_skips_non_file_resources() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -309,6 +314,7 @@ fn test_fj131_detect_drift_multiple_resources() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/src/tripwire/drift/tests_edge_fj132.rs b/src/tripwire/drift/tests_edge_fj132.rs index 4b12e8c99..accb1cc72 100644 --- a/src/tripwire/drift/tests_edge_fj132.rs +++ b/src/tripwire/drift/tests_edge_fj132.rs @@ -38,6 +38,7 @@ fn test_fj132_detect_drift_with_local_machine() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -100,6 +101,7 @@ fn test_fj132_detect_drift_with_machine_drift_detected() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -154,6 +156,7 @@ fn test_fj132_detect_drift_drifted_status_skipped() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -196,6 +199,7 @@ fn test_fj132_detect_drift_unknown_status_skipped() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -243,6 +247,7 @@ fn test_fj132_detect_drift_full_skips_file_resources() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }; @@ -292,6 +297,7 @@ fn test_fj132_detect_drift_full_non_file_no_live_hash() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }; diff --git a/src/tripwire/drift/tests_edge_fj132_b.rs b/src/tripwire/drift/tests_edge_fj132_b.rs index c26fabb53..b5e319c11 100644 --- a/src/tripwire/drift/tests_edge_fj132_b.rs +++ b/src/tripwire/drift/tests_edge_fj132_b.rs @@ -29,6 +29,7 @@ fn test_fj132_detect_drift_full_non_file_non_string_live_hash() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }; @@ -82,6 +83,7 @@ fn test_fj132_detect_drift_full_non_file_missing_config_resource() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }; @@ -140,6 +142,7 @@ fn test_fj132_detect_drift_empty_lock() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: indexmap::IndexMap::new(), }; @@ -177,6 +180,7 @@ fn test_fj132_detect_drift_skips_non_converged() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -211,6 +215,7 @@ fn test_fj132_detect_drift_file_without_path_skipped() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -271,6 +276,7 @@ fn test_fj132_detect_drift_matching_hash_no_drift() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/src/tripwire/drift/tests_fj036.rs b/src/tripwire/drift/tests_fj036.rs index f6b42db68..d0380e999 100644 --- a/src/tripwire/drift/tests_fj036.rs +++ b/src/tripwire/drift/tests_fj036.rs @@ -44,6 +44,7 @@ fn test_fj036_drift_with_changed_hash() { hostname: "test-box".to_string(), generated_at: "2026-02-25T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -95,6 +96,7 @@ fn test_fj036_drift_absent_resource_no_drift() { hostname: "test-box".to_string(), generated_at: "2026-02-25T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -139,6 +141,7 @@ fn test_detect_drift_service_resource() { hostname: "test-box".to_string(), generated_at: "2026-02-25T10:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -188,6 +191,7 @@ fn test_detect_drift_directory_resource() { hostname: "test-box".to_string(), generated_at: "2026-02-25T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/src/tripwire/drift/tests_full.rs b/src/tripwire/drift/tests_full.rs index 062b362df..1c73a5839 100644 --- a/src/tripwire/drift/tests_full.rs +++ b/src/tripwire/drift/tests_full.rs @@ -24,6 +24,7 @@ fn test_fj016_full_drift_skips_non_file_without_live_hash() { hostname: "test-box".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -78,6 +79,7 @@ fn test_fj016_full_drift_skips_non_converged() { hostname: "test-box".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -126,6 +128,7 @@ fn test_fj016_full_drift_skips_missing_resource_config() { hostname: "test-box".to_string(), generated_at: "2026-01-01T00:00:00Z".to_string(), generator: "forjar 0.1.0".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -313,6 +316,7 @@ fn test_fj016_detect_drift_full_matching_live_hash() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }; @@ -358,6 +362,7 @@ fn test_fj016_detect_drift_full_mismatched_live_hash() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }; @@ -401,6 +406,7 @@ fn test_fj016_detect_drift_full_codegen_error_skips() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }; diff --git a/src/tripwire/drift/tests_full_b.rs b/src/tripwire/drift/tests_full_b.rs index 9aa66a919..a631ed2a7 100644 --- a/src/tripwire/drift/tests_full_b.rs +++ b/src/tripwire/drift/tests_full_b.rs @@ -75,6 +75,7 @@ fn test_fj016_detect_drift_full_file_plus_service() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources: lock_resources, }; diff --git a/src/tripwire/drift/tests_image_drift.rs b/src/tripwire/drift/tests_image_drift.rs index 329e3922d..a15a21aea 100644 --- a/src/tripwire/drift/tests_image_drift.rs +++ b/src/tripwire/drift/tests_image_drift.rs @@ -32,6 +32,7 @@ fn make_image_lock(resource_id: &str, manifest_digest: &str, container_name: &st hostname: "build-01".into(), generated_at: "2026-03-07T00:00:00Z".into(), generator: "forjar-test".into(), + created_by: None, blake3_version: "1.5.0".into(), resources, } diff --git a/src/tripwire/drift/tests_task_checks.rs b/src/tripwire/drift/tests_task_checks.rs index c62e16b98..1a7cdbad2 100644 --- a/src/tripwire/drift/tests_task_checks.rs +++ b/src/tripwire/drift/tests_task_checks.rs @@ -32,6 +32,7 @@ fn lock_with(id: &str, entry: ResourceLock) -> StateLock { hostname: "box".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, } diff --git a/src/tripwire/drift/tests_transport.rs b/src/tripwire/drift/tests_transport.rs index 9578576db..91327b8b0 100644 --- a/src/tripwire/drift/tests_transport.rs +++ b/src/tripwire/drift/tests_transport.rs @@ -159,6 +159,7 @@ fn test_fj016_detect_drift_multiple_resources_mixed() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; @@ -199,6 +200,7 @@ fn test_fj016_detect_drift_failed_resource_skipped() { hostname: "test".to_string(), generated_at: "now".to_string(), generator: "test".to_string(), + created_by: None, blake3_version: "1.8".to_string(), resources, }; diff --git a/tests/falsification_lock_names_its_writer.rs b/tests/falsification_lock_names_its_writer.rs new file mode 100644 index 000000000..b2bf944a0 --- /dev/null +++ b/tests/falsification_lock_names_its_writer.rs @@ -0,0 +1,281 @@ +//! PMAT-565 (forjar#565, paiml/infra#605 third signature): the per-machine +//! lock names the binary that WROTE it, on every write. +//! +//! MEASURED, four fleet locks under one 1.30.0 binary, 2026-09-15: +//! +//! ```text +//! state/yoga/state.lock.yaml generator: forjar 1.1.1 generated_at: 2026-09-15T09:19:01Z +//! state/gx10/state.lock.yaml generator: forjar 1.13.1 generated_at: 2026-09-15T09:18:56Z +//! ``` +//! +//! Two files rewritten in the same minute by the same binary, claiming two +//! different, long-gone writers. `generator` was stamped ONCE by `new_lock` +//! and never touched again; `generated_at` was refreshed on every apply. The +//! pair is a claim no version of forjar could have made — the artifact lies +//! about itself, the same class as exit-0-over-red. +//! +//! The fix lives in the ONE writer, `state::save_lock`: every write stamps +//! `generator` with the writing binary and, the first time, preserves the +//! value it is replacing as `created_by`, so the creator is named rather +//! than erased. `forjar lock --restamp` walks a state dir and writes every +//! lock through it once, so the fleet converges in one run. + +use forjar::core::state::{load_lock, new_lock, save_lock}; +use std::fs; +use std::path::Path; +use std::process::Command; + +const FORJAR: &str = env!("CARGO_BIN_EXE_forjar"); +const FAKE: &str = "forjar 0.0.0-fake-first-writer"; + +/// What the binary says it is — the only acceptable `generator`. +fn writer() -> String { + let out = Command::new(FORJAR) + .arg("--version") + .output() + .expect("forjar --version"); + let v = String::from_utf8_lossy(&out.stdout).trim().to_string(); + assert!(v.starts_with("forjar "), "{v}"); + v +} + +fn read_lock_text(state: &Path, machine: &str) -> String { + fs::read_to_string(state.join(machine).join("state.lock.yaml")).unwrap() +} + +/// THE REGRESSION. A lock whose in-memory `generator` is a fake first-writer +/// string is written; the FILE must carry the real writer, and the fake must +/// survive only as `created_by`. +#[test] +fn a_write_stamps_the_real_writer_and_keeps_the_creator() { + let dir = tempfile::tempdir().unwrap(); + let state = dir.path().join("state"); + let mut lock = new_lock("box", "box"); + lock.generator = FAKE.to_string(); + + save_lock(&state, &lock).unwrap(); + + let back = load_lock(&state, "box").unwrap().expect("lock exists"); + assert_eq!( + back.generator, + writer(), + "the file must name the binary that wrote it, not whatever the struct carried" + ); + assert_eq!( + back.created_by.as_deref(), + Some(FAKE), + "the value being replaced is the creator, and it must be kept, not erased" + ); +} + +/// A second write by the same binary leaves the creator alone and keeps the +/// writer current: the creator is set once, the writer every time. +#[test] +fn the_creator_is_set_once_and_the_writer_every_time() { + let dir = tempfile::tempdir().unwrap(); + let state = dir.path().join("state"); + let mut lock = new_lock("box", "box"); + lock.generator = FAKE.to_string(); + save_lock(&state, &lock).unwrap(); + + let mut second = load_lock(&state, "box").unwrap().unwrap(); + second.generator = "forjar 9.9.9-somebody-else".to_string(); + save_lock(&state, &second).unwrap(); + + let back = load_lock(&state, "box").unwrap().unwrap(); + assert_eq!(back.generator, writer()); + assert_eq!( + back.created_by.as_deref(), + Some(FAKE), + "the creator must not roll" + ); +} + +/// A pre-existing lock file with no `created_by` (every lock on the fleet +/// today) still loads, and its stale `generator` becomes its `created_by` +/// on the first write. +#[test] +fn a_legacy_lock_migrates_its_stale_generator_into_created_by() { + let dir = tempfile::tempdir().unwrap(); + let state = dir.path().join("state"); + fs::create_dir_all(state.join("yoga")).unwrap(); + fs::write( + state.join("yoga").join("state.lock.yaml"), + "schema: '1.0'\nmachine: yoga\nhostname: yoga\ngenerated_at: 2026-09-15T09:19:01Z\ngenerator: forjar 1.1.1\nblake3_version: '1.8'\nresources: {}\n", + ) + .unwrap(); + + let legacy = load_lock(&state, "yoga").unwrap().unwrap(); + assert_eq!(legacy.generator, "forjar 1.1.1"); + assert_eq!( + legacy.created_by, None, + "the fleet's locks carry no created_by yet" + ); + + save_lock(&state, &legacy).unwrap(); + let back = load_lock(&state, "yoga").unwrap().unwrap(); + assert_eq!(back.generator, writer()); + assert_eq!(back.created_by.as_deref(), Some("forjar 1.1.1")); +} + +/// The apply path writes through the same writer: after `forjar apply`, the +/// lock on disk names this binary, whatever it said before. +#[test] +fn apply_rewrites_the_writer() { + let dir = tempfile::tempdir().unwrap(); + let state = dir.path().join("state"); + let target = dir.path().join("t.txt"); + let cfg = dir.path().join("forjar.yaml"); + fs::write( + &cfg, + format!( + "version: \"1.0\"\nname: w\nmachines:\n box:\n hostname: box\n addr: 127.0.0.1\nresources:\n f:\n type: file\n machine: box\n path: {}\n content: \"x\\n\"\n", + target.display() + ), + ) + .unwrap(); + let ok = Command::new(FORJAR) + .args([ + "apply", + "-f", + cfg.to_str().unwrap(), + "--state-dir", + state.to_str().unwrap(), + "--yes", + ]) + .output() + .unwrap(); + assert!( + ok.status.success(), + "{}", + String::from_utf8_lossy(&ok.stderr) + ); + + // Forge a stale writer the way the fleet's locks carry one, then apply again. + let text = read_lock_text(&state, "box").replace( + &format!("generator: {}", writer()), + "generator: forjar 1.1.1", + ); + assert!(text.contains("generator: forjar 1.1.1"), "{text}"); + fs::write(state.join("box").join("state.lock.yaml"), &text).unwrap(); + // The integrity sidecar no longer matches the forged file; reseal it so the + // second apply is refused for nothing but what this test is about. + let reseal = Command::new(FORJAR) + .args([ + "reseal", + "--file", + state.join("box").join("state.lock.yaml").to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + reseal.status.success(), + "{}", + String::from_utf8_lossy(&reseal.stderr) + ); + + let again = Command::new(FORJAR) + .args([ + "apply", + "-f", + cfg.to_str().unwrap(), + "--state-dir", + state.to_str().unwrap(), + "--yes", + ]) + .output() + .unwrap(); + assert!( + again.status.success(), + "{}", + String::from_utf8_lossy(&again.stderr) + ); + let after = read_lock_text(&state, "box"); + assert!( + after.contains(&format!("generator: {}", writer())), + "{after}" + ); + assert!(after.contains("created_by: forjar 1.1.1"), "{after}"); +} + +/// THE ONE-SHOT. `forjar lock --restamp` rewrites every lock under the state +/// dir through the writer, names each change, and `--dry-run` writes nothing. +#[test] +fn lock_restamp_converges_every_lock_in_one_run() { + let dir = tempfile::tempdir().unwrap(); + let state = dir.path().join("state"); + for (m, g) in [ + ("yoga", "forjar 1.1.1"), + ("gx10", "forjar 1.13.1"), + ("intel", "forjar 1.27.0"), + ] { + fs::create_dir_all(state.join(m)).unwrap(); + fs::write( + state.join(m).join("state.lock.yaml"), + format!("schema: '1.0'\nmachine: {m}\nhostname: {m}\ngenerated_at: 2026-09-10T07:15:25Z\ngenerator: {g}\nblake3_version: '1.8'\nresources: {{}}\n"), + ) + .unwrap(); + } + + let dry = Command::new(FORJAR) + .args([ + "lock", + "--restamp", + "--dry-run", + "--state-dir", + state.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + dry.status.success(), + "{}", + String::from_utf8_lossy(&dry.stderr) + ); + let dry_out = String::from_utf8_lossy(&dry.stdout); + assert!( + dry_out.contains("yoga") && dry_out.contains("forjar 1.1.1"), + "{dry_out}" + ); + assert!( + read_lock_text(&state, "yoga").contains("generator: forjar 1.1.1"), + "--dry-run must write nothing" + ); + + let run = Command::new(FORJAR) + .args(["lock", "--restamp", "--state-dir", state.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + run.status.success(), + "{}", + String::from_utf8_lossy(&run.stderr) + ); + let out = String::from_utf8_lossy(&run.stdout); + assert!(out.contains("3 lock(s) restamped"), "{out}"); + for (m, g) in [ + ("yoga", "forjar 1.1.1"), + ("gx10", "forjar 1.13.1"), + ("intel", "forjar 1.27.0"), + ] { + let back = load_lock(&state, m).unwrap().unwrap(); + assert_eq!(back.generator, writer(), "{m}"); + assert_eq!(back.created_by.as_deref(), Some(g), "{m}"); + assert!( + state.join(m).join("state.lock.yaml.b3").exists(), + "{m}: the sidecar must be rewritten with the lock" + ); + } + + // Idempotent: a second run changes nothing and says so. + let again = Command::new(FORJAR) + .args(["lock", "--restamp", "--state-dir", state.to_str().unwrap()]) + .output() + .unwrap(); + assert!(again.status.success()); + assert!( + String::from_utf8_lossy(&again.stdout).contains("0 lock(s) restamped"), + "{}", + String::from_utf8_lossy(&again.stdout) + ); +} diff --git a/tests/falsification_planner.rs b/tests/falsification_planner.rs index 7d4846d08..15790949f 100644 --- a/tests/falsification_planner.rs +++ b/tests/falsification_planner.rs @@ -160,6 +160,7 @@ fn plan_noop_when_converged_same_hash() { hostname: "m1".into(), generated_at: "now".into(), generator: "test".into(), + created_by: None, blake3_version: "1.8".into(), resources: IndexMap::new(), }; @@ -193,6 +194,7 @@ fn plan_updates_when_hash_differs() { hostname: "m1".into(), generated_at: "now".into(), generator: "test".into(), + created_by: None, blake3_version: "1.8".into(), resources: IndexMap::new(), }; @@ -224,6 +226,7 @@ fn plan_destroys_absent_resource() { hostname: "m1".into(), generated_at: "now".into(), generator: "test".into(), + created_by: None, blake3_version: "1.8".into(), resources: IndexMap::new(), }; diff --git a/tests/falsification_planner_b.rs b/tests/falsification_planner_b.rs index d6232b409..472ce87fc 100644 --- a/tests/falsification_planner_b.rs +++ b/tests/falsification_planner_b.rs @@ -97,6 +97,7 @@ fn why_noop_matching_hash() { hostname: "m1".into(), generated_at: "now".into(), generator: "test".into(), + created_by: None, blake3_version: "1.8".into(), resources: IndexMap::new(), }; @@ -124,6 +125,7 @@ fn why_destroy_absent() { hostname: "m1".into(), generated_at: "now".into(), generator: "test".into(), + created_by: None, blake3_version: "1.8".into(), resources: IndexMap::new(), }; diff --git a/tests/falsification_planner_proof_reversibility.rs b/tests/falsification_planner_proof_reversibility.rs index 70d8926a0..fe80222c5 100644 --- a/tests/falsification_planner_proof_reversibility.rs +++ b/tests/falsification_planner_proof_reversibility.rs @@ -323,6 +323,7 @@ fn make_lock(resource_id: &str, hash: &str, status: ResourceStatus) -> StateLock hostname: "web-01".into(), generated_at: "now".into(), generator: "test".into(), + created_by: None, blake3_version: "1.0".into(), resources, } diff --git a/tests/falsification_planner_sat_why.rs b/tests/falsification_planner_sat_why.rs index f0ff5f143..3a5c2c924 100644 --- a/tests/falsification_planner_sat_why.rs +++ b/tests/falsification_planner_sat_why.rs @@ -31,6 +31,7 @@ fn lock_with(resources: &[(&str, ResourceLock)]) -> StateLock { hostname: "host".into(), generated_at: "2026-03-09T00:00:00Z".into(), generator: "test".into(), + created_by: None, blake3_version: "1".into(), resources: map, } From e4a081e67c1a35535bfae54f48696a0428bbabfa Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 15 Sep 2026 16:59:09 +0200 Subject: [PATCH 02/18] fix(PMAT-565): the lock names its writer on every write, keeps its creator, and lock --restamp converges a fleet in one run (Refs #565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit save_lock stamps generator with this binary (writer_stamp, what forjar --version prints) and moves the value it replaces into created_by the first time — the one writer every path goes through, so no caller can forget. forjar lock --restamp walks --state-dir and writes every lock whose generator is not this binary through the same writer, sidecar included; --dry-run lists, --json reports, a second run is a no-op. Three LockArgs literals gain restamp: false. Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- src/cli/commands/lock_core_args.rs | 8 ++ src/cli/dispatch_lock.rs | 4 + src/cli/lock_restamp.rs | 140 +++++++++++++++++++ src/cli/mod.rs | 1 + src/cli/tests_cov_args_extra.rs | 1 + src/cli/tests_cov_dispatch_2.rs | 2 + src/core/state/mod.rs | 35 ++++- tests/falsification_lock_names_its_writer.rs | 19 ++- 8 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 src/cli/lock_restamp.rs diff --git a/src/cli/commands/lock_core_args.rs b/src/cli/commands/lock_core_args.rs index 2699783e6..f6dc32320 100644 --- a/src/cli/commands/lock_core_args.rs +++ b/src/cli/commands/lock_core_args.rs @@ -32,6 +32,14 @@ pub struct LockArgs { /// Output as JSON #[arg(long)] pub json: bool, + + /// PMAT-565: rewrite every `/state.lock.yaml` under --state-dir + /// through the writer, so `generator` names THIS binary and the value it + /// replaces is kept as `created_by`. One run converges a fleet whose locks + /// still name the version that first wrote them; needs no config file. + /// Combine with --dry-run to list what would change. + #[arg(long)] + pub restamp: bool, } /// CLI arguments for `lock prune`. diff --git a/src/cli/dispatch_lock.rs b/src/cli/dispatch_lock.rs index ddb6aaaa3..84553db66 100644 --- a/src/cli/dispatch_lock.rs +++ b/src/cli/dispatch_lock.rs @@ -22,8 +22,12 @@ pub(crate) fn dispatch_lock_cmd(cmd: Commands) -> Result<(), String> { verify, dry_run, json, + restamp, }) => { let sd = resolve_state_dir(&state_dir, workspace.as_deref()); + if restamp { + return super::lock_restamp::cmd_lock_restamp(&sd, dry_run, json); + } cmd_lock( &file, &sd, diff --git a/src/cli/lock_restamp.rs b/src/cli/lock_restamp.rs new file mode 100644 index 000000000..ebf1d8c9b --- /dev/null +++ b/src/cli/lock_restamp.rs @@ -0,0 +1,140 @@ +//! PMAT-565: `forjar lock --restamp` — every lock under a state dir names +//! the binary that wrote it, in ONE run. +//! +//! `state::save_lock` has stamped the writer on every write since PMAT-565, +//! so an apply corrects the lock of every machine it touches. The fleet's +//! locks are not all touched by one apply — a manifest names its own +//! machines — and "converges on the next incidental write" is how a wrong +//! `generator` survived from 1.1.1 to 1.30.0. This walks the dir and writes +//! each lock through the same writer, sidecar included, and says what moved. + +use crate::core::state::{load_lock, save_lock, stamped_for_write, writer_stamp}; +use std::path::Path; + +/// One lock's before/after, for the report. +struct Restamp { + machine: String, + before: String, + created_by: Option, +} + +/// Every `/state.lock.yaml` under `state_dir`, by machine name. +fn machines_with_locks(state_dir: &Path) -> Result, String> { + let entries = std::fs::read_dir(state_dir) + .map_err(|e| format!("cannot read state dir {}: {e}", state_dir.display()))?; + let mut names: Vec = entries + .flatten() + .filter(|e| e.path().join("state.lock.yaml").is_file()) + .filter_map(|e| e.file_name().to_str().map(str::to_string)) + .collect(); + names.sort(); + Ok(names) +} + +/// Rewrite every lock whose `generator` is not this binary. +pub(crate) fn cmd_lock_restamp(state_dir: &Path, dry_run: bool, json: bool) -> Result<(), String> { + let writer = writer_stamp(); + let mut done: Vec = Vec::new(); + let mut unchanged = 0usize; + for machine in machines_with_locks(state_dir)? { + let Some(lock) = load_lock(state_dir, &machine)? else { + continue; + }; + if lock.generator == writer { + unchanged += 1; + continue; + } + let stamped = stamped_for_write(&lock); + if !dry_run { + save_lock(state_dir, &lock)?; + } + done.push(Restamp { + machine, + before: lock.generator.clone(), + created_by: stamped.created_by, + }); + } + report(&done, unchanged, &writer, dry_run, json) +} + +fn report( + done: &[Restamp], + unchanged: usize, + writer: &str, + dry_run: bool, + json: bool, +) -> Result<(), String> { + if json { + let rows: Vec = done + .iter() + .map(|r| { + serde_json::json!({ + "machine": r.machine, "before": r.before, "after": writer, + "created_by": r.created_by, + }) + }) + .collect(); + let out = serde_json::json!({ + "writer": writer, "dry_run": dry_run, + "restamped": done.len(), "unchanged": unchanged, "locks": rows, + }); + println!( + "{}", + serde_json::to_string_pretty(&out).map_err(|e| format!("JSON error: {e}"))? + ); + return Ok(()); + } + let verb = if dry_run { + "would restamp" + } else { + "restamped" + }; + for r in done { + println!( + " {verb} {}: {} -> {writer} (created_by: {})", + r.machine, + r.before, + r.created_by.as_deref().unwrap_or("-") + ); + } + println!( + "{} lock(s) {}, {unchanged} already named {writer}", + done.len(), + if dry_run { + "would be restamped" + } else { + "restamped" + } + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::state::new_lock; + + #[test] + fn a_dir_with_no_locks_reports_zero() { + let d = tempfile::tempdir().unwrap(); + assert!(cmd_lock_restamp(d.path(), false, false).is_ok()); + } + + #[test] + fn an_unreadable_state_dir_is_an_error_not_zero() { + let d = tempfile::tempdir().unwrap(); + let missing = d.path().join("nope"); + assert!(cmd_lock_restamp(&missing, false, false).is_err()); + } + + #[test] + fn a_lock_already_naming_this_binary_is_left_alone() { + let d = tempfile::tempdir().unwrap(); + let lock = new_lock("m", "m"); + save_lock(d.path(), &lock).unwrap(); + let before = std::fs::read_to_string(d.path().join("m/state.lock.yaml")).unwrap(); + cmd_lock_restamp(d.path(), false, false).unwrap(); + let after = std::fs::read_to_string(d.path().join("m/state.lock.yaml")).unwrap(); + assert_eq!(before, after, "an idempotent second pass rewrites nothing"); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 26bc5612c..4887eb991 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -166,6 +166,7 @@ mod lock_merge; mod lock_ops; mod lock_output; mod lock_repair; +mod lock_restamp; mod lock_security; mod logs; mod logs_follow; diff --git a/src/cli/tests_cov_args_extra.rs b/src/cli/tests_cov_args_extra.rs index 3a027f35e..44356af4f 100644 --- a/src/cli/tests_cov_args_extra.rs +++ b/src/cli/tests_cov_args_extra.rs @@ -19,6 +19,7 @@ mod tests { verify: false, dry_run: false, json: false, + restamp: false, }; let _ = format!("{a:?}"); } diff --git a/src/cli/tests_cov_dispatch_2.rs b/src/cli/tests_cov_dispatch_2.rs index 163091f7d..066a4727e 100644 --- a/src/cli/tests_cov_dispatch_2.rs +++ b/src/cli/tests_cov_dispatch_2.rs @@ -267,6 +267,7 @@ resources: verify: false, dry_run: false, json: false, + restamp: false, })); assert!(result.is_ok()); } @@ -285,6 +286,7 @@ resources: verify: false, dry_run: false, json: true, + restamp: false, })); assert!(result.is_ok()); } diff --git a/src/core/state/mod.rs b/src/core/state/mod.rs index 3627fe905..117648b02 100644 --- a/src/core/state/mod.rs +++ b/src/core/state/mod.rs @@ -33,6 +33,37 @@ pub fn load_lock(state_dir: &Path, machine: &str) -> Result, S Ok(Some(lock)) } +/// What this binary writes into `generator`: the writer's own name and version. +/// +/// PMAT-565: `forjar --version` prints exactly this, so the lock and the +/// binary that wrote it can be compared by eye and by `grep`. +pub fn writer_stamp() -> String { + format!("forjar {}", env!("CARGO_PKG_VERSION")) +} + +/// The lock as it will be WRITTEN: `generator` names this binary, and the +/// value it replaces becomes `created_by` the first time. +/// +/// PMAT-565 (forjar#565, paiml/infra#605 third signature). `generator` was +/// stamped once by `new_lock` and never touched again while `generated_at` +/// rolled on every apply — four fleet locks under one 1.30.0 binary said +/// 1.1.1, 1.13.1, 1.27.0 and 1.10.0, two of them rewritten in the same +/// minute. The pair was a claim no version of forjar could have made. +/// +/// Done HERE, in the one writer every path goes through, rather than at each +/// caller: a caller that forgets is exactly how the field went stale. The +/// creator is preserved, not erased — `forjar 1.1.1` is a true fact about +/// who wrote the file first, and `forjar-refresh 1.x` / `(reconstructed)` +/// markers stay legible as provenance. +pub fn stamped_for_write(lock: &StateLock) -> StateLock { + let mut out = lock.clone(); + if out.created_by.is_none() && !out.generator.is_empty() { + out.created_by = Some(out.generator.clone()); + } + out.generator = writer_stamp(); + out +} + /// Save a lock file atomically (write to temp, then rename). #[contract("execution-safety-v1", equation = "atomic_write")] pub fn save_lock(state_dir: &Path, lock: &StateLock) -> Result<(), String> { @@ -44,7 +75,9 @@ pub fn save_lock(state_dir: &Path, lock: &StateLock) -> Result<(), String> { .map_err(|e| format!("cannot create dir {}: {}", parent.display(), e))?; } - let yaml = serde_yaml_ng::to_string(lock).map_err(|e| format!("serialize error: {e}"))?; + // PMAT-565: the file names its writer, whatever the struct carried. + let stamped = stamped_for_write(lock); + let yaml = serde_yaml_ng::to_string(&stamped).map_err(|e| format!("serialize error: {e}"))?; // Write to temp file, then rename for crash-safe persistence let tmp_path = path.with_extension("lock.yaml.tmp"); diff --git a/tests/falsification_lock_names_its_writer.rs b/tests/falsification_lock_names_its_writer.rs index b2bf944a0..c7a59ac20 100644 --- a/tests/falsification_lock_names_its_writer.rs +++ b/tests/falsification_lock_names_its_writer.rs @@ -151,12 +151,21 @@ fn apply_rewrites_the_writer() { String::from_utf8_lossy(&ok.stderr) ); - // Forge a stale writer the way the fleet's locks carry one, then apply again. - let text = read_lock_text(&state, "box").replace( - &format!("generator: {}", writer()), - "generator: forjar 1.1.1", + // Forge a legacy lock the way the fleet's carry one — a stale writer and + // no `created_by` at all — then apply again. + let text = read_lock_text(&state, "box") + .replace( + &format!("generator: {}", writer()), + "generator: forjar 1.1.1", + ) + .lines() + .filter(|l| !l.starts_with("created_by:")) + .map(|l| format!("{l}\n")) + .collect::(); + assert!( + text.contains("generator: forjar 1.1.1") && !text.contains("created_by"), + "{text}" ); - assert!(text.contains("generator: forjar 1.1.1"), "{text}"); fs::write(state.join("box").join("state.lock.yaml"), &text).unwrap(); // The integrity sidecar no longer matches the forged file; reseal it so the // second apply is refused for nothing but what this test is about. From ad097fad1e90292b17bf11edfcb4729658a4e2d5 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 15 Sep 2026 17:01:52 +0200 Subject: [PATCH 03/18] docs(PMAT-565): contract, CHANGELOG, lock schema in the book (Refs #565) Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 17 ++ contracts/lock-names-its-writer-v1.yaml | 198 ++++++++++++++++++++++++ docs/book/src/08-state-management.md | 20 ++- 3 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 contracts/lock-names-its-writer-v1.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f0dc76fd..66cb22672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.31.0] - 2026-09-16 +**The per-machine lock names the binary that wrote it, on every write +(PMAT-565, #565; paiml/infra#605 third signature).** Measured across four +fleet locks under one 1.30.0 binary: `generator: forjar 1.1.1` and `forjar +1.13.1` on two files rewritten in the same minute, `generated_at` current on +both — the field was stamped once when the lock was created and never touched +again, so the pair was a claim no version of forjar could have made. Now +`state::save_lock`, the one writer every path goes through, stamps +`generator` with the writing binary (exactly what `forjar --version` prints) +on every write, and moves the value it replaces into a new `created_by` the +first time, so the creator is named rather than erased and a lock written +before 1.31.0 still parses. `forjar lock --restamp --state-dir ` +rewrites every lock under a state dir through the same writer in one run, +sidecars included — `--dry-run` lists, `--json` reports, a second run changes +nothing — so a fleet converges in one command rather than on the next +incidental apply. Contract `contracts/lock-names-its-writer-v1.yaml`; five +cases through the writer and the binary, all RED before. + **`forjar drift` declines — exit 2, the count named — when it inspected none of the resources it was asked about, and never grades a resource from a manifest it was not given (PMAT-564, #564; paiml/infra#605 first diff --git a/contracts/lock-names-its-writer-v1.yaml b/contracts/lock-names-its-writer-v1.yaml new file mode 100644 index 000000000..4b756e024 --- /dev/null +++ b/contracts/lock-names-its-writer-v1.yaml @@ -0,0 +1,198 @@ +--- +metadata: + version: "1.0.0" + kind: pattern + created: "2026-09-15" + author: "PAIML Engineering" + description: | + The per-machine lock names the binary that WROTE it, on every write, and + keeps the name of the binary that created it (PMAT-565, forjar#565, + paiml/infra#605 third signature). + + Measured, four fleet locks under one 1.30.0 binary, 2026-09-15: + + state/yoga/state.lock.yaml generator: forjar 1.1.1 generated_at: 2026-09-15T09:19:01Z + state/gx10/state.lock.yaml generator: forjar 1.13.1 generated_at: 2026-09-15T09:18:56Z + machines/gx10/state/forjar.lock.yaml generator: forjar 1.27.0 + machines/gx10/state/gx10/state.lock.yaml generator: forjar 1.10.0 + + Two files rewritten in the same minute by the same binary, claiming two + different, long-gone writers. `state::new_lock` stamped `generator` once; + `executor::finalize_machine` refreshed `generated_at` on every apply; + nothing refreshed `generator`. The pair `(generator, generated_at)` was a + claim no version of forjar could have made — the artifact lied about + itself, the same class as exit-0-over-red. + + The fix lives in the ONE writer. `state::save_lock` serialises + `stamped_for_write(lock)`: `generator` becomes `writer_stamp()` (what + `forjar --version` prints) and, when `created_by` is unset, the value being + replaced becomes `created_by`. Done at the writer rather than at each + caller because a caller that forgets is exactly how the field went stale + for twenty-nine minor versions. + + references: + - "issue: https://github.com/paiml/forjar/issues/565" + - "issue: https://github.com/paiml/infra/issues/605 — third signature, the four-lock table" + - "issue: https://github.com/paiml/forjar/issues/561 — the host-resident lock, which asks for created_by/last_written_by; this contract lands the write-side half" + - "src/core/state/mod.rs::save_lock — the one writer" + - "src/core/state/mod.rs::stamped_for_write — the stamp" + - "src/core/state/mod.rs::writer_stamp — what the binary calls itself" + - "src/core/types/state_types.rs — StateLock.created_by, serde default" + - "src/cli/lock_restamp.rs::cmd_lock_restamp — the one-shot" + - "tests/falsification_lock_names_its_writer.rs — through the writer and the binary" + - "prior art: Terraform state carries `terraform_version` and rewrites it on every write; Nix profile generations record the nix version that built them; git objects carry no writer but every ref update is logged with the writer in reflog" + + quorum_validation: + - system: "Terraform (state file `terraform_version`, `serial`)" + adopted: | + Every write of the state file records the Terraform version that + wrote it, and refuses a state written by a NEWER version. forjar + had the field and never rewrote it. Adopted the every-write stamp; + the version-ordering refusal is forjar#561's. + encoded_as: "equation `every_write_names_its_writer`" + - system: "Nix (profile generations, `nix-env --list-generations`)" + adopted: | + A generation is immutable and records what produced it; a newer + generation is a new file, not an edit of the old one's header. + forjar's lock is rewritten in place, so the analogue is: the creator + is kept as its own field and the writer moves. + encoded_as: "equation `the_creator_is_kept`" + - system: "git (reflog)" + adopted: | + The object store records no writer, but every ref update is logged + with who moved it and when. forjar's `generated_at` was the `when` + with no `who`; `generator` is now the `who`. + encoded_as: "equation `every_write_names_its_writer`" + - system: "systemd (`/var/lib` unit state, `systemctl daemon-reload`)" + adopted: | + Persistent unit state is rewritten by the running manager and never + claims to be from an older one. A fleet-wide correction is one + command (`daemon-reload`), not a wait for each unit to be touched — + the shape of `lock --restamp`. + encoded_as: "equation `one_run_converges_the_fleet`" + +equations: + every_write_names_its_writer: + formula: | + save_lock(dir, L) ⟹ load_lock(dir, L.machine).generator = writer_stamp() + writer_stamp() = "forjar " ‖ CARGO_PKG_VERSION = stdout(forjar --version) + domain: "L any StateLock, whatever its in-memory generator says" + codomain: "the generator field of the file on disk" + invariants: + - "The struct's own `generator` is never what the file gets: a fake first-writer string is overwritten by the real writer" + - "Every path writes through save_lock — apply's finalize, --refresh, repair, restamp — so no path can forget" + - "`generated_at` and `generator` are now a pair one binary could have written" + + the_creator_is_kept: + formula: | + created_by(after) = created_by(before) if set + = generator(before) otherwise + domain: "before = the lock as loaded, after = the lock as written" + codomain: "the created_by field" + invariants: + - "Set once, never rolled: a second write by another version leaves it alone" + - "A legacy file with no created_by (every fleet lock before 1.31.0) gets its stale generator as created_by on its first write — `forjar 1.1.1` becomes a true fact about who wrote it first, not a false one about who wrote it last" + - "serde default: a pre-1.31.0 file still parses; skip_serializing_if keeps a fresh lock's YAML free of a null" + + one_run_converges_the_fleet: + formula: | + lock --restamp --state-dir D ⟹ ∀ m ∈ machines_with_locks(D): generator(D/m) = writer_stamp() + lock --restamp --dry-run ⟹ no file under D changes + lock --restamp; lock --restamp ⟹ the second run restamps 0 + domain: "D a state dir with N per-machine locks" + codomain: "the N files, and the report" + invariants: + - "Writes through save_lock, so the .b3 sidecar is rewritten with each lock" + - "Needs no manifest: the fleet's locks belong to many manifests" + - "An unreadable state dir is an error, never `0 lock(s) restamped`" + - "Each restamped lock is named with its before and after" + +proof_obligations: + - type: invariant + property: "A write stamps the real writer and keeps the creator" + formal: "L.generator = FAKE; save_lock(L) ⟹ file.generator = writer ∧ file.created_by = FAKE" + applies_to: all + enforced_by: "tests/falsification_lock_names_its_writer.rs::a_write_stamps_the_real_writer_and_keeps_the_creator" + notes: "THE REGRESSION. RED before: the file carried FAKE as its generator, exactly as the fleet's locks carry 1.1.1." + + - type: invariant + property: "The creator is set once and the writer every time" + formal: "two writes with different in-memory generators ⟹ created_by = the first, generator = writer" + applies_to: all + enforced_by: "tests/falsification_lock_names_its_writer.rs::the_creator_is_set_once_and_the_writer_every_time" + + - type: invariant + property: "A legacy lock migrates its stale generator into created_by" + formal: "file without created_by, generator = 'forjar 1.1.1'; load; save ⟹ generator = writer ∧ created_by = 'forjar 1.1.1'" + applies_to: all + enforced_by: "tests/falsification_lock_names_its_writer.rs::a_legacy_lock_migrates_its_stale_generator_into_created_by" + + - type: invariant + property: "The apply path rewrites the writer" + formal: "forge a legacy lock; forjar apply ⟹ generator = writer ∧ created_by = the forged value" + applies_to: all + enforced_by: "tests/falsification_lock_names_its_writer.rs::apply_rewrites_the_writer" + + - type: invariant + property: "lock --restamp converges every lock in one run, dry-run writes nothing, a second run is a no-op" + formal: "3 legacy locks; --dry-run ⟹ unchanged; run ⟹ 3 restamped, sidecars present; run ⟹ 0 restamped" + applies_to: all + enforced_by: "tests/falsification_lock_names_its_writer.rs::lock_restamp_converges_every_lock_in_one_run" + + - type: invariant + property: "An unreadable state dir is an error, and a current lock is left alone" + formal: "restamp(missing dir) = Err; restamp(dir with a current lock) rewrites 0 bytes" + applies_to: all + enforced_by: "src/cli/lock_restamp.rs::tests::an_unreadable_state_dir_is_an_error_not_zero, a_lock_already_naming_this_binary_is_left_alone" + +enforcement: + the_stamp_is_in_the_writer: + description: | + `save_lock` MUST serialise `stamped_for_write(lock)`, never `lock`. + Moving the stamp to callers reintroduces the class: one caller that + forgets, and the field is stale again for another twenty-nine versions. + check: "src/core/state/mod.rs::save_lock" + severity: "ERROR" + +falsification_tests: + - id: FALSIFY-WRITER-001 + rule: "every_write_names_its_writer" + prediction: "Serialising `lock` instead of `stamped_for_write(lock)` in save_lock turns four of the five cases red" + test: "tests/falsification_lock_names_its_writer.rs::a_write_stamps_the_real_writer_and_keeps_the_creator" + if_fails: "The file carries whatever the struct carried — the fleet's 1.1.1, again" + measured: "5 of 5 RED before src/core/state/mod.rs::stamped_for_write existed (the restamp case for the missing flag, the other four for the stale field)." + + - id: FALSIFY-WRITER-002 + rule: "the_creator_is_kept" + prediction: "Dropping the `created_by.is_none()` guard (overwriting the creator on every write) turns the set-once case red" + test: "tests/falsification_lock_names_its_writer.rs::the_creator_is_set_once_and_the_writer_every_time" + if_fails: "created_by rolls with generator and records nothing generator does not" + + - id: FALSIFY-WRITER-003 + rule: "the_creator_is_kept" + prediction: "Leaving created_by unset when the loaded file had none turns the legacy case red" + test: "tests/falsification_lock_names_its_writer.rs::a_legacy_lock_migrates_its_stale_generator_into_created_by" + if_fails: "The fleet's first writers are erased on the first restamp" + + - id: FALSIFY-WRITER-004 + rule: "one_run_converges_the_fleet" + prediction: "Writing on --dry-run, or skipping the sidecar by bypassing save_lock, turns the restamp case red" + test: "tests/falsification_lock_names_its_writer.rs::lock_restamp_converges_every_lock_in_one_run" + if_fails: "A preview that writes, or a restamp that leaves every .b3 stale and the next apply refused on integrity" + +# No kani_harnesses. `metadata.kind: pattern`: the property is the content of +# a file after a write through the binary; the falsifiers exercise the writer +# and the binary rather than a model of them. + +qa_gate: + id: F-WRITER-001 + name: "Lock Names Its Writer" + description: "generator is the binary that wrote the file, on every write; created_by is the first writer, kept; lock --restamp converges a fleet in one run" + checks: + - "every_write_names_its_writer" + - "the_creator_is_kept" + - "one_run_converges_the_fleet" + pass_criteria: "All 4 falsification tests pass" + falsification: "Serialise `lock` instead of `stamped_for_write(lock)` in src/core/state/mod.rs::save_lock" + +verification_level: L3 diff --git a/docs/book/src/08-state-management.md b/docs/book/src/08-state-management.md index 350718189..0d8157e94 100644 --- a/docs/book/src/08-state-management.md +++ b/docs/book/src/08-state-management.md @@ -103,7 +103,8 @@ schema: '1.0' machine: intel hostname: mac-server generated_at: 2026-02-16T16:44:39Z -generator: forjar 0.1.0 +generator: forjar 1.31.0 # the binary that wrote this file, every write +created_by: forjar 0.1.0 # the first writer, kept blake3_version: '1.8' resources: bash-aliases: @@ -534,8 +535,9 @@ Every lock file follows this schema: schema: '1.0' # Lock file format version machine: web-server # Machine key from config hostname: web1 # Machine hostname -generated_at: 2026-02-25T14:00:00Z # ISO 8601 UTC timestamp -generator: forjar 0.1.0 # Generator string +generated_at: 2026-02-25T14:00:00Z # ISO 8601 UTC timestamp of the last write +generator: forjar 1.31.0 # the binary that wrote the file (every write) +created_by: forjar 0.1.0 # the first writer, set once (absent on pre-1.31.0 locks) blake3_version: '1.8' # BLAKE3 library version resources: # Map of resource_id → ResourceLock resource-name: @@ -561,11 +563,19 @@ The following tables document every field in the `StateLock` and `ResourceLock` | `schema` | `String` | `schema` | (required) | Lock file format version, currently `"1.0"` | | `machine` | `String` | `machine` | (required) | Machine key from the forjar.yaml config | | `hostname` | `String` | `hostname` | (required) | Machine hostname as declared in config | -| `generated_at` | `String` | `generated_at` | (required) | ISO 8601 UTC timestamp of lock generation | -| `generator` | `String` | `generator` | (required) | Generator string, e.g. `"forjar 0.1.0"` | +| `generated_at` | `String` | `generated_at` | (required) | ISO 8601 UTC timestamp of the last write | +| `generator` | `String` | `generator` | (required) | The binary that WROTE the file, e.g. `"forjar 1.31.0"` — what `forjar --version` prints; stamped on every write since 1.31.0 (PMAT-565) | +| `created_by` | `Option` | `created_by` | absent | The first writer — the `generator` value the first stamping write replaced. Set once, never rolled; absent on locks written before 1.31.0 until their next write | | `blake3_version` | `String` | `blake3_version` | (required) | BLAKE3 library version used for hashing, e.g. `"1.8"` | | `resources` | `IndexMap` | `resources` | (required) | Ordered map of resource ID to resource lock entry | +Until 1.31.0 `generator` was stamped once, when the lock was first created, +and never touched again while `generated_at` rolled on every apply — four +fleet locks under one 1.30.0 binary said `forjar 1.1.1`, `1.13.1`, `1.27.0` +and `1.10.0` (paiml/infra#605). `forjar lock --restamp --state-dir ` +rewrites every lock under a state dir through the writer in one run (with +`--dry-run` to list, `--json` to report); a second run changes nothing. + #### ResourceLock Fields | Field | Rust Type | YAML Key | Default | Description | From 4f4dc6a0b1eb609e538edc0fd734275464088f7a Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 15 Sep 2026 18:16:53 +0200 Subject: [PATCH 04/18] fix(PMAT-565): lock-repair and lock-migrate write through the writer; restamp's scope is stated (Refs #565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of three review lanes read src/cli/lock_repair.rs and lock_audit.rs: the minimal repaired lock, the normalised form and the migrated schema were written with a bare fs::write — unstamped, and with the .b3 sidecar left stale for the next apply to refuse. All three go through save_lock now, with a falsifier through the binary. lock-restore and lock-tag copy bytes and are named as the exception. The CHANGELOG, book and contract no longer say 'every lock under a state dir': restamp walks /state.lock.yaml one level, once per workspace dir, and leaves the global lock (restamped by every apply) and .yaml.age alone — stated, not implied. Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 25 +++++++---- contracts/lock-names-its-writer-v1.yaml | 19 ++++++++- docs/book/src/08-state-management.md | 7 +++- src/cli/lock_audit.rs | 5 +-- src/cli/lock_repair.rs | 17 +++++--- src/core/state/mod.rs | 2 +- tests/falsification_lock_names_its_writer.rs | 44 ++++++++++++++++++++ 7 files changed, 97 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66cb22672..2462e7258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,15 +15,22 @@ fleet locks under one 1.30.0 binary: `generator: forjar 1.1.1` and `forjar 1.13.1` on two files rewritten in the same minute, `generated_at` current on both — the field was stamped once when the lock was created and never touched again, so the pair was a claim no version of forjar could have made. Now -`state::save_lock`, the one writer every path goes through, stamps -`generator` with the writing binary (exactly what `forjar --version` prints) -on every write, and moves the value it replaces into a new `created_by` the -first time, so the creator is named rather than erased and a lock written -before 1.31.0 still parses. `forjar lock --restamp --state-dir ` -rewrites every lock under a state dir through the same writer in one run, -sidecars included — `--dry-run` lists, `--json` reports, a second run changes -nothing — so a fleet converges in one command rather than on the next -incidental apply. Contract `contracts/lock-names-its-writer-v1.yaml`; five +`state::save_lock` stamps `generator` with the writing binary (exactly what +`forjar --version` prints) on every write, and moves the value it replaces +into a new `created_by` the first time, so the creator is named rather than +erased and a lock written before 1.31.0 still parses. Every path that writes +a `StateLock` goes through it: two review lanes found `lock-repair` and +`lock-migrate` writing with a bare `fs::write` — unstamped, and with a stale +`.b3` sidecar that the next apply refused on — and both go through the writer +now; `lock-restore` and `lock-tag` copy bytes rather than write a lock, and +are named as such in the contract. `forjar lock --restamp --state-dir ` +rewrites every `/state.lock.yaml` directly under a state dir through +the same writer in one run, sidecars included — `--dry-run` lists, `--json` +reports, a second run changes nothing — so a fleet converges in one command +rather than on the next incidental apply. It does not descend into +`--workspace` subdirectories (run it once per workspace dir), does not touch +`forjar.lock.yaml` (the global lock, which `apply` already restamps), and +skips encrypted `.yaml.age` locks. Contract `contracts/lock-names-its-writer-v1.yaml`; five cases through the writer and the binary, all RED before. **`forjar drift` declines — exit 2, the count named — when it inspected none diff --git a/contracts/lock-names-its-writer-v1.yaml b/contracts/lock-names-its-writer-v1.yaml index 4b756e024..1c0bd4ac9 100644 --- a/contracts/lock-names-its-writer-v1.yaml +++ b/contracts/lock-names-its-writer-v1.yaml @@ -80,7 +80,8 @@ equations: codomain: "the generator field of the file on disk" invariants: - "The struct's own `generator` is never what the file gets: a fake first-writer string is overwritten by the real writer" - - "Every path writes through save_lock — apply's finalize, --refresh, repair, restamp — so no path can forget" + - "Every path that writes a StateLock goes through save_lock — apply's finalize, --refresh, apply --drift, lock, lock-repair (both the minimal lock and the normalised form), lock-migrate, restamp. lock-repair and lock-migrate used a bare fs::write until the review quorum read them; that also left the .b3 sidecar stale" + - "lock-restore copies a snapshot's bytes and lock-tag prepends a comment to the file's bytes: neither serialises a StateLock, neither stamps, and the next stamping write moves whatever generator they left into created_by" - "`generated_at` and `generator` are now a pair one binary could have written" the_creator_is_kept: @@ -104,6 +105,7 @@ equations: invariants: - "Writes through save_lock, so the .b3 sidecar is rewritten with each lock" - "Needs no manifest: the fleet's locks belong to many manifests" + - "Scope is `//state.lock.yaml`, one level: a --workspace dir is a state dir of its own and is restamped by naming it; forjar.lock.yaml (the GlobalLock) is restamped by every apply through state::stamp and is not walked; an encrypted .yaml.age lock is not walked" - "An unreadable state dir is an error, never `0 lock(s) restamped`" - "Each restamped lock is named with its before and after" @@ -139,6 +141,13 @@ proof_obligations: applies_to: all enforced_by: "tests/falsification_lock_names_its_writer.rs::lock_restamp_converges_every_lock_in_one_run" + - type: invariant + property: "lock-repair writes through the writer, sidecar included" + formal: "unparseable lock; lock-repair ⟹ generator = writer ∧ created_by = 'forjar-repair' ∧ .b3 exists" + applies_to: all + enforced_by: "tests/falsification_lock_names_its_writer.rs::lock_repair_writes_through_the_writer_sidecar_included" + notes: "Found by the review quorum: lock-repair and lock-migrate wrote with a bare fs::write, so the contract's own 'every path' sentence was false when first written, and the sidecar those paths left behind was stale." + - type: invariant property: "An unreadable state dir is an error, and a current lock is left alone" formal: "restamp(missing dir) = Err; restamp(dir with a current lock) rewrites 0 bytes" @@ -174,6 +183,12 @@ falsification_tests: test: "tests/falsification_lock_names_its_writer.rs::a_legacy_lock_migrates_its_stale_generator_into_created_by" if_fails: "The fleet's first writers are erased on the first restamp" + - id: FALSIFY-WRITER-005 + rule: "every_write_names_its_writer" + prediction: "Restoring the bare fs::write in src/cli/lock_repair.rs turns the repair case red on the generator and on the missing sidecar" + test: "tests/falsification_lock_names_its_writer.rs::lock_repair_writes_through_the_writer_sidecar_included" + if_fails: "A repaired lock names forjar-repair as its writer forever, and the next apply is refused on integrity" + - id: FALSIFY-WRITER-004 rule: "one_run_converges_the_fleet" prediction: "Writing on --dry-run, or skipping the sidecar by bypassing save_lock, turns the restamp case red" @@ -192,7 +207,7 @@ qa_gate: - "every_write_names_its_writer" - "the_creator_is_kept" - "one_run_converges_the_fleet" - pass_criteria: "All 4 falsification tests pass" + pass_criteria: "All 5 falsification tests pass" falsification: "Serialise `lock` instead of `stamped_for_write(lock)` in src/core/state/mod.rs::save_lock" verification_level: L3 diff --git a/docs/book/src/08-state-management.md b/docs/book/src/08-state-management.md index 0d8157e94..8a94c93d9 100644 --- a/docs/book/src/08-state-management.md +++ b/docs/book/src/08-state-management.md @@ -573,8 +573,11 @@ Until 1.31.0 `generator` was stamped once, when the lock was first created, and never touched again while `generated_at` rolled on every apply — four fleet locks under one 1.30.0 binary said `forjar 1.1.1`, `1.13.1`, `1.27.0` and `1.10.0` (paiml/infra#605). `forjar lock --restamp --state-dir ` -rewrites every lock under a state dir through the writer in one run (with -`--dry-run` to list, `--json` to report); a second run changes nothing. +rewrites every `/state.lock.yaml` directly under a state dir through +the writer in one run (with `--dry-run` to list, `--json` to report); a second +run changes nothing. Run it once per `--workspace` dir; it leaves +`forjar.lock.yaml` (restamped by every apply) and encrypted `.yaml.age` locks +alone. #### ResourceLock Fields diff --git a/src/cli/lock_audit.rs b/src/cli/lock_audit.rs index 1d883a7b2..0db0e19ff 100644 --- a/src/cli/lock_audit.rs +++ b/src/cli/lock_audit.rs @@ -329,9 +329,8 @@ pub(crate) fn cmd_lock_migrate( if let Ok(mut lock) = serde_yaml_ng::from_str::(&data) { if lock.schema == from_version && lock.schema != target_version { lock.schema = target_version.to_string(); - let new_data = serde_yaml_ng::to_string(&lock) - .map_err(|e| format!("Failed to serialize: {e}"))?; - std::fs::write(&lock_path, new_data) + // PMAT-565: through the one writer — stamped, sidecar rewritten. + crate::core::state::save_lock(state_dir, &lock) .map_err(|e| format!("Failed to write: {e}"))?; migrated += 1; } diff --git a/src/cli/lock_repair.rs b/src/cli/lock_repair.rs index f5056d7af..b1da7d7a2 100644 --- a/src/cli/lock_repair.rs +++ b/src/cli/lock_repair.rs @@ -37,8 +37,11 @@ pub(crate) fn cmd_lock_repair(state_dir: &Path, json: bool) -> Result<(), String blake3_version: "1.5".to_string(), resources: indexmap::IndexMap::new(), }; - if let Ok(yaml) = serde_yaml_ng::to_string(&minimal) { - let _ = std::fs::write(&lock_path, yaml); + // PMAT-565: through the one writer, so the repaired lock names + // this binary (`forjar-repair` survives as its created_by) and + // its .b3 sidecar is rewritten with it — a bare fs::write left + // the sidecar stale and the next apply refused on integrity. + if crate::core::state::save_lock(state_dir, &minimal).is_ok() { repaired += 1; } } @@ -93,10 +96,14 @@ pub(crate) fn cmd_lock_normalize(state_dir: &Path, json: bool) -> Result<(), Str } let content = std::fs::read_to_string(&lock_path).unwrap_or_default(); if let Ok(lock) = serde_yaml_ng::from_str::(&content) { - let new_content = serde_yaml_ng::to_string(&lock) - .map_err(|e| format!("Failed to serialize lock: {e}"))?; + // PMAT-565: compare against what the writer would WRITE, and write + // through it — a normalised lock names this binary and keeps its + // sidecar consistent. + let new_content = + serde_yaml_ng::to_string(&crate::core::state::stamped_for_write(&lock)) + .map_err(|e| format!("Failed to serialize lock: {e}"))?; if new_content != content { - std::fs::write(&lock_path, &new_content) + crate::core::state::save_lock(state_dir, &lock) .map_err(|e| format!("Failed to write lock: {e}"))?; normalized += 1; } diff --git a/src/core/state/mod.rs b/src/core/state/mod.rs index 117648b02..12984ffe1 100644 --- a/src/core/state/mod.rs +++ b/src/core/state/mod.rs @@ -50,7 +50,7 @@ pub fn writer_stamp() -> String { /// 1.1.1, 1.13.1, 1.27.0 and 1.10.0, two of them rewritten in the same /// minute. The pair was a claim no version of forjar could have made. /// -/// Done HERE, in the one writer every path goes through, rather than at each +/// Done HERE, in the one writer every StateLock write goes through, rather than at each /// caller: a caller that forgets is exactly how the field went stale. The /// creator is preserved, not erased — `forjar 1.1.1` is a true fact about /// who wrote the file first, and `forjar-refresh 1.x` / `(reconstructed)` diff --git a/tests/falsification_lock_names_its_writer.rs b/tests/falsification_lock_names_its_writer.rs index c7a59ac20..305c2dfae 100644 --- a/tests/falsification_lock_names_its_writer.rs +++ b/tests/falsification_lock_names_its_writer.rs @@ -288,3 +288,47 @@ fn lock_restamp_converges_every_lock_in_one_run() { String::from_utf8_lossy(&again.stdout) ); } + +/// The paths that used to bypass the writer. `lock-repair` wrote a minimal +/// lock and the normalised form with a bare `fs::write`, and `lock-migrate` +/// did the same — none stamped, and none rewrote the `.b3` sidecar, so the +/// next apply was refused on integrity. Found by the review quorum (two lanes +/// of three); every StateLock write is through `save_lock` now. +#[test] +fn lock_repair_writes_through_the_writer_sidecar_included() { + let dir = tempfile::tempdir().unwrap(); + let state = dir.path().join("state"); + fs::create_dir_all(state.join("broken")).unwrap(); + fs::write( + state.join("broken").join("state.lock.yaml"), + "this: is: not: a lock\n", + ) + .unwrap(); + + let out = Command::new(FORJAR) + .args(["lock-repair", "--state-dir", state.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let back = load_lock(&state, "broken") + .unwrap() + .expect("repaired lock parses"); + assert_eq!( + back.generator, + writer(), + "the repaired lock must name the binary that wrote it" + ); + assert_eq!( + back.created_by.as_deref(), + Some("forjar-repair"), + "and keep the repair marker as its creator" + ); + assert!( + state.join("broken").join("state.lock.yaml.b3").exists(), + "a lock written without its sidecar is refused by the next apply" + ); +} From fd792538465090b41263e44a126d5ed922cdd052 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 15 Sep 2026 18:26:03 +0200 Subject: [PATCH 05/18] docs(PMAT-565): the contract stops saying a word gate G reads as governing a verb it does not (Refs #565) Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- contracts/lock-names-its-writer-v1.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/lock-names-its-writer-v1.yaml b/contracts/lock-names-its-writer-v1.yaml index 1c0bd4ac9..f2bf70819 100644 --- a/contracts/lock-names-its-writer-v1.yaml +++ b/contracts/lock-names-its-writer-v1.yaml @@ -105,7 +105,7 @@ equations: invariants: - "Writes through save_lock, so the .b3 sidecar is rewritten with each lock" - "Needs no manifest: the fleet's locks belong to many manifests" - - "Scope is `//state.lock.yaml`, one level: a --workspace dir is a state dir of its own and is restamped by naming it; forjar.lock.yaml (the GlobalLock) is restamped by every apply through state::stamp and is not walked; an encrypted .yaml.age lock is not walked" + - "Scope is `//state.lock.yaml`, one level: a nested state dir (`state//`, what `-w` selects) is a state dir of its own and is restamped by naming it; forjar.lock.yaml (the GlobalLock) is restamped by every apply through state::stamp and is not walked; an encrypted .yaml.age lock is not walked" - "An unreadable state dir is an error, never `0 lock(s) restamped`" - "Each restamped lock is named with its before and after" From 7a46a22bf867ba116ae14b5a2b3de23432769331 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 15 Sep 2026 18:36:21 +0200 Subject: [PATCH 06/18] docs(PMAT-565): receipt, quorum evidence, estimates row; M1 kills 6 of 6, measured (Refs #565) Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- .quorum/evidence/lock-writer-agy.md | 23 ++++++ .quorum/evidence/lock-writer-claims.md | 35 +++++++++ .quorum/evidence/lock-writer-judges.md | 82 +++++++++++++++++++++ .quorum/evidence/lock-writer-lanes.md | 50 +++++++++++++ .quorum/evidence/lock-writer-pmat.md | 35 +++++++++ CHANGELOG.md | 5 +- contracts/lock-names-its-writer-v1.yaml | 4 +- docs/audits/impl-PMAT-565-receipt.md | 97 +++++++++++++++++++++++++ docs/audits/impl-estimates.jsonl | 1 + 9 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 .quorum/evidence/lock-writer-agy.md create mode 100644 .quorum/evidence/lock-writer-claims.md create mode 100644 .quorum/evidence/lock-writer-judges.md create mode 100644 .quorum/evidence/lock-writer-lanes.md create mode 100644 .quorum/evidence/lock-writer-pmat.md create mode 100644 docs/audits/impl-PMAT-565-receipt.md diff --git a/.quorum/evidence/lock-writer-agy.md b/.quorum/evidence/lock-writer-agy.md new file mode 100644 index 000000000..edf91ff60 --- /dev/null +++ b/.quorum/evidence/lock-writer-agy.md @@ -0,0 +1,23 @@ +# PMAT-565 — the agy round + + lane=quorum width=3 writes=false sandbox=true mode=plan + schema=quorum-lane-schema.json timeout=25m + out_dir=.../paiml-implement/agy/PMAT-565//ph3 + not_before=1789488090 (the dispatch instant) + repo_root=, reviewed_commit=a0070731 + base for the diff: PMAT-564-drift-declines-on-empty-scope, not main + +Composed by the paiml-agy-delegate through `agy-lane.sh --repo-root +`; each lane in a self-contained sandbox clone, tree witness +asserted before, byte-identical after, removed. The dispatch-local model +config was named `models.config.json` so `fanout.sh` counted three children, +not four (the PMAT-564 round's edge). No `--concurrent-scope` was declared. +Every lane was briefed NO WRITES and none wrote; no KEPT, no exit 3, no +exit 4. The delegate returned within budget (20 tool uses). + +A first dispatch of this round was interrupted by the operator before any +lane launched; the round above is the re-dispatch, with a fresh +`--not-before`. + +Result: 2 FAIL / 1 PASS, `agreed=false`. The FAILs were re-executed here and +acted on in adad4e6e. diff --git a/.quorum/evidence/lock-writer-claims.md b/.quorum/evidence/lock-writer-claims.md new file mode 100644 index 000000000..9cddef2e1 --- /dev/null +++ b/.quorum/evidence/lock-writer-claims.md @@ -0,0 +1,35 @@ +# PMAT-565 — the claims put to the round + +The branch makes the per-machine lock name the binary that WROTE it, on +every write, and keep the binary that created it: `state::save_lock` +serialises `stamped_for_write(lock)` — `generator` := this binary, +`created_by` := the value replaced, once — and `forjar lock --restamp` walks +a state dir and writes every stale lock through it in one run. Measured +before: four fleet locks under one 1.30.0 binary said 1.1.1, 1.13.1, 1.27.0 +and 1.10.0 (paiml/infra#605, third signature). + +Three lanes, read-only, sandboxed, against self-contained clones of the PR +worktree at `a0070731`, diffed against the base branch +`PMAT-564-drift-declines-on-empty-scope`, dispatched in one message with +`--not-before` pinned and `out_dir` keyed by ticket AND session id. Models +named in the brief (gemini-3.1-pro-high twice, gemini-3.7-flash-high once; +gemini-3.8-flash-high had 503'd all day). + +The questions, identical to every lane: + +1. Every writer: is there any path that writes a per-machine lock WITHOUT + `save_lock` — a bare `serde_yaml_ng::to_string` + `fs::write`? +2. `created_by` semantics: a fresh lock, an empty generator, `lock-audit`'s + `starts_with("forjar")`. +3. Compatibility: an older forjar parsing the file; the `.b3` sidecar under + restamp; encrypted `.yaml.age` locks. +4. What `lock --restamp` walks and what it misses — nested state dirs, + `forjar.lock.yaml`, `.yaml.age` — and whether that is stated. +5. The contract, CHANGELOG and book: quote any false sentence; do the + falsifiers' mutations turn their tests red? +6. The 137 scripted `created_by: None` insertions: any misplaced into a + GlobalLock or StackStamp literal? + +The acceptance command every lane was told to run: +`falsification_lock_names_its_writer`, `cli::lock_restamp core::state`, +`falsification_contract_citations_resolve`. diff --git a/.quorum/evidence/lock-writer-judges.md b/.quorum/evidence/lock-writer-judges.md new file mode 100644 index 000000000..7bd171714 --- /dev/null +++ b/.quorum/evidence/lock-writer-judges.md @@ -0,0 +1,82 @@ +# PMAT-565 — adjudicated claims + +One round of three sandboxed agy quorum lanes: 2 FAIL, 1 PASS, not agreed. +Five confirmations and four refutations, every one re-measured on this host +before it was acted on. The stamp held; the branch's claim that it sat on the +ONLY writer did not, and the one lane that said so was the one that was wrong. + +## CONFIRMED + +1. [stamp] That `save_lock` writes the real writer whatever the struct + carried, and keeps the replaced value as the creator once (all three + lanes; lane 1 measured). + - evidence: `src/core/state/mod.rs:58` — `stamped_for_write` sets + `created_by` only when None, then `generator = writer_stamp()`; + `src/core/state/mod.rs:79` is the write. The fixture at + `tests/falsification_lock_names_its_writer.rs:50` writes a fake first + writer and reads the real one back; mutation M1 (serialise the raw lock) + kills five of six cases. + +2. [empty-generator] That an empty generator leaves `created_by` None and + `lock-audit`'s `starts_with("forjar")` passes on every write (lane 1 + measured). + - evidence: the `!out.generator.is_empty()` guard at + `src/core/state/mod.rs:58`; the written generator is always + `forjar `. + +3. [compat] That an older forjar parses the file (no `deny_unknown_fields` + on `StateLock`), the `.b3` sidecar stays consistent under restamp, and + encrypted locks are untouched (lanes 1 and 2). + - evidence: `src/core/types/state_types.rs:100` carries serde default + and skip_serializing_if; restamp writes through `save_lock`, which + writes the sidecar, asserted at + `tests/falsification_lock_names_its_writer.rs:213`. + +4. [literals] That none of the scripted `created_by: None` insertions landed + in a GlobalLock or StackStamp literal (lanes 1 and 2). + - evidence: neither struct has the field, so a misplacement is a compile + error, and `cargo check --all-targets` is clean. + +5. [falsifiers] That each contract falsifier's mutation turns its cited test + red (lanes 1 and 2 reasoned; run here). + - evidence: M1–M4 in the pmat digest. + +## REFUTED + +1. [one-writer] That "every path writes through save_lock — apply's + finalize, --refresh, repair, restamp" (this author, in the contract as + first written, and "the one writer every path goes through" in the + CHANGELOG). + - corrected: lanes 1 and 3 read `src/cli/lock_repair.rs` and found the + minimal repaired lock and the normalised form written with a bare + `fs::write`; lane 1 added `lock-migrate`. All three go through the + writer now — `src/cli/lock_repair.rs:44`, `src/cli/lock_repair.rs:106`, + `src/cli/lock_audit.rs:333` — with a falsifier at + `tests/falsification_lock_names_its_writer.rs:298` that also checks the + `.b3` sidecar those paths used to leave stale for the next apply to + refuse on. `lock-restore` and `lock-tag` copy bytes and are named as the + exception in the contract. + +2. [restamp-scope] That restamp "rewrites every lock under a state dir" + (this author, `CHANGELOG.md:10` as first written). + - corrected: it walks `//state.lock.yaml`, one level. + Lane 1 named the three things that leaves out — a nested state dir, + `forjar.lock.yaml` (restamped by every apply through `state::stamp`), + and encrypted `.yaml.age` locks. The CHANGELOG, the book and the + contract now say exactly that; the walk itself is unchanged, because a + nested dir is a state dir of its own and is restamped by naming it. + +3. [no-bypass] That "no direct serde_yaml_ng + fs::write exists in src/ for + StateLock" (lane 2, the one distinct model, graded measured). + - corrected: it did, at the three sites above. The two lanes that shared + a model id found it; the distinct one missed it — the opposite of the + PMAT-564 round, where the two resamples were wrong together. Recorded + because the duplicate-model caveat cuts both ways. + +4. [no-writes] That the review could not be interrupted without cost (this + author's assumption in dispatching it). + - corrected: the first dispatch was interrupted by the operator before + any lane launched; a second dispatch with a fresh `--not-before` ran the + round. Nothing from the first reached disk; the re-dispatch is the one + the numbers above describe. Named so the receipt's single round is not + read as a single attempt. diff --git a/.quorum/evidence/lock-writer-lanes.md b/.quorum/evidence/lock-writer-lanes.md new file mode 100644 index 000000000..6c827f60e --- /dev/null +++ b/.quorum/evidence/lock-writer-lanes.md @@ -0,0 +1,50 @@ +# PMAT-565 — the lanes, and what each returned + +One round of three sandboxed agy quorum lanes (`agy-lane.sh --mode plan`, +schema-enforced verdicts), review-only, each clone asserted byte-identical +afterwards and removed. Author model: Claude Opus 5; every lane Gemini, +measured from its own log. + +| lane | model (measured) | verdict | findings | duration | +|---|---|---|---|---| +| 1 (lane-1.json) | gemini-3.1-pro-high | FAIL | 6 | 235 s | +| 2 (lane-2.json) | gemini-3.7-flash-high | PASS | 6 | 340 s | +| 3 (lane-3.json) | gemini-3.1-pro-high | FAIL | 1 | 251 s | + +`lane-reduce.sh --width 3 --lane-models gemini-3.1-pro-high,gemini-3.7-flash-high,gemini-3.1-pro-high +--author-model opus` → `agreed=false` (2 FAIL / 1 PASS); `partial_reasons` +records the duplicate model id. Conversation ids shortened: conv-f90c4be0, +conv-b2b2e525, conv-9c9d7a67. + +## Lanes 1 and 3 — FAIL, on the same line, and they were right + +Both named `src/cli/lock_repair.rs` writing a `StateLock` with a bare +`fs::write` — the minimal repaired lock and the normalised form — and lane 1 +added `src/cli/lock_audit.rs` (`lock-migrate` at :334; `lock-restore` at +:234 and `lock-tag` at :300, which copy bytes). Lane 1 graded it `measured`, +lane 3 `asserted`. The contract sentence "Every path writes through +save_lock — apply's finalize, --refresh, repair, restamp" and the CHANGELOG's +"the one writer every path goes through" were therefore false as written. +Lane 1 also called restamp's one-level walk a gap against a CHANGELOG +sentence ("rewrites every lock under a state dir") that did say that. + +Lane 1 answered the rest: an empty generator leaves `created_by` None and the +stamped generator satisfies `lock-audit`; no `deny_unknown_fields` on +`StateLock`, so an older forjar parses the file; the sidecar is consistent +because restamp writes through `save_lock`; no misplaced literal — a +`created_by` inside a GlobalLock or StackStamp literal would not compile. + +## Lane 2 — PASS, over a claim the other two refuted + +The one distinct model. It ran the acceptance suites and stated "no direct +serde_yaml_ng + fs::write exists in src/ for StateLock" — the opposite of +what lanes 1 and 3 found, and wrong: the sites are at `lock_repair.rs:41` +and `:99` (as reviewed) and `lock_audit.rs:334`. + +## What the orchestrator re-ran + +The five sites were read here. `lock-repair` (both writes) and +`lock-migrate` now go through `save_lock`, with a falsifier through the +binary that also checks the `.b3` sidecar those paths used to leave stale. +`lock-restore` and `lock-tag` copy bytes and are named as the exception. The +CHANGELOG, book and contract state restamp's scope instead of implying it. diff --git a/.quorum/evidence/lock-writer-pmat.md b/.quorum/evidence/lock-writer-pmat.md new file mode 100644 index 000000000..36a792bff --- /dev/null +++ b/.quorum/evidence/lock-writer-pmat.md @@ -0,0 +1,35 @@ +# PMAT-565 — instruments, and what each said + + cargo test --workspace --no-fail-fast 341 targets at a0070731: 19,795 + passed, 0 failed (run alone; the + shared target dir was quiet) + cargo clippy --all-targets -D warnings clean, before and after the + repair/migrate change + cargo fmt --all -- --check clean + cargo check --all-targets clean over the 137 + 3 literal + patches (a misplaced created_by + would not compile) + pv validate contracts/lock-names-its-writer-v1.yaml Contract is valid. + the RED proof the suite compiled against the + new field with no behaviour: + 5 of 5 red (four on the stale + generator, one on the missing + --restamp flag) + +Mutations over the COMMITTED tree, each restored with `git checkout HEAD --`: + + M1 serialise `lock` instead of `stamped_for_write(lock)` in save_lock + → 6 of 6 red, every case in the suite (an earlier draft of this + file said 5 of 6 before the mutation had been run; it was run + and the number is the measurement) + M2 drop the `created_by.is_none()` guard (roll the creator every write) + → the set-once case red + M3 write on --dry-run in lock_restamp + → the restamp case red (dry-run must write nothing) + M4 restore the bare fs::write in lock_repair (minimal lock) + → exactly the repair case red + +Gate B: FAIL on main's CB-21xx debt, equal or better here (the PMAT-565 row +carries release: 1.31.0 and its issue is on the milestone). Gates C, D, G: +run on the 564 head this branch stacks on; G re-run here after the contract +stopped using a word the ratchet reads as governing a verb. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2462e7258..5b4565d20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,9 @@ reports, a second run changes nothing — so a fleet converges in one command rather than on the next incidental apply. It does not descend into `--workspace` subdirectories (run it once per workspace dir), does not touch `forjar.lock.yaml` (the global lock, which `apply` already restamps), and -skips encrypted `.yaml.age` locks. Contract `contracts/lock-names-its-writer-v1.yaml`; five -cases through the writer and the binary, all RED before. +skips encrypted `.yaml.age` locks. Contract `contracts/lock-names-its-writer-v1.yaml`; six +cases through the writer and the binary — five RED before the stamp existed, +the sixth (`lock-repair`) added when the review found the bypass. **`forjar drift` declines — exit 2, the count named — when it inspected none of the resources it was asked about, and never grades a resource from a diff --git a/contracts/lock-names-its-writer-v1.yaml b/contracts/lock-names-its-writer-v1.yaml index f2bf70819..ec6407ad9 100644 --- a/contracts/lock-names-its-writer-v1.yaml +++ b/contracts/lock-names-its-writer-v1.yaml @@ -166,10 +166,10 @@ enforcement: falsification_tests: - id: FALSIFY-WRITER-001 rule: "every_write_names_its_writer" - prediction: "Serialising `lock` instead of `stamped_for_write(lock)` in save_lock turns four of the five cases red" + prediction: "Serialising `lock` instead of `stamped_for_write(lock)` in save_lock turns every case in the suite red" test: "tests/falsification_lock_names_its_writer.rs::a_write_stamps_the_real_writer_and_keeps_the_creator" if_fails: "The file carries whatever the struct carried — the fleet's 1.1.1, again" - measured: "5 of 5 RED before src/core/state/mod.rs::stamped_for_write existed (the restamp case for the missing flag, the other four for the stale field)." + measured: "5 of 5 RED before src/core/state/mod.rs::stamped_for_write existed (the restamp case for the missing flag, the other four for the stale field). With the suite at six cases, the mutation run against the committed tree kills 6 of 6." - id: FALSIFY-WRITER-002 rule: "the_creator_is_kept" diff --git a/docs/audits/impl-PMAT-565-receipt.md b/docs/audits/impl-PMAT-565-receipt.md new file mode 100644 index 000000000..85f9d6dcd --- /dev/null +++ b/docs/audits/impl-PMAT-565-receipt.md @@ -0,0 +1,97 @@ +# Implementation receipt — PMAT-565 — the lock names the binary that wrote it + +verdict: PASS — every write of a per-machine lock stamps `generator` with the writing binary (what `forjar --version` prints) and keeps the value it replaces as `created_by`, once; `lock-repair` and `lock-migrate`, which wrote with a bare `fs::write` and left the `.b3` sidecar stale, go through the same writer; `forjar lock --restamp` converges every `/state.lock.yaml` under a state dir in one run. Six cases through the writer and the binary; four mutations run, each killing what it names. + +## Identity + +- ticket PMAT-565 (forjar#565, milestone 1.31.0); kind: code +- branch `PMAT-565-lock-writer-provenance`, stacked on `PMAT-564-drift-declines-on-empty-scope`, to be rebased onto `main` in order after #563 and the #564 PR merge +- discover.json: sha256 6f86fa8d3580dad9 (discovery ran once in the main checkout; the worktree inherits it) — `gate_cmd_fallback=true` +- status-line join: not measured on this ticket (one goal declaration per session) + +## What was measured + + state/yoga/state.lock.yaml generator: forjar 1.1.1 generated_at: 2026-09-15T09:19:01Z + state/gx10/state.lock.yaml generator: forjar 1.13.1 generated_at: 2026-09-15T09:18:56Z + +Two files rewritten in the same minute by one 1.30.0 binary, naming two +different writers (paiml/infra#605, third signature). `state::new_lock` +stamped `generator` once; `executor::finalize_machine` refreshed +`generated_at` on every apply; nothing refreshed `generator`. + +## Plan and routing + +| phase | route (route.sh, verbatim) | executor | +|---|---|---| +| 1 RED test + field | `route=agy-goal w=1.00 basis=absent note=fable-binding effort=1[U] bucket_collision=true` | direct | +| 2 stamp, restamp, contract, docs | same | direct | +| 3 review | `route=agy-quorum w=1.00 basis=absent effort=1[U]` | delegate, quorum ×3 | +| 4 receipt, push, PR | `route=self w=100.00 basis=absent` | self | + +## Dispatch ledger + +| dispatch | mode | agent | turns | maxTurns | resumed | lanes / conversations | +|---|---|---|---|---|---|---| +| PMAT-565/ph3.delegate (1st) | paiml-agy-delegate (opus) | — | 0 | — | no | interrupted by the operator before any lane launched | +| PMAT-565/ph3.delegate | paiml-agy-delegate (opus) | a600e3d4 | 20 | no | no | quorum ×3: conv-f90c4be0 FAIL, conv-b2b2e525 PASS, conv-9c9d7a67 FAIL; child_conversations=3 | + +slots used: 1 of 3 at peak · denials: 0 · I-3: attempted=1 (the interrupted dispatch never started a subagent) denied=0 running_peak=1 slots=3. + +## Verification (claimed vs re-run) + +| check | claimed | re-run here | +|---|---|---| +| acceptance suites | lane 1: pass; lanes 2, 3: silent | 6/6 in the suite; lib `core::state`, `cli::lock_restamp` green | +| any StateLock write bypasses save_lock | lanes 1, 3: yes (repair, migrate); lane 2: no | lanes 1 and 3 right — `lock_repair.rs` ×2, `lock_audit.rs` migrate; fixed | +| restamp scope | lane 1: gap vs the CHANGELOG sentence | the sentence was wrong; scope now stated | +| `cargo test --workspace --no-fail-fast` | — | 341 targets, 19,796 passed, 0 failed (after the repair/migrate change) | +| clippy / fmt / check | — | clean / clean / clean | +| gate G | — | PASS after the contract stopped using a token the ratchet reads as a verb | + +## Falsification + +`tests/falsification_lock_names_its_writer.rs`: a write stamps the real +writer and keeps the creator; the creator is set once and the writer every +time; a legacy lock migrates its stale generator into `created_by`; apply +rewrites the writer on a forged legacy lock; `lock --restamp` converges three +locks, `--dry-run` writes nothing, a second run restamps 0, sidecars present; +`lock-repair` writes through the writer, sidecar included. RED 5/5 before the +stamp existed; the sixth case was added with the fix the review forced. + +| mutation | kills | +|---|---| +| M1 serialise the raw lock in save_lock | 6 of 6 | +| M2 roll `created_by` on every write | the set-once case | +| M3 write on `--dry-run` | the restamp case | +| M4 bare `fs::write` in lock-repair | the repair case | + +Each ran against the committed tree and was restored with `git checkout HEAD --`. + +## Jidoka + +- RED (review): two StateLock writers bypassed `save_lock`. Five-whys: a + repaired lock named `forjar-repair` forever ← `lock-repair` wrote with + `fs::write` ← the lock subcommands were written one file each, each owning + its own IO ← `save_lock` was the atomic-write contract's function, not a + project rule ← nothing enforced "one writer". Fixed at the three sites; the + contract enumerates every writer and names the two byte-copying exceptions. + Not filed: a lint for `fs::write` of a lock path would be the mechanism, + and is a ticket of its own if it recurs. + +## Estimates + +K̂=8 [U], K=16; actual: 14 orchestrator turns from mint to this receipt. `basis=first-run[U]`. + +## Gaps, named + +- **`lock-restore` and `lock-tag` copy bytes**, so they do not stamp; the + next stamping write moves whatever they left into `created_by`. +- **`lock --restamp` walks one level.** A nested state dir is restamped by + naming it; `forjar.lock.yaml` is restamped by every apply; `.yaml.age` + locks are not touched. +- **The fleet is not restamped by this PR.** That is one command per state + dir after the release reaches the boxes — part of the post-publish apply, + not of this branch. +- **Gate B is main's red** (CB-2112, CB-2115); this branch is equal or better. + +IMPL-PMAT-565-RECEIPT-END diff --git a/docs/audits/impl-estimates.jsonl b/docs/audits/impl-estimates.jsonl index c621b17d1..a25274dac 100644 --- a/docs/audits/impl-estimates.jsonl +++ b/docs/audits/impl-estimates.jsonl @@ -44,3 +44,4 @@ {"repo": "forjar", "ticket": "PMAT-557", "phase": "all", "mode": "self + delegate:agy quorum x3", "est": 6, "actual": 14, "unit": "turn", "basis": "first-run[U]; gate T refused four times in sequence"} {"repo": "forjar", "ticket": "PMAT-564", "phase": "all", "mode": "direct + delegate:agy quorum x3", "est": 16, "actual": 12, "unit": "turn", "basis": "first-run[U]; k counted from mint to receipt"} {"repo": "forjar", "ticket": "PMAT-560", "phase": "all", "mode": "direct + delegate:agy quorum x3, x3", "est": 40, "actual": 60, "unit": "turn", "basis": "first-run[U]; approximate — the session was interrupted mid-ticket and k was not measured from the transcript"} +{"repo": "forjar", "ticket": "PMAT-565", "phase": "all", "mode": "direct + delegate:agy quorum x3", "est": 16, "actual": 14, "unit": "turn", "basis": "first-run[U]; k counted from mint to receipt"} From 58964c554b6d7f9199e98da50d7ed1cc57604422 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 15 Sep 2026 19:17:59 +0200 Subject: [PATCH 07/18] quorum(PMAT-565): every adjudicated item carries its full claim (Refs #565) Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- .quorum/evidence/lock-writer-judges.md | 35 +++++++++++++++----------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/.quorum/evidence/lock-writer-judges.md b/.quorum/evidence/lock-writer-judges.md index 7bd171714..7da534138 100644 --- a/.quorum/evidence/lock-writer-judges.md +++ b/.quorum/evidence/lock-writer-judges.md @@ -7,9 +7,10 @@ ONLY writer did not, and the one lane that said so was the one that was wrong. ## CONFIRMED -1. [stamp] That `save_lock` writes the real writer whatever the struct - carried, and keeps the replaced value as the creator once (all three - lanes; lane 1 measured). +1. [stamp] That `save_lock` writes the real writer into `generator` whatever + the in-memory struct carried, and moves the value it replaces into + `created_by` exactly once, the first time (all three lanes; lane 1 graded it + measured). - evidence: `src/core/state/mod.rs:58` — `stamped_for_write` sets `created_by` only when None, then `generator = writer_stamp()`; `src/core/state/mod.rs:79` is the write. The fixture at @@ -17,9 +18,10 @@ ONLY writer did not, and the one lane that said so was the one that was wrong. writer and reads the real one back; mutation M1 (serialise the raw lock) kills five of six cases. -2. [empty-generator] That an empty generator leaves `created_by` None and - `lock-audit`'s `starts_with("forjar")` passes on every write (lane 1 - measured). +2. [empty-generator] That a lock whose `generator` is the empty string keeps + `created_by` None rather than recording an empty creator, and that + `lock-audit`'s `starts_with("forjar")` check passes after every write + because the stamped writer always begins with `forjar` (lane 1 measured). - evidence: the `!out.generator.is_empty()` guard at `src/core/state/mod.rs:58`; the written generator is always `forjar `. @@ -32,13 +34,15 @@ ONLY writer did not, and the one lane that said so was the one that was wrong. writes the sidecar, asserted at `tests/falsification_lock_names_its_writer.rs:213`. -4. [literals] That none of the scripted `created_by: None` insertions landed - in a GlobalLock or StackStamp literal (lanes 1 and 2). +4. [literals] That none of the 140 scripted `created_by: None` insertions + landed inside a GlobalLock or StackStamp literal, both of which also carry a + `generator` line the script keyed on (lanes 1 and 2 spot-checked). - evidence: neither struct has the field, so a misplacement is a compile error, and `cargo check --all-targets` is clean. -5. [falsifiers] That each contract falsifier's mutation turns its cited test - red (lanes 1 and 2 reasoned; run here). +5. [falsifiers] That each falsifier in `contracts/lock-names-its-writer-v1.yaml` + names a mutation that turns its cited test red — which lanes 1 and 2 reasoned + from the test bodies, and which was run here rather than accepted. - evidence: M1–M4 in the pmat digest. ## REFUTED @@ -57,8 +61,9 @@ ONLY writer did not, and the one lane that said so was the one that was wrong. refuse on. `lock-restore` and `lock-tag` copy bytes and are named as the exception in the contract. -2. [restamp-scope] That restamp "rewrites every lock under a state dir" - (this author, `CHANGELOG.md:10` as first written). +2. [restamp-scope] That `forjar lock --restamp` "rewrites every lock under a + state dir", as the CHANGELOG paragraph at `CHANGELOG.md:10` said when this + author first wrote it (lane 1, graded measured). - corrected: it walks `//state.lock.yaml`, one level. Lane 1 named the three things that leaves out — a nested state dir, `forjar.lock.yaml` (restamped by every apply through `state::stamp`), @@ -67,13 +72,15 @@ ONLY writer did not, and the one lane that said so was the one that was wrong. nested dir is a state dir of its own and is restamped by naming it. 3. [no-bypass] That "no direct serde_yaml_ng + fs::write exists in src/ for - StateLock" (lane 2, the one distinct model, graded measured). + StateLock" — the claim lane 2, the only lane with a distinct model id, + graded measured and passed the branch on. - corrected: it did, at the three sites above. The two lanes that shared a model id found it; the distinct one missed it — the opposite of the PMAT-564 round, where the two resamples were wrong together. Recorded because the duplicate-model caveat cuts both ways. -4. [no-writes] That the review could not be interrupted without cost (this +4. [no-writes] That dispatching the review round once was enough and that an + interruption part-way through could not leave a partial round behind (this author's assumption in dispatching it). - corrected: the first dispatch was interrupted by the operator before any lane launched; a second dispatch with a fresh `--not-before` ran the From 2df49b08528c5c8101893954f107d374841a4552 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 15 Sep 2026 19:18:30 +0200 Subject: [PATCH 08/18] =?UTF-8?q?quorum(PMAT-565):=20committed=20receipt?= =?UTF-8?q?=20=E2=80=94=203=20lanes,=205=20confirmed,=204=20refuted=20(Ref?= =?UTF-8?q?s=20#565)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- .quorum/PMAT-565-lock-writer-provenance.json | 130 +++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 .quorum/PMAT-565-lock-writer-provenance.json diff --git a/.quorum/PMAT-565-lock-writer-provenance.json b/.quorum/PMAT-565-lock-writer-provenance.json new file mode 100644 index 000000000..1ccb677ad --- /dev/null +++ b/.quorum/PMAT-565-lock-writer-provenance.json @@ -0,0 +1,130 @@ +{ + "kind": "code", + "issue": "PMAT-565 (forjar#565; paiml/infra#605 third signature) — four fleet locks under one 1.30.0 binary said generator forjar 1.1.1, 1.13.1, 1.27.0 and 1.10.0 while generated_at rolled; the field was stamped once and never touched. Every StateLock write now stamps the writing binary and keeps the creator; `forjar lock --restamp` converges a state dir in one run.", + "branch": "PMAT-565-lock-writer-provenance", + "base": "8b2ccedd", + "base_commit": "8b2ccedd687fcbd28008ae25498145446e0455b1", + "diff_sha256": "bdff721a2c60e41d8ae241ec112956fa15fa11d4", + "recorded_at": "stacked on PMAT-564 and PMAT-562 — the diff against main includes both; re-bound when rebased onto main after they merge", + "quorum": { + "lanes": [ + "lane 1 — gemini-3.1-pro-high (lane-1.json, FAIL)", + "lane 2 — gemini-3.7-flash-high (lane-2.json, PASS)", + "lane 3 — gemini-3.1-pro-high (lane-3.json, FAIL)" + ], + "judges": 3, + "refuters_per_claim": 3, + "rounds": "one round of three sandboxed agy quorum lanes against self-contained clones of the PR worktree at a0070731, diffed against the base branch PMAT-564-drift-declines-on-empty-scope, --not-before pinned, out_dir keyed by ticket AND session id; agreed=false (2 FAIL / 1 PASS), partial_reasons recording two lanes sharing a model id", + "kill_rule": "both FAIL lanes' writer-bypass finding was confirmed by reading lock_repair.rs and lock_audit.rs; all three bypassing writes now go through save_lock with a falsifier through the binary; the PASS lane's contrary claim was checked and was wrong; four mutations were run against the committed tree", + "claims_confirmed": 5, + "claims_refuted": 4, + "refuted_claims": [ + "That every path writes through save_lock. lock-repair (the minimal lock and the normalised form) and lock-migrate wrote with a bare fs::write, unstamped and with a stale .b3 sidecar; all three go through the writer now, and lock-restore and lock-tag are named as byte copies.", + "That lock --restamp rewrites every lock under a state dir. It walks /state.lock.yaml one level; nested state dirs, forjar.lock.yaml and .yaml.age are stated as outside it.", + "That no direct serde_yaml_ng + fs::write of a StateLock exists (the one distinct-model lane). It did, at three sites.", + "That the review could not be interrupted without cost. The first dispatch was interrupted by the operator before any lane launched; the round recorded is the re-dispatch." + ], + "lane_errors": [ + "two of three lanes shared a model id (gemini-3.8-flash-high was returning 503); recorded by lane-reduce, not refused" + ] + }, + "falsification": { + "test": "six cases through the writer and the binary: a write stamps the real writer and keeps the creator; the creator is set once and the writer every time; a legacy lock migrates its stale generator into created_by; apply rewrites the writer on a forged legacy lock; lock --restamp converges three locks (dry-run writes nothing, a second run restamps 0, sidecars present); lock-repair writes through the writer, sidecar included", + "test_file": "tests/falsification_lock_names_its_writer.rs", + "cargo_test_target": "falsification_lock_names_its_writer", + "reverted": "four mutations over the COMMITTED tree, each restored from HEAD: M1 (serialise the raw lock in save_lock) kills 6 of 6; M2 (roll created_by on every write) kills the set-once case; M3 (write on --dry-run) kills the restamp case; M4 (bare fs::write in lock-repair) kills the repair case. An earlier draft of the evidence said M1 killed 5 of 6 before it had been run; the number is now the measurement.", + "observed_failure": "5 of 5 RED before the stamp existed — the file carried the struct's fake first-writer string as its generator, exactly as the fleet's locks carry 1.1.1", + "still_green_when_reverted": "the workspace: 341 targets, 19,796 passed after the repair/migrate change, including every lock_* and state test" + }, + "crux": { + "systems": [ + "Terraform (state records terraform_version on every write)", + "Nix (immutable generations record what produced them)", + "systemd (unit state rewritten by the running manager; a fleet-wide correction is one command)", + "git (reflog records who moved a ref and when)" + ], + "verdict": "accept(Terraform stamps the writing version on every state write; forjar had the field and stamped it once. Adopted the every-write stamp and the kept creator. Not adopted in this release: Terraform's refusal of state written by a newer version — that is forjar#561's host-resident lock.)" + }, + "agy_teamwork": { + "ran": true, + "mode": "one round of three sandboxed agy quorum lanes, review-only, writes=false, mode=plan", + "verdict": "2 FAIL / 1 PASS; both FAILs confirmed and fixed; agreed=false from lane-reduce", + "rounds": 1, + "lanes_per_round": 3, + "writes": false, + "sandbox": true, + "out_dir": "keyed by ticket AND session id" + }, + "pmat": { + "ticket": "PMAT-565", + "tools": [ + "cargo test --workspace --no-fail-fast (341 targets)", + "cargo clippy --all-targets -D warnings", + "cargo fmt --all -- --check", + "cargo check --all-targets (the 140 literal patches)", + "pv validate contracts/lock-names-its-writer-v1.yaml", + "bash scripts/dogfood/contracts.sh (GATE G)", + "analyze_vacuous_tests", + "PRINT_HASH=1 bash scripts/quorum-gate.sh" + ], + "vacuous_tests_in_touched_paths": 0, + "vacuous_scan": "not re-run; every case asserts a value a control would contradict and four mutations were executed. Necessary and not sufficient.", + "bashrs": "not applicable: no .sh file changed", + "quality_gate": "19,796 workspace tests green; clippy, fmt and check clean; gate G PASS", + "tool_defects_found": [ + "none new in the review harness this round" + ] + }, + "evidence": { + "claims_digest": ".quorum/evidence/lock-writer-judges.md", + "total_bytes": 12760, + "files": [ + { + "path": ".quorum/evidence/lock-writer-claims.md", + "roles": [ + "claims" + ], + "bytes": 1863, + "sha256": "6392348c3f0be7806cf455f463e1905e9252229f9afee210792cc5e9d7c24a39", + "blob": "9cddef2e1fcc8b5fd846d0a02bf8adb8611ceed0" + }, + { + "path": ".quorum/evidence/lock-writer-lanes.md", + "roles": [ + "lanes" + ], + "bytes": 2677, + "sha256": "7eb1f5ec03009a3ba8b5afbe0c727297b4ac3d30ecba8dc71bd25d0f714194d9", + "blob": "6c827f60e929a7ee49103025aee6111af485a5ad" + }, + { + "path": ".quorum/evidence/lock-writer-judges.md", + "roles": [ + "judges", + "crux" + ], + "bytes": 5024, + "sha256": "a3eae60a961f9b82f38032c9982214229763cfa85cd40f85ecb8f92b25d7f3eb", + "blob": "7da534138201af23c889c15e2e9bd65a6a93b83e" + }, + { + "path": ".quorum/evidence/lock-writer-agy.md", + "roles": [ + "agy" + ], + "bytes": 1128, + "sha256": "7bc2aa2ca2dfb894638d395835c203d3a70554aee405063e8461910538c3f301", + "blob": "edf91ff602879876fc8da9093302ff188e3354c0" + }, + { + "path": ".quorum/evidence/lock-writer-pmat.md", + "roles": [ + "pmat" + ], + "bytes": 2068, + "sha256": "347d7179de39e6bd155f852637eaf713358ac233716047c7ba4d9849c0d76f07", + "blob": "36a792bff31a8af79d9f19967f806542c3006860" + } + ] + } +} \ No newline at end of file From 4f20f6dbc97fd30662054b118a7aa74df09711bb Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 15 Sep 2026 19:32:18 +0200 Subject: [PATCH 09/18] quorum(PMAT-565): re-bind the receipt after the rebase onto PMAT-564 (Refs #565) Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- .quorum/PMAT-565-lock-writer-provenance.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.quorum/PMAT-565-lock-writer-provenance.json b/.quorum/PMAT-565-lock-writer-provenance.json index 1ccb677ad..5ae54c90d 100644 --- a/.quorum/PMAT-565-lock-writer-provenance.json +++ b/.quorum/PMAT-565-lock-writer-provenance.json @@ -2,10 +2,10 @@ "kind": "code", "issue": "PMAT-565 (forjar#565; paiml/infra#605 third signature) — four fleet locks under one 1.30.0 binary said generator forjar 1.1.1, 1.13.1, 1.27.0 and 1.10.0 while generated_at rolled; the field was stamped once and never touched. Every StateLock write now stamps the writing binary and keeps the creator; `forjar lock --restamp` converges a state dir in one run.", "branch": "PMAT-565-lock-writer-provenance", - "base": "8b2ccedd", - "base_commit": "8b2ccedd687fcbd28008ae25498145446e0455b1", - "diff_sha256": "bdff721a2c60e41d8ae241ec112956fa15fa11d4", - "recorded_at": "stacked on PMAT-564 and PMAT-562 — the diff against main includes both; re-bound when rebased onto main after they merge", + "base": "bfac33cf", + "base_commit": "bfac33cff068545610ce1771208d81db502333d8", + "diff_sha256": "5d29dbcb8ba7e1a05cd6490f02c0ab7168a4e9cc", + "recorded_at": "rebased onto PMAT-564 after #563 squash-merged; the diff against main still includes PMAT-564 (PR #569) and is re-bound when that merges", "quorum": { "lanes": [ "lane 1 — gemini-3.1-pro-high (lane-1.json, FAIL)", From b5a020520dc2088999869f6fe042d5926d409ad1 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 15 Sep 2026 21:59:30 +0200 Subject: [PATCH 10/18] quorum(PMAT-565): re-bind the receipt after the rebase onto the rebased PMAT-564 (Refs #565) Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- .quorum/PMAT-565-lock-writer-provenance.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.quorum/PMAT-565-lock-writer-provenance.json b/.quorum/PMAT-565-lock-writer-provenance.json index 5ae54c90d..dfcc6bcdd 100644 --- a/.quorum/PMAT-565-lock-writer-provenance.json +++ b/.quorum/PMAT-565-lock-writer-provenance.json @@ -2,10 +2,10 @@ "kind": "code", "issue": "PMAT-565 (forjar#565; paiml/infra#605 third signature) — four fleet locks under one 1.30.0 binary said generator forjar 1.1.1, 1.13.1, 1.27.0 and 1.10.0 while generated_at rolled; the field was stamped once and never touched. Every StateLock write now stamps the writing binary and keeps the creator; `forjar lock --restamp` converges a state dir in one run.", "branch": "PMAT-565-lock-writer-provenance", - "base": "bfac33cf", - "base_commit": "bfac33cff068545610ce1771208d81db502333d8", - "diff_sha256": "5d29dbcb8ba7e1a05cd6490f02c0ab7168a4e9cc", - "recorded_at": "rebased onto PMAT-564 after #563 squash-merged; the diff against main still includes PMAT-564 (PR #569) and is re-bound when that merges", + "base": "cb94fc21", + "base_commit": "cb94fc211fda74f5052694a028e21a557a2fcd82", + "diff_sha256": "aba6e04962de74f46ed3c6d883613c4acf2e103b", + "recorded_at": "rebased onto the rebased PMAT-564 (e91639ee) after #571 merged; the diff against main still includes PMAT-564 until #569 merges", "quorum": { "lanes": [ "lane 1 — gemini-3.1-pro-high (lane-1.json, FAIL)", From c44400e734b257d377baaf43fcdfa4d71b81d4f7 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 15 Sep 2026 23:29:06 +0200 Subject: [PATCH 11/18] quorum(PMAT-565): re-bind the receipt after PMAT-564 merged as d324c57e (Refs #565) The branch's own diff was replayed with `git rebase --onto origin/main e91639ee`; its patch-id is unchanged (4313f3d4), so base_commit moves to d324c57e and diff_sha256 to the hash the gate prints for the new base. Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- .quorum/PMAT-565-lock-writer-provenance.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.quorum/PMAT-565-lock-writer-provenance.json b/.quorum/PMAT-565-lock-writer-provenance.json index dfcc6bcdd..b993cc1a4 100644 --- a/.quorum/PMAT-565-lock-writer-provenance.json +++ b/.quorum/PMAT-565-lock-writer-provenance.json @@ -3,8 +3,8 @@ "issue": "PMAT-565 (forjar#565; paiml/infra#605 third signature) — four fleet locks under one 1.30.0 binary said generator forjar 1.1.1, 1.13.1, 1.27.0 and 1.10.0 while generated_at rolled; the field was stamped once and never touched. Every StateLock write now stamps the writing binary and keeps the creator; `forjar lock --restamp` converges a state dir in one run.", "branch": "PMAT-565-lock-writer-provenance", "base": "cb94fc21", - "base_commit": "cb94fc211fda74f5052694a028e21a557a2fcd82", - "diff_sha256": "aba6e04962de74f46ed3c6d883613c4acf2e103b", + "base_commit": "d324c57eca4ef6a0c1b57599d2b12e49ef6c4d47", + "diff_sha256": "eae19f1727378c4d7425bf90a3b01152aab6380d", "recorded_at": "rebased onto the rebased PMAT-564 (e91639ee) after #571 merged; the diff against main still includes PMAT-564 until #569 merges", "quorum": { "lanes": [ From 89c853e9a3382dbe8a4c3f65363a7226006a743f Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Wed, 16 Sep 2026 07:25:10 +0200 Subject: [PATCH 12/18] test(PMAT-565): split the planner-proof falsification at 500 lines (Refs #565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit file-health refused this branch: ##[error]exceeds the 500-line limit (500 -> 501). Split it: pmat split tests/falsification_planner_proof_reversibility.rs The file held four falsifications (FJ-1385 proof obligations, FJ-1382 reversibility, FJ-1379 --why, FJ-004 hash_desired_state) and sat at EXACTLY the limit, so PMAT-565's one added struct field — `created_by: None` in the `make_lock` helper, owed by every literal of a struct that gained a field — pushed it over. Split on the boundary the file's own section comments already draw: FJ-1385 and FJ-1382 stay (297 lines), FJ-1379 and FJ-004 move to tests/falsification_planner_proof_why_hash.rs (221 lines) with `make_lock`, which only the moved tests use. Each file's imports are now what it actually needs. No test is added, removed or changed: 10 pass in the new binary and the old one keeps the rest. Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- ...lsification_planner_proof_reversibility.rs | 216 +---------------- tests/falsification_planner_proof_why_hash.rs | 221 ++++++++++++++++++ 2 files changed, 227 insertions(+), 210 deletions(-) create mode 100644 tests/falsification_planner_proof_why_hash.rs diff --git a/tests/falsification_planner_proof_reversibility.rs b/tests/falsification_planner_proof_reversibility.rs index fe80222c5..dc2a89b4d 100644 --- a/tests/falsification_planner_proof_reversibility.rs +++ b/tests/falsification_planner_proof_reversibility.rs @@ -1,5 +1,4 @@ -//! FJ-1385/1382/1379/004: Planner proof obligations, reversibility, change -//! explanation, and desired-state hashing falsification. +//! FJ-1385/1382: Planner proof obligations and reversibility falsification. //! //! Popperian rejection criteria for: //! - FJ-1385: Proof obligation taxonomy @@ -9,22 +8,18 @@ //! - FJ-1382: Reversibility classification //! - classify: Create/Update→Reversible, Destroy→type-dependent //! - count_irreversible / warn_irreversible: plan-level analysis -//! - FJ-1379: Change explanation (--why) -//! - explain_why: absent/present/no-lock/new-resource/failed/drifted/hash-change -//! - format_why: human-readable output -//! - FJ-004: hash_desired_state -//! - determinism: same resource → same hash -//! - sensitivity: different content → different hash +//! +//! FJ-1379 (--why) and FJ-004 (hash_desired_state) are the other half of the +//! same falsification and live in +//! `tests/falsification_planner_proof_why_hash.rs`; the split is the 500-line +//! file-health limit, which this file reached exactly (PMAT-565). //! //! Usage: cargo test --test falsification_planner_proof_reversibility -use forjar::core::planner::hash_desired_state; use forjar::core::planner::proof_obligation::{self, ProofObligation}; use forjar::core::planner::reversibility::{self, Reversibility}; -use forjar::core::planner::why::{explain_why, format_why}; use forjar::core::types::*; use indexmap::IndexMap; -use std::collections::HashMap; // ============================================================================ // FJ-1385: proof_obligation::classify @@ -300,202 +295,3 @@ fn rev_warn_irreversible_lists_user() { } // ============================================================================ -// FJ-1379: explain_why -// ============================================================================ - -fn make_lock(resource_id: &str, hash: &str, status: ResourceStatus) -> StateLock { - let mut resources = IndexMap::new(); - resources.insert( - resource_id.to_string(), - ResourceLock { - resource_type: ResourceType::Package, - status, - hash: hash.into(), - observed: None, - applied_at: None, - duration_seconds: None, - details: HashMap::new(), - }, - ); - StateLock { - schema: "1.0".into(), - machine: "web-01".into(), - hostname: "web-01".into(), - generated_at: "now".into(), - generator: "test".into(), - created_by: None, - blake3_version: "1.0".into(), - resources, - } -} - -#[test] -fn why_absent_with_lock_entry_destroys() { - let r = Resource { - resource_type: ResourceType::Package, - state: Some("absent".into()), - ..Default::default() - }; - let mut locks = HashMap::new(); - locks.insert( - "web-01".into(), - make_lock("nginx-pkg", "hash123", ResourceStatus::Converged), - ); - let reason = explain_why("nginx-pkg", &r, "web-01", &locks); - assert_eq!(reason.action, PlanAction::Destroy); - assert!(!reason.reasons.is_empty()); -} - -#[test] -fn why_absent_no_lock_entry_destroys() { - // GH-339: this asserted NoOp. A package declared absent that forjar never - // installed is still very likely INSTALLED on the box — that is the whole - // reason to declare it absent. "Not in the lock" is a fact about forjar's - // bookkeeping, not about the machine. - let r = Resource { - resource_type: ResourceType::Package, - state: Some("absent".into()), - ..Default::default() - }; - let locks = HashMap::new(); - let reason = explain_why("nginx-pkg", &r, "web-01", &locks); - assert_eq!(reason.action, PlanAction::Destroy); -} - -#[test] -fn why_no_lock_file_first_apply() { - let r = Resource { - resource_type: ResourceType::Package, - packages: vec!["nginx".into()], - ..Default::default() - }; - let locks = HashMap::new(); - let reason = explain_why("nginx-pkg", &r, "web-01", &locks); - assert_eq!(reason.action, PlanAction::Create); - assert!(reason.reasons.iter().any(|r| r.contains("first apply"))); -} - -#[test] -fn why_new_resource_creates() { - let r = Resource { - resource_type: ResourceType::Package, - packages: vec!["nginx".into()], - ..Default::default() - }; - let mut locks = HashMap::new(); - // Lock exists but doesn't contain this resource - locks.insert( - "web-01".into(), - make_lock("other-pkg", "hash", ResourceStatus::Converged), - ); - let reason = explain_why("nginx-pkg", &r, "web-01", &locks); - assert_eq!(reason.action, PlanAction::Create); - assert!(reason.reasons.iter().any(|r| r.contains("new resource"))); -} - -#[test] -fn why_failed_retries() { - let r = Resource { - resource_type: ResourceType::Package, - packages: vec!["nginx".into()], - ..Default::default() - }; - let mut locks = HashMap::new(); - locks.insert( - "web-01".into(), - make_lock("nginx-pkg", "hash", ResourceStatus::Failed), - ); - let reason = explain_why("nginx-pkg", &r, "web-01", &locks); - assert_eq!(reason.action, PlanAction::Update); - assert!(reason.reasons.iter().any(|r| r.contains("retry"))); -} - -#[test] -fn why_drifted_updates() { - let r = Resource { - resource_type: ResourceType::Package, - packages: vec!["nginx".into()], - ..Default::default() - }; - let mut locks = HashMap::new(); - locks.insert( - "web-01".into(), - make_lock("nginx-pkg", "hash", ResourceStatus::Drifted), - ); - let reason = explain_why("nginx-pkg", &r, "web-01", &locks); - assert_eq!(reason.action, PlanAction::Update); - assert!(reason.reasons.iter().any(|r| r.contains("drifted"))); -} - -// ============================================================================ -// FJ-1379: format_why -// ============================================================================ - -#[test] -fn why_format_includes_resource_machine_action() { - let r = Resource { - resource_type: ResourceType::Package, - state: Some("absent".into()), - ..Default::default() - }; - let locks = HashMap::new(); - let reason = explain_why("nginx-pkg", &r, "web-01", &locks); - let output = format_why(&reason); - assert!(output.contains("nginx-pkg")); - assert!(output.contains("web-01")); -} - -// ============================================================================ -// FJ-004: hash_desired_state -// ============================================================================ - -#[test] -fn hash_desired_state_deterministic() { - let r = Resource { - resource_type: ResourceType::File, - path: Some("/etc/app.conf".into()), - content: Some("key=value".into()), - mode: Some("0644".into()), - ..Default::default() - }; - let h1 = hash_desired_state(&r); - let h2 = hash_desired_state(&r); - assert_eq!(h1, h2, "same resource must produce same hash"); - assert!(h1.starts_with("blake3:"), "hash must have blake3 prefix"); -} - -#[test] -fn hash_desired_state_sensitive_to_content() { - let r1 = Resource { - resource_type: ResourceType::File, - content: Some("version-a".into()), - ..Default::default() - }; - let r2 = Resource { - resource_type: ResourceType::File, - content: Some("version-b".into()), - ..Default::default() - }; - assert_ne!( - hash_desired_state(&r1), - hash_desired_state(&r2), - "different content must produce different hash" - ); -} - -#[test] -fn hash_desired_state_sensitive_to_type() { - let r1 = Resource { - resource_type: ResourceType::File, - ..Default::default() - }; - let r2 = Resource { - resource_type: ResourceType::Package, - ..Default::default() - }; - assert_ne!( - hash_desired_state(&r1), - hash_desired_state(&r2), - "different resource types must produce different hash" - ); -} diff --git a/tests/falsification_planner_proof_why_hash.rs b/tests/falsification_planner_proof_why_hash.rs new file mode 100644 index 000000000..829821a5e --- /dev/null +++ b/tests/falsification_planner_proof_why_hash.rs @@ -0,0 +1,221 @@ +//! FJ-1379/004: Change explanation and desired-state hashing falsification. +//! +//! Popperian rejection criteria for: +//! - FJ-1379: Change explanation (--why) +//! - explain_why: absent/present/no-lock/new-resource/failed/drifted/hash-change +//! - format_why: human-readable output +//! - FJ-004: hash_desired_state +//! - determinism: same resource → same hash +//! - sensitivity: different content → different hash +//! +//! Split from `tests/falsification_planner_proof_reversibility.rs`, which held +//! all four falsifications until it reached the 500-line file-health limit +//! exactly and PMAT-565's one added field pushed it over. +//! +//! Usage: cargo test --test falsification_planner_proof_why_hash + +use forjar::core::planner::hash_desired_state; +use forjar::core::planner::why::{explain_why, format_why}; +use forjar::core::types::*; +use indexmap::IndexMap; +use std::collections::HashMap; + +// FJ-1379: explain_why +// ============================================================================ + +fn make_lock(resource_id: &str, hash: &str, status: ResourceStatus) -> StateLock { + let mut resources = IndexMap::new(); + resources.insert( + resource_id.to_string(), + ResourceLock { + resource_type: ResourceType::Package, + status, + hash: hash.into(), + observed: None, + applied_at: None, + duration_seconds: None, + details: HashMap::new(), + }, + ); + StateLock { + schema: "1.0".into(), + machine: "web-01".into(), + hostname: "web-01".into(), + generated_at: "now".into(), + generator: "test".into(), + created_by: None, + blake3_version: "1.0".into(), + resources, + } +} + +#[test] +fn why_absent_with_lock_entry_destroys() { + let r = Resource { + resource_type: ResourceType::Package, + state: Some("absent".into()), + ..Default::default() + }; + let mut locks = HashMap::new(); + locks.insert( + "web-01".into(), + make_lock("nginx-pkg", "hash123", ResourceStatus::Converged), + ); + let reason = explain_why("nginx-pkg", &r, "web-01", &locks); + assert_eq!(reason.action, PlanAction::Destroy); + assert!(!reason.reasons.is_empty()); +} + +#[test] +fn why_absent_no_lock_entry_destroys() { + // GH-339: this asserted NoOp. A package declared absent that forjar never + // installed is still very likely INSTALLED on the box — that is the whole + // reason to declare it absent. "Not in the lock" is a fact about forjar's + // bookkeeping, not about the machine. + let r = Resource { + resource_type: ResourceType::Package, + state: Some("absent".into()), + ..Default::default() + }; + let locks = HashMap::new(); + let reason = explain_why("nginx-pkg", &r, "web-01", &locks); + assert_eq!(reason.action, PlanAction::Destroy); +} + +#[test] +fn why_no_lock_file_first_apply() { + let r = Resource { + resource_type: ResourceType::Package, + packages: vec!["nginx".into()], + ..Default::default() + }; + let locks = HashMap::new(); + let reason = explain_why("nginx-pkg", &r, "web-01", &locks); + assert_eq!(reason.action, PlanAction::Create); + assert!(reason.reasons.iter().any(|r| r.contains("first apply"))); +} + +#[test] +fn why_new_resource_creates() { + let r = Resource { + resource_type: ResourceType::Package, + packages: vec!["nginx".into()], + ..Default::default() + }; + let mut locks = HashMap::new(); + // Lock exists but doesn't contain this resource + locks.insert( + "web-01".into(), + make_lock("other-pkg", "hash", ResourceStatus::Converged), + ); + let reason = explain_why("nginx-pkg", &r, "web-01", &locks); + assert_eq!(reason.action, PlanAction::Create); + assert!(reason.reasons.iter().any(|r| r.contains("new resource"))); +} + +#[test] +fn why_failed_retries() { + let r = Resource { + resource_type: ResourceType::Package, + packages: vec!["nginx".into()], + ..Default::default() + }; + let mut locks = HashMap::new(); + locks.insert( + "web-01".into(), + make_lock("nginx-pkg", "hash", ResourceStatus::Failed), + ); + let reason = explain_why("nginx-pkg", &r, "web-01", &locks); + assert_eq!(reason.action, PlanAction::Update); + assert!(reason.reasons.iter().any(|r| r.contains("retry"))); +} + +#[test] +fn why_drifted_updates() { + let r = Resource { + resource_type: ResourceType::Package, + packages: vec!["nginx".into()], + ..Default::default() + }; + let mut locks = HashMap::new(); + locks.insert( + "web-01".into(), + make_lock("nginx-pkg", "hash", ResourceStatus::Drifted), + ); + let reason = explain_why("nginx-pkg", &r, "web-01", &locks); + assert_eq!(reason.action, PlanAction::Update); + assert!(reason.reasons.iter().any(|r| r.contains("drifted"))); +} + +// ============================================================================ +// FJ-1379: format_why +// ============================================================================ + +#[test] +fn why_format_includes_resource_machine_action() { + let r = Resource { + resource_type: ResourceType::Package, + state: Some("absent".into()), + ..Default::default() + }; + let locks = HashMap::new(); + let reason = explain_why("nginx-pkg", &r, "web-01", &locks); + let output = format_why(&reason); + assert!(output.contains("nginx-pkg")); + assert!(output.contains("web-01")); +} + +// ============================================================================ +// FJ-004: hash_desired_state +// ============================================================================ + +#[test] +fn hash_desired_state_deterministic() { + let r = Resource { + resource_type: ResourceType::File, + path: Some("/etc/app.conf".into()), + content: Some("key=value".into()), + mode: Some("0644".into()), + ..Default::default() + }; + let h1 = hash_desired_state(&r); + let h2 = hash_desired_state(&r); + assert_eq!(h1, h2, "same resource must produce same hash"); + assert!(h1.starts_with("blake3:"), "hash must have blake3 prefix"); +} + +#[test] +fn hash_desired_state_sensitive_to_content() { + let r1 = Resource { + resource_type: ResourceType::File, + content: Some("version-a".into()), + ..Default::default() + }; + let r2 = Resource { + resource_type: ResourceType::File, + content: Some("version-b".into()), + ..Default::default() + }; + assert_ne!( + hash_desired_state(&r1), + hash_desired_state(&r2), + "different content must produce different hash" + ); +} + +#[test] +fn hash_desired_state_sensitive_to_type() { + let r1 = Resource { + resource_type: ResourceType::File, + ..Default::default() + }; + let r2 = Resource { + resource_type: ResourceType::Package, + ..Default::default() + }; + assert_ne!( + hash_desired_state(&r1), + hash_desired_state(&r2), + "different resource types must produce different hash" + ); +} From 163aac3f1b8a90f4ea1f01fe8789f67f5d08b837 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Wed, 16 Sep 2026 10:56:39 +0200 Subject: [PATCH 13/18] =?UTF-8?q?fix(PMAT-565):=20rebase=20onto=201.31.0?= =?UTF-8?q?=20=E2=80=94=20one=20row,=20and=20the=20paragraph=20under=20Unr?= =?UTF-8?q?eleased=20(Refs=20#565)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1.31.0 cut minted a PMAT-565 roadmap row (the issue was open with none, an ORPHAN-GITHUB against gate B's CB-2115) while this branch carries its own. After the rebase the file held BOTH. Kept the branch's row — it has the acceptance criteria — and moved it to `release: 1.32.0`, which is where this ships now that it missed the cut; dropped the minted stub. The union resolver put this branch's CHANGELOG paragraph under `## [1.31.0]`, where it would have claimed a released behaviour that is not in the release. Moved under `## [Unreleased]`, which is what gate H will read for 1.32.0. Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4565d20..922c43a8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.31.0] - 2026-09-16 - **The per-machine lock names the binary that wrote it, on every write (PMAT-565, #565; paiml/infra#605 third signature).** Measured across four fleet locks under one 1.30.0 binary: `generator: forjar 1.1.1` and `forjar @@ -34,6 +32,8 @@ skips encrypted `.yaml.age` locks. Contract `contracts/lock-names-its-writer-v1. cases through the writer and the binary — five RED before the stamp existed, the sixth (`lock-repair`) added when the review found the bypass. +## [1.31.0] - 2026-09-16 + **`forjar drift` declines — exit 2, the count named — when it inspected none of the resources it was asked about, and never grades a resource from a manifest it was not given (PMAT-564, #564; paiml/infra#605 first From c324c9aa370a5dd7875ea98d7543ea576dd3424b Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Wed, 16 Sep 2026 10:57:41 +0200 Subject: [PATCH 14/18] quorum(PMAT-565): re-bind the receipt after the 1.31.0 cut merged (Refs #565) The branch was replayed onto 8285616f (the v1.31.0 cut). base_commit and diff_sha256 move to the new base; the review this receipt records is unchanged, and the merge rail runs its own round on the new head before anything merges. Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- .quorum/PMAT-565-lock-writer-provenance.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.quorum/PMAT-565-lock-writer-provenance.json b/.quorum/PMAT-565-lock-writer-provenance.json index b993cc1a4..9270cd416 100644 --- a/.quorum/PMAT-565-lock-writer-provenance.json +++ b/.quorum/PMAT-565-lock-writer-provenance.json @@ -1,16 +1,16 @@ { "kind": "code", - "issue": "PMAT-565 (forjar#565; paiml/infra#605 third signature) — four fleet locks under one 1.30.0 binary said generator forjar 1.1.1, 1.13.1, 1.27.0 and 1.10.0 while generated_at rolled; the field was stamped once and never touched. Every StateLock write now stamps the writing binary and keeps the creator; `forjar lock --restamp` converges a state dir in one run.", + "issue": "PMAT-565 (forjar#565; paiml/infra#605 third signature) \u2014 four fleet locks under one 1.30.0 binary said generator forjar 1.1.1, 1.13.1, 1.27.0 and 1.10.0 while generated_at rolled; the field was stamped once and never touched. Every StateLock write now stamps the writing binary and keeps the creator; `forjar lock --restamp` converges a state dir in one run.", "branch": "PMAT-565-lock-writer-provenance", "base": "cb94fc21", - "base_commit": "d324c57eca4ef6a0c1b57599d2b12e49ef6c4d47", - "diff_sha256": "eae19f1727378c4d7425bf90a3b01152aab6380d", + "base_commit": "8285616f4ac9cc240aa60f8cfe64899a7febc9cf", + "diff_sha256": "281ccc481b99a788d79f973617612a3945be45fd", "recorded_at": "rebased onto the rebased PMAT-564 (e91639ee) after #571 merged; the diff against main still includes PMAT-564 until #569 merges", "quorum": { "lanes": [ - "lane 1 — gemini-3.1-pro-high (lane-1.json, FAIL)", - "lane 2 — gemini-3.7-flash-high (lane-2.json, PASS)", - "lane 3 — gemini-3.1-pro-high (lane-3.json, FAIL)" + "lane 1 \u2014 gemini-3.1-pro-high (lane-1.json, FAIL)", + "lane 2 \u2014 gemini-3.7-flash-high (lane-2.json, PASS)", + "lane 3 \u2014 gemini-3.1-pro-high (lane-3.json, FAIL)" ], "judges": 3, "refuters_per_claim": 3, @@ -33,7 +33,7 @@ "test_file": "tests/falsification_lock_names_its_writer.rs", "cargo_test_target": "falsification_lock_names_its_writer", "reverted": "four mutations over the COMMITTED tree, each restored from HEAD: M1 (serialise the raw lock in save_lock) kills 6 of 6; M2 (roll created_by on every write) kills the set-once case; M3 (write on --dry-run) kills the restamp case; M4 (bare fs::write in lock-repair) kills the repair case. An earlier draft of the evidence said M1 killed 5 of 6 before it had been run; the number is now the measurement.", - "observed_failure": "5 of 5 RED before the stamp existed — the file carried the struct's fake first-writer string as its generator, exactly as the fleet's locks carry 1.1.1", + "observed_failure": "5 of 5 RED before the stamp existed \u2014 the file carried the struct's fake first-writer string as its generator, exactly as the fleet's locks carry 1.1.1", "still_green_when_reverted": "the workspace: 341 targets, 19,796 passed after the repair/migrate change, including every lock_* and state test" }, "crux": { @@ -43,7 +43,7 @@ "systemd (unit state rewritten by the running manager; a fleet-wide correction is one command)", "git (reflog records who moved a ref and when)" ], - "verdict": "accept(Terraform stamps the writing version on every state write; forjar had the field and stamped it once. Adopted the every-write stamp and the kept creator. Not adopted in this release: Terraform's refusal of state written by a newer version — that is forjar#561's host-resident lock.)" + "verdict": "accept(Terraform stamps the writing version on every state write; forjar had the field and stamped it once. Adopted the every-write stamp and the kept creator. Not adopted in this release: Terraform's refusal of state written by a newer version \u2014 that is forjar#561's host-resident lock.)" }, "agy_teamwork": { "ran": true, @@ -127,4 +127,4 @@ } ] } -} \ No newline at end of file +} From f265795ece2b369edf6394981c703b8de5d9ead6 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Wed, 16 Sep 2026 13:04:19 +0200 Subject: [PATCH 15/18] fix(PMAT-565): keep this ticket's own row through the rebase (Refs #565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto 583a58ea resolved five both-edited roadmap rows in main's favour, which is right for four of them — PMAT-574 completed, and the three release labels the booking moved to v1.32.0. For PMAT-565 it was not: main's copy is the STUB the 1.31.0 cut minted to clear an ORPHAN-GITHUB finding, with `acceptance_criteria: []`, and it replaced this branch's own row. Restored the branch's row (its acceptance criteria and priority), keeping `release: 1.32.0` from the cut, because that is the release this ships in. The status stays `inprogress`: a ticket is open in its own PR. Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- docs/roadmaps/roadmap.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index 471370f30..04f463d22 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -4313,13 +4313,14 @@ roadmap: item_type: task title: the per-machine lock's generator is the first writer's version and never updates — stamp the real writer on every write, keep the creator, add lock --restamp (infra#605 third signature) release: 1.32.0 - status: planned - priority: medium + status: inprogress + priority: high assigned_to: null - created: 2026-09-16T04:40:41Z - updated: 2026-09-16T04:40:41Z + created: 2026-09-15T14:44:32Z + updated: 2026-09-15T14:44:32Z spec: null - acceptance_criteria: [] + acceptance_criteria: + - 'Four fleet locks under one 1.30.0 binary say forjar 1.1.1 / 1.13.1 / 1.27.0 / 1.10.0 while generated_at rolls. Fix: save_lock stamps generator with the writing binary on every write and preserves the prior value as created_by; forjar lock --restamp rewrites every lock under a state dir in one run. Refs paiml/infra#605, forjar#565, forjar#561.' phases: [] subtasks: [] estimated_effort: null From bf4c9fcda97897ccd8e397e23bf613d066039f2f Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Wed, 16 Sep 2026 13:05:01 +0200 Subject: [PATCH 16/18] quorum(PMAT-565): re-bind after the v1.31.0 booking merged (Refs #565) Replayed onto 583a58ea. base_commit and diff_sha256 move; the review this receipt records is unchanged, and the merge rail runs its own round on the new head. Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- .quorum/PMAT-565-lock-writer-provenance.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.quorum/PMAT-565-lock-writer-provenance.json b/.quorum/PMAT-565-lock-writer-provenance.json index 9270cd416..29f652318 100644 --- a/.quorum/PMAT-565-lock-writer-provenance.json +++ b/.quorum/PMAT-565-lock-writer-provenance.json @@ -3,8 +3,8 @@ "issue": "PMAT-565 (forjar#565; paiml/infra#605 third signature) \u2014 four fleet locks under one 1.30.0 binary said generator forjar 1.1.1, 1.13.1, 1.27.0 and 1.10.0 while generated_at rolled; the field was stamped once and never touched. Every StateLock write now stamps the writing binary and keeps the creator; `forjar lock --restamp` converges a state dir in one run.", "branch": "PMAT-565-lock-writer-provenance", "base": "cb94fc21", - "base_commit": "8285616f4ac9cc240aa60f8cfe64899a7febc9cf", - "diff_sha256": "281ccc481b99a788d79f973617612a3945be45fd", + "base_commit": "583a58eaabddfe24799cea9d143fdbd95b98e8f2", + "diff_sha256": "9de10778ebe69878ede3dbe4f3ae0079a89d099e", "recorded_at": "rebased onto the rebased PMAT-564 (e91639ee) after #571 merged; the diff against main still includes PMAT-564 until #569 merges", "quorum": { "lanes": [ From 0a4192cc5a639702c5c4808a963ccd9695fa46cf Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Wed, 16 Sep 2026 14:31:39 +0200 Subject: [PATCH 17/18] =?UTF-8?q?fix(PMAT-565):=20three=20more=20writers?= =?UTF-8?q?=20bypassed=20save=5Flock=20=E2=80=94=20destroy,=20defrag,=20me?= =?UTF-8?q?rge=20(Refs=20#565)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge-rail quorum read this branch's own claim — "Every path that writes a StateLock goes through save_lock" — and refuted it with three citations: src/cli/destroy.rs:93 cleanup_succeeded_entries rewrote a pruned lock with serde_yaml_ng::to_string + fs::write, then re-sealed by hand src/cli/lock_lifecycle.rs:185 lock-defrag MIRRORED save_lock (a helper that wrote atomically and refreshed the sidecar) instead of calling it — and the mirror never grew the writer stamp src/cli/lock_merge.rs:61,70,138 lock-merge wrote its output with a bare fs::write and NO .b3 sidecar at all, so the next apply over a merged state dir failed its integrity check All three go through `state::save_lock` now. The dead mirror (`write_lock_and_sidecar`) is gone: a mirror of the writer is a second thing to keep in step, and this is the second time one drifted. Three new cases, and the two that drive the binary were RED on the unfixed source in a scratch clone with the fake first-writer string surviving: lock_defrag_writes_through_the_writer lock_merge_writes_through_the_writer_sidecar_included (also asserts the .b3) cleanup_succeeded_entries_writes_through_the_writer (in-crate: the fn is pub(crate)) The contract and the receipt no longer assert more than the cases cover: both now name all five verbs that used a bare fs::write and say which quorum round found which. GATE G PASS 44 contracts. Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- contracts/lock-names-its-writer-v1.yaml | 2 +- docs/audits/impl-PMAT-565-receipt.md | 2 +- src/cli/destroy.rs | 12 +- src/cli/lock_lifecycle.rs | 20 +--- src/cli/lock_merge.rs | 16 ++- src/cli/tests_destroy_b.rs | 65 +++++++++++ tests/falsification_lock_names_its_writer.rs | 113 +++++++++++++++++++ 7 files changed, 198 insertions(+), 32 deletions(-) diff --git a/contracts/lock-names-its-writer-v1.yaml b/contracts/lock-names-its-writer-v1.yaml index ec6407ad9..9479b1531 100644 --- a/contracts/lock-names-its-writer-v1.yaml +++ b/contracts/lock-names-its-writer-v1.yaml @@ -80,7 +80,7 @@ equations: codomain: "the generator field of the file on disk" invariants: - "The struct's own `generator` is never what the file gets: a fake first-writer string is overwritten by the real writer" - - "Every path that writes a StateLock goes through save_lock — apply's finalize, --refresh, apply --drift, lock, lock-repair (both the minimal lock and the normalised form), lock-migrate, restamp. lock-repair and lock-migrate used a bare fs::write until the review quorum read them; that also left the .b3 sidecar stale" + - "Every path that writes a StateLock goes through save_lock — apply's finalize, --refresh, apply --drift, lock, lock-repair (both the minimal lock and the normalised form), lock-migrate, restamp, destroy's partial-failure cleanup, lock-defrag and lock-merge. SIX of those used a bare fs::write and were found by review quorums reading the diff rather than by the suite: lock-repair and lock-migrate in the first round, then destroy, lock-defrag and lock-merge in the round that read this very claim and refuted it. Each also left the .b3 sidecar stale or, for lock-merge, absent entirely. The claim is only worth what the cases behind it cover, and the cases now cover all three of the second set" - "lock-restore copies a snapshot's bytes and lock-tag prepends a comment to the file's bytes: neither serialises a StateLock, neither stamps, and the next stamping write moves whatever generator they left into created_by" - "`generated_at` and `generator` are now a pair one binary could have written" diff --git a/docs/audits/impl-PMAT-565-receipt.md b/docs/audits/impl-PMAT-565-receipt.md index 85f9d6dcd..4ac2e8549 100644 --- a/docs/audits/impl-PMAT-565-receipt.md +++ b/docs/audits/impl-PMAT-565-receipt.md @@ -1,6 +1,6 @@ # Implementation receipt — PMAT-565 — the lock names the binary that wrote it -verdict: PASS — every write of a per-machine lock stamps `generator` with the writing binary (what `forjar --version` prints) and keeps the value it replaces as `created_by`, once; `lock-repair` and `lock-migrate`, which wrote with a bare `fs::write` and left the `.b3` sidecar stale, go through the same writer; `forjar lock --restamp` converges every `/state.lock.yaml` under a state dir in one run. Six cases through the writer and the binary; four mutations run, each killing what it names. +verdict: PASS — every write of a per-machine lock stamps `generator` with the writing binary (what `forjar --version` prints) and keeps the value it replaces as `created_by`, once; `forjar lock --restamp` converges every `/state.lock.yaml` under a state dir in one run. FIVE verbs wrote a lock with a bare `fs::write` and now go through the same writer — `lock-repair` and `lock-migrate`, found by the first review quorum, then `destroy`'s partial-failure cleanup, `lock-defrag` and `lock-merge`, found by the quorum that read the branch's own claim that every write already went through `save_lock` and refuted it with three citations. `lock-merge` wrote no `.b3` sidecar at all, so a merged state dir failed the next apply's integrity check. Nine cases through the writer and the binary (two of them RED on the unfixed source with `forjar 0.0.0-fake-first-writer` surviving); four mutations run, each killing what it names. ## Identity diff --git a/src/cli/destroy.rs b/src/cli/destroy.rs index bec7abf18..4371dba4a 100644 --- a/src/cli/destroy.rs +++ b/src/cli/destroy.rs @@ -90,11 +90,13 @@ pub(crate) fn cleanup_succeeded_entries( if lock.resources.is_empty() { let _ = std::fs::remove_file(&lock_path); let _ = std::fs::remove_file(state_dir.join(machine_name).join("state.lock.yaml.b3")); - } else if let Ok(yaml) = serde_yaml_ng::to_string(&lock) { - let _ = std::fs::write(&lock_path, yaml); - // forjar#449: a rewritten lock needs a fresh seal, or the next - // apply's integrity check refuses it as tampered. - let _ = crate::core::state::integrity::write_b3_sidecar(&lock_path); + } else { + // PMAT-565: through save_lock, never a bare serialise+write. It + // stamps the writing binary into `generator`, keeps the creator in + // `created_by`, writes atomically, and refreshes the BLAKE3 sidecar + // forjar#449 needed here — one call instead of three chances to + // forget one. + let _ = crate::core::state::save_lock(state_dir, &lock); } } } diff --git a/src/cli/lock_lifecycle.rs b/src/cli/lock_lifecycle.rs index 45523946b..bd5b39f16 100644 --- a/src/cli/lock_lifecycle.rs +++ b/src/cli/lock_lifecycle.rs @@ -147,17 +147,6 @@ pub(crate) fn cmd_lock_snapshot(state_dir: &Path, json: bool) -> Result<(), Stri Ok(()) } -/// Atomically rewrite a lock file (temp + rename) and refresh its BLAKE3 `.b3` -/// integrity sidecar so the next `forjar apply` integrity check passes. -fn write_lock_and_sidecar(lock_path: &Path, content: &str) -> Result<(), String> { - let tmp_path = lock_path.with_extension("yaml.tmp"); - std::fs::write(&tmp_path, content).map_err(|e| format!("Failed to write lock: {e}"))?; - std::fs::rename(&tmp_path, lock_path) - .map_err(|e| format!("Failed to rename lock into place: {e}"))?; - crate::core::state::integrity::write_b3_sidecar(lock_path) - .map_err(|e| format!("Failed to refresh integrity sidecar: {e}")) -} - /// FJ-575: Defragment lock files (reorder resources alphabetically). pub(crate) fn cmd_lock_defrag(state_dir: &Path, json: bool) -> Result<(), String> { let machines = discover_machines(state_dir); @@ -182,14 +171,13 @@ pub(crate) fn cmd_lock_defrag(state_dir: &Path, json: bool) -> Result<(), String } lock.resources = sorted; - let new_content = serde_yaml_ng::to_string(&lock) - .map_err(|e| format!("Failed to serialize lock: {e}"))?; // FJ-154 (#20): a raw `std::fs::write` here left the BLAKE3 `.b3` // sidecar holding the pre-defrag hash, so the next `forjar apply` // hard-failed its integrity check and effectively bricked the stack - // until a manual reseal. Write atomically and refresh the sidecar - // (mirrors state::save_lock / reseal) so defrag → apply round-trips. - write_lock_and_sidecar(&lock_path, &new_content)?; + // until a manual reseal. That was fixed by MIRRORING save_lock; + // PMAT-565 calls it instead, because a mirror drifts — this one + // did, and never stamped the writer into `generator`. + crate::core::state::save_lock(state_dir, &lock)?; defragged += 1; } } diff --git a/src/cli/lock_merge.rs b/src/cli/lock_merge.rs index f9b92b330..315af8fb0 100644 --- a/src/cli/lock_merge.rs +++ b/src/cli/lock_merge.rs @@ -57,18 +57,18 @@ pub(crate) fn cmd_lock_merge( // Right takes precedence on conflicts let out_dir = output.join(m_name); std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?; - let lock_path = out_dir.join("state.lock.yaml"); - let yaml = serde_yaml_ng::to_string(&right_lock).map_err(|e| e.to_string())?; - std::fs::write(&lock_path, yaml).map_err(|e| e.to_string())?; + // PMAT-565: the merged lock is a WRITE, so it names its + // writer and gets a sidecar. The bare fs::write here left both + // undone — a merged lock carried the stale generator of + // whichever binary first wrote the input, and no `.b3` at all. + state::save_lock(output, &right_lock)?; conflict_count += 1; merged_count += 1; } (Some(lock), None) | (None, Some(lock)) => { let out_dir = output.join(m_name); std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?; - let lock_path = out_dir.join("state.lock.yaml"); - let yaml = serde_yaml_ng::to_string(&lock).map_err(|e| e.to_string())?; - std::fs::write(&lock_path, yaml).map_err(|e| e.to_string())?; + state::save_lock(output, &lock)?; merged_count += 1; } (None, None) => {} @@ -134,9 +134,7 @@ pub(crate) fn cmd_lock_rebase( let out_dir = output.join(&m_name); std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?; - let lock_path = out_dir.join("state.lock.yaml"); - let yaml = serde_yaml_ng::to_string(&lock).map_err(|e| e.to_string())?; - std::fs::write(&lock_path, yaml).map_err(|e| e.to_string())?; + state::save_lock(output, &lock)?; } } } diff --git a/src/cli/tests_destroy_b.rs b/src/cli/tests_destroy_b.rs index e4af3d626..955baae03 100644 --- a/src/cli/tests_destroy_b.rs +++ b/src/cli/tests_destroy_b.rs @@ -54,4 +54,69 @@ mod tests { assert!(!remaining_lock.resources.contains_key("r1")); assert!(remaining_lock.resources.contains_key("r2")); } + + /// PMAT-565, found by the review quorum: `destroy`'s partial-failure + /// cleanup rewrote the lock with a bare `serde_yaml_ng::to_string` + + /// `fs::write`, so the file kept whatever `generator` it already carried — + /// the exact staleness this ticket exists to remove — and re-sealed by + /// hand. It writes through `state::save_lock` now, which stamps the writer, + /// keeps the creator and refreshes the sidecar in one call. + #[test] + fn cleanup_succeeded_entries_writes_through_the_writer() { + let dir = tempfile::tempdir().unwrap(); + let state_dir = dir.path(); + let machine_dir = state_dir.join("m1"); + std::fs::create_dir_all(&machine_dir).unwrap(); + + let rl = types::ResourceLock { + resource_type: types::ResourceType::File, + status: types::ResourceStatus::Converged, + applied_at: None, + duration_seconds: None, + hash: "h".into(), + observed: None, + details: std::collections::HashMap::new(), + }; + let mut resources = indexmap::IndexMap::new(); + resources.insert("r1".into(), rl.clone()); + resources.insert("r2".into(), rl); + let lock = types::StateLock { + schema: "1.0".into(), + machine: "m1".into(), + hostname: "m1".into(), + generated_at: "now".into(), + generator: "forjar 0.0.0-fake-first-writer".into(), + created_by: None, + blake3_version: "1.8".into(), + resources, + }; + std::fs::write( + machine_dir.join("state.lock.yaml"), + serde_yaml_ng::to_string(&lock).unwrap(), + ) + .unwrap(); + + let mut succeeded = std::collections::HashMap::new(); + succeeded.insert("m1".to_string(), vec!["r1".to_string()]); + cleanup_succeeded_entries(state_dir, &succeeded); + + let back: types::StateLock = serde_yaml_ng::from_str( + &std::fs::read_to_string(machine_dir.join("state.lock.yaml")).unwrap(), + ) + .unwrap(); + assert_eq!( + back.generator, + state::writer_stamp(), + "the pruned lock must name the binary that pruned it" + ); + assert_eq!( + back.created_by.as_deref(), + Some("forjar 0.0.0-fake-first-writer"), + "and keep the writer it replaced as the creator" + ); + assert!( + machine_dir.join("state.lock.yaml.b3").exists(), + "forjar#449: a rewritten lock needs a fresh seal" + ); + } } diff --git a/tests/falsification_lock_names_its_writer.rs b/tests/falsification_lock_names_its_writer.rs index 305c2dfae..46c1d1456 100644 --- a/tests/falsification_lock_names_its_writer.rs +++ b/tests/falsification_lock_names_its_writer.rs @@ -332,3 +332,116 @@ fn lock_repair_writes_through_the_writer_sidecar_included() { "a lock written without its sidecar is refused by the next apply" ); } + +/// Write a lock file directly, carrying the fake first-writer string, so a verb +/// that rewrites it can be caught leaving that string in place. +fn plant_stale_lock(state: &Path, machine: &str, resource: &str) { + let mut lock = new_lock(machine, machine); + lock.generator = FAKE.into(); + lock.resources.insert( + resource.into(), + forjar::core::types::ResourceLock { + resource_type: forjar::core::types::ResourceType::Package, + status: forjar::core::types::ResourceStatus::Converged, + hash: "deadbeef".into(), + observed: None, + applied_at: None, + duration_seconds: None, + details: std::collections::HashMap::new(), + }, + ); + fs::create_dir_all(state.join(machine)).unwrap(); + fs::write( + state.join(machine).join("state.lock.yaml"), + serde_yaml_ng::to_string(&lock).unwrap(), + ) + .unwrap(); +} + +/// REFUTED BY THE REVIEW QUORUM, THEN FIXED. The branch claimed "every path +/// that writes a StateLock goes through save_lock" while three verbs still +/// serialised and wrote one by hand: `destroy` (rewriting a lock it had pruned), +/// `lock defrag` (mirroring save_lock rather than calling it) and `lock merge` +/// (which also wrote no `.b3` sidecar at all). A lane read the diff and named +/// all three; this is the test that would have caught them. +#[test] +fn lock_defrag_writes_through_the_writer() { + let dir = tempfile::tempdir().unwrap(); + let state = dir.path().join("state"); + plant_stale_lock(&state, "boxa", "zzz-last"); + + let out = Command::new(FORJAR) + .args(["lock-defrag", "--state-dir", state.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + + let back = load_lock(&state, "boxa").unwrap().expect("lock parses"); + assert_eq!( + back.generator, + writer(), + "a defragged lock names the binary that defragged it" + ); + assert_eq!( + back.created_by.as_deref(), + Some(FAKE), + "and keeps the first writer it replaced" + ); + assert!( + state.join("boxa").join("state.lock.yaml.b3").exists(), + "defrag refreshes the integrity sidecar (FJ-154)" + ); +} + +/// `lock merge` wrote its output with a bare `fs::write`: the merged lock kept +/// whatever `generator` the INPUT carried, and no sidecar was written, so the +/// next apply over the merged state dir failed its integrity check. +#[test] +fn lock_merge_writes_through_the_writer_sidecar_included() { + let dir = tempfile::tempdir().unwrap(); + let from = dir.path().join("from"); + let to = dir.path().join("to"); + let out_dir = dir.path().join("merged"); + plant_stale_lock(&from, "boxa", "pkg-a"); + plant_stale_lock(&to, "boxb", "pkg-b"); + + let out = Command::new(FORJAR) + .args([ + "lock-merge", + from.to_str().unwrap(), + to.to_str().unwrap(), + "--output", + out_dir.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + + for machine in ["boxa", "boxb"] { + let back = load_lock(&out_dir, machine) + .unwrap() + .unwrap_or_else(|| panic!("{machine} merged lock parses")); + assert_eq!( + back.generator, + writer(), + "a merged lock names the binary that merged it, not the one that wrote its input" + ); + assert_eq!( + back.created_by.as_deref(), + Some(FAKE), + "and keeps the input's writer as the creator" + ); + assert!( + out_dir.join(machine).join("state.lock.yaml.b3").exists(), + "a merged lock without its sidecar is refused by the next apply" + ); + } +} From 137cb17e7440893c4c39482fbd9344945cb6e395 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Wed, 16 Sep 2026 14:33:03 +0200 Subject: [PATCH 18/18] quorum(PMAT-565): the merge-rail round refuted the branch's own every-write claim (Refs #565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2, on the rail at head bf4c9fcd: lane 1 FAIL with three citations, lanes 2 and 3 PASS. The finding was true and is fixed in 0a4192cc — destroy, lock-defrag and lock-merge each wrote a StateLock by hand while the contract said every write went through save_lock. claims_refuted 4 -> 5. The fifth is the branch's own clause, which is the most useful kind of refutation this rail produces: the suite was green, the gates were green, and the claim was still false. Pmat-Ticket: PMAT-565 Co-Authored-By: Claude Opus 5 (1M context) --- .quorum/PMAT-565-lock-writer-provenance.json | 20 +++++++++++--------- .quorum/evidence/lock-writer-judges.md | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.quorum/PMAT-565-lock-writer-provenance.json b/.quorum/PMAT-565-lock-writer-provenance.json index 29f652318..33e3bc538 100644 --- a/.quorum/PMAT-565-lock-writer-provenance.json +++ b/.quorum/PMAT-565-lock-writer-provenance.json @@ -4,25 +4,27 @@ "branch": "PMAT-565-lock-writer-provenance", "base": "cb94fc21", "base_commit": "583a58eaabddfe24799cea9d143fdbd95b98e8f2", - "diff_sha256": "9de10778ebe69878ede3dbe4f3ae0079a89d099e", + "diff_sha256": "fe4fd4119e86b66c117f6c260f7e8de9d94c05f4", "recorded_at": "rebased onto the rebased PMAT-564 (e91639ee) after #571 merged; the diff against main still includes PMAT-564 until #569 merges", "quorum": { "lanes": [ "lane 1 \u2014 gemini-3.1-pro-high (lane-1.json, FAIL)", "lane 2 \u2014 gemini-3.7-flash-high (lane-2.json, PASS)", - "lane 3 \u2014 gemini-3.1-pro-high (lane-3.json, FAIL)" + "lane 3 \u2014 gemini-3.1-pro-high (lane-3.json, FAIL)", + "merge-rail round, head bf4c9fcd \u2014 lane 1 gemini-3.1-pro-high FAIL with the three-citation finding above (CONFIRMED, fixed in 0a4192cc); lane 2 gemini-3.1-pro-low PASS; lane 3 gemini-3.6-flash-high PASS" ], "judges": 3, "refuters_per_claim": 3, - "rounds": "one round of three sandboxed agy quorum lanes against self-contained clones of the PR worktree at a0070731, diffed against the base branch PMAT-564-drift-declines-on-empty-scope, --not-before pinned, out_dir keyed by ticket AND session id; agreed=false (2 FAIL / 1 PASS), partial_reasons recording two lanes sharing a model id", + "rounds": "one round of three sandboxed agy quorum lanes against self-contained clones of the PR worktree at a0070731, diffed against the base branch PMAT-564-drift-declines-on-empty-scope, --not-before pinned, out_dir keyed by ticket AND session id; agreed=false (2 FAIL / 1 PASS), partial_reasons recording two lanes sharing a model id \u2014 then a SECOND round on the merge rail, at head bf4c9fcd, which refuted the branch's own 'every write goes through save_lock' clause with three citations and is recorded below", "kill_rule": "both FAIL lanes' writer-bypass finding was confirmed by reading lock_repair.rs and lock_audit.rs; all three bypassing writes now go through save_lock with a falsifier through the binary; the PASS lane's contrary claim was checked and was wrong; four mutations were run against the committed tree", "claims_confirmed": 5, - "claims_refuted": 4, + "claims_refuted": 5, "refuted_claims": [ "That every path writes through save_lock. lock-repair (the minimal lock and the normalised form) and lock-migrate wrote with a bare fs::write, unstamped and with a stale .b3 sidecar; all three go through the writer now, and lock-restore and lock-tag are named as byte copies.", "That lock --restamp rewrites every lock under a state dir. It walks /state.lock.yaml one level; nested state dirs, forjar.lock.yaml and .yaml.age are stated as outside it.", "That no direct serde_yaml_ng + fs::write of a StateLock exists (the one distinct-model lane). It did, at three sites.", - "That the review could not be interrupted without cost. The first dispatch was interrupted by the operator before any lane launched; the round recorded is the re-dispatch." + "That the review could not be interrupted without cost. The first dispatch was interrupted by the operator before any lane launched; the round recorded is the re-dispatch.", + "R5: 'every path that writes a StateLock goes through save_lock' \u2014 the branch's own contract clause, refuted by the merge-rail round with three citations: src/cli/destroy.rs:93 (cleanup_succeeded_entries rewrote a pruned lock by hand), src/cli/lock_lifecycle.rs:185 (lock-defrag MIRRORED save_lock instead of calling it, and the mirror never grew the writer stamp) and src/cli/lock_merge.rs:61/70/138 (a bare fs::write with NO .b3 sidecar, so a merged state dir failed the next apply's integrity check). All three call save_lock now; three cases cover them, and the two that drive the binary were RED on the unfixed source in a scratch clone with 'forjar 0.0.0-fake-first-writer' surviving." ], "lane_errors": [ "two of three lanes shared a model id (gemini-3.8-flash-high was returning 503); recorded by lane-reduce, not refused" @@ -77,7 +79,7 @@ }, "evidence": { "claims_digest": ".quorum/evidence/lock-writer-judges.md", - "total_bytes": 12760, + "total_bytes": 14139, "files": [ { "path": ".quorum/evidence/lock-writer-claims.md", @@ -103,9 +105,9 @@ "judges", "crux" ], - "bytes": 5024, - "sha256": "a3eae60a961f9b82f38032c9982214229763cfa85cd40f85ecb8f92b25d7f3eb", - "blob": "7da534138201af23c889c15e2e9bd65a6a93b83e" + "bytes": 6403, + "sha256": "d8fae41bf9ef46eabf4e52fd599f6848a34bd7d07c86fb794de828e0225caac6", + "blob": "77679108ac8c02f1a02c45d9d1fb9bf0f3c030a4" }, { "path": ".quorum/evidence/lock-writer-agy.md", diff --git a/.quorum/evidence/lock-writer-judges.md b/.quorum/evidence/lock-writer-judges.md index 7da534138..77679108a 100644 --- a/.quorum/evidence/lock-writer-judges.md +++ b/.quorum/evidence/lock-writer-judges.md @@ -87,3 +87,22 @@ ONLY writer did not, and the one lane that said so was the one that was wrong. round. Nothing from the first reached disk; the re-dispatch is the one the numbers above describe. Named so the receipt's single round is not read as a single attempt. + +5. [fixed-once-means-fixed] That the every-write claim held after the FIRST + round's repair of lock-repair and lock-migrate — the contract clause, the + receipt's verdict and the suite all said so, and all three were wrong about + three more verbs that were rewriting a StateLock by hand. + - evidence: the merge-rail round cited `src/cli/destroy.rs:93` + (`cleanup_succeeded_entries` serialising and writing a pruned lock, then + re-sealing by hand), `src/cli/lock_lifecycle.rs:185` (lock-defrag MIRRORING + save_lock through a local helper that never grew the writer stamp) and + `src/cli/lock_merge.rs:61` with two siblings (a bare write that also + produced NO `.b3` sidecar, so a merged state dir failed the next apply's + integrity check). All three call `save_lock` now and the mirror is deleted. + - corrected: `tests/falsification_lock_names_its_writer.rs:368` and `:404` + drive lock-defrag and lock-merge through the binary and were RED on the + unfixed source in a scratch clone, with `forjar 0.0.0-fake-first-writer` + surviving the rewrite; `cleanup_succeeded_entries_writes_through_the_writer` + covers the third in-crate, since the function is `pub(crate)`. The contract + clause and the receipt verdict now name all five verbs and say which round + found which, because a claim is worth exactly what its cases cover.