diff --git a/.gitignore b/.gitignore index ea8c4bf..0592392 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 06eefa3..cc536a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -603,9 +603,9 @@ dependencies = [ [[package]] name = "yoagent-state" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7745b432c1d947c2e90b44c14b96d5809168fb8ab5e92e9a6a687d423c3266e" +checksum = "7384e9ee74b278cbbbf4fa6d5870133c97df202e74a4e89bb9bef54a85ab229e" dependencies = [ "async-trait", "chrono", diff --git a/conformance-check/Cargo.toml b/conformance-check/Cargo.toml index 7e251ed..14d3491 100644 --- a/conformance-check/Cargo.toml +++ b/conformance-check/Cargo.toml @@ -6,7 +6,7 @@ description = "GASP conformance checker — verifies an agent repo against Part license = "MIT" [dependencies] -yoagent-state = "0.4" +yoagent-state = "0.5" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/conformance-check/build.rs b/conformance-check/build.rs new file mode 100644 index 0000000..733ef37 --- /dev/null +++ b/conformance-check/build.rs @@ -0,0 +1,78 @@ +//! Records which `yoagent-state` the checker folds with, so the report can say so. +//! +//! The checker answers "can a conformant runtime fold and restore this store?" +//! by folding the store itself. That answer is only meaningful if the fold it +//! uses matches the fold runtimes use — and when it drifted, the skew was +//! discoverable only by reading two lockfiles. +//! +//! A certificate that does not say what produced it cannot be audited. +//! +//! Reads the workspace lockfile rather than guessing: cargo exposes no env var +//! for a dependency's resolved version, and the requirement in `Cargo.toml` +//! ("0.5") is not what was actually built. + +use std::path::PathBuf; + +fn main() { + // Both layouts, in this order. `cargo package` ships the lockfile at the + // *package* root, beside this build script — so looking only at the + // workspace parent worked in development and printed "unknown" for every + // installed build, which is the one place the line has to work. Cargo also + // regenerates the workspace lock before build scripts run, so the failure + // was unreachable in-tree. + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let lock = [manifest.join("Cargo.lock"), manifest.join("../Cargo.lock")] + .into_iter() + .find(|p| p.exists()); + + let version = lock + .as_ref() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|body| resolved_version(&body, "yoagent-state")) + // Not a build failure: a missing or restructured lockfile should not + // stop the checker from running, only from naming its fold version. + .unwrap_or_else(|| "unknown".to_string()); + + println!("cargo:rustc-env=GASP_STATE_VERSION={version}"); + if let Some(p) = lock { + println!("cargo:rerun-if-changed={}", p.display()); + } +} + +/// Every `version = "x"` following a `name = ""` in a Cargo.lock. +/// +/// All of them, not the first. Cargo sorts by name then version ascending, so +/// taking the first reports the *lowest* when two versions resolve — and this +/// line exists precisely because 0.4 and 0.5 fold differently, so naming the +/// wrong one is worse than naming none. Ambiguity is reported as such. +fn resolved_version(lock: &str, crate_name: &str) -> Option { + let needle = format!("name = \"{crate_name}\""); + let mut found: Vec = Vec::new(); + let lines: Vec<&str> = lock.lines().collect(); + let mut in_package = false; + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + // Only `[[package]]` entries describe what was resolved. Cargo also + // emits `[[patch.unused]]` in the same shape, so collecting every + // matching name reported `ambiguous(...)` when a single version had + // resolved — reachable with `[patch.crates-io] yoagent-state = { path + // = ... }`, which is the obvious way to test a local fix. + if trimmed.starts_with("[[") { + in_package = trimmed == "[[package]]"; + } + if !in_package || trimmed != needle { + continue; + } + if let Some(v) = lines + .get(i + 1) + .and_then(|l| l.trim().strip_prefix("version = ")) + { + found.push(v.trim_matches('"').to_string()); + } + } + match found.len() { + 0 => None, + 1 => found.pop(), + _ => Some(format!("ambiguous({})", found.join(","))), + } +} diff --git a/conformance-check/src/lib.rs b/conformance-check/src/lib.rs index b75b3dd..5cecc67 100644 --- a/conformance-check/src/lib.rs +++ b/conformance-check/src/lib.rs @@ -10,7 +10,7 @@ use sha2::{Digest, Sha256}; use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::Path; use std::process::Command; -use yoagent_state::{replay, Event, Graph, Pack, StateOp}; +use yoagent_state::{replay, replay_with_diagnostics, Event, Graph, Pack, StateOp}; pub const EVENTS_PATH: &str = "state/events.jsonl"; @@ -205,14 +205,40 @@ pub fn check_envelope(lines: &[String]) -> CheckReport { /// `state/events.jsonl` (including their newlines). pub fn check_replay(repo: &Path, events: &[Event], raw_log: &str) -> CheckReport { let mut report = CheckReport::new(2, "replay"); - if let Err(e) = replay(events) { - report.failures.push(format!("fold failed: {e}")); - return report; + // Fold exactly as a conformant runtime does, not more strictly. + // + // This check asks "can a conformant runtime fold and restore this store?". + // Since yoagent-state 0.5.1 the answer for a log containing an op that + // references a missing node is *yes* — the op is skipped and the fold + // completes. Failing here would report non-conformance for a store every + // runtime can restore, which inverts what the badge means. + // + // But the skip is not swallowed: an op the graph cannot represent is worth + // a reader's attention even when it is survivable, and a certificate that + // hides it is not auditable. It lands in `notes`, which does not fail the + // check. + match replay_with_diagnostics(events) { + Err(e) => { + report.failures.push(format!("fold failed: {e}")); + return report; + } + Ok((_graph, skipped)) => { + for s in &skipped { + // The reason already names the node, so do not repeat it. + report.notes.push(format!( + "{} at batch index {} skipped — {}. The log is readable but malformed; a \ + conformant runtime folds past this, and so does this check", + s.op, s.index, s.reason, + )); + } + } } let snapshots = repo.join("snapshots"); if !snapshots.is_dir() { - report.notes.push("no snapshots/ — skipped seed check".into()); + report + .notes + .push("no snapshots/ — skipped seed check".into()); return report; } let entries = match std::fs::read_dir(&snapshots) { @@ -252,14 +278,16 @@ pub fn check_replay(repo: &Path, events: &[Event], raw_log: &str) -> CheckReport let value: Value = match serde_json::from_str(&raw) { Ok(v) => v, Err(e) => { - report.failures.push(format!("snapshot {name}: not JSON: {e}")); + report + .failures + .push(format!("snapshot {name}: not JSON: {e}")); continue; } }; let (Some(graph_v), Some(integrity)) = (value.get("graph"), value.get("integrity")) else { - report - .failures - .push(format!("snapshot {name}: missing `graph` or `integrity` record")); + report.failures.push(format!( + "snapshot {name}: missing `graph` or `integrity` record" + )); continue; }; let Some(line_count) = integrity.get("line_count").and_then(Value::as_u64) else { @@ -269,9 +297,9 @@ pub fn check_replay(repo: &Path, events: &[Event], raw_log: &str) -> CheckReport continue; }; let Some(expected_sha) = integrity.get("sha256").and_then(Value::as_str) else { - report - .failures - .push(format!("snapshot {name}: integrity record missing `sha256`")); + report.failures.push(format!( + "snapshot {name}: integrity record missing `sha256`" + )); continue; }; @@ -361,7 +389,11 @@ fn load_packs(repo: &Path) -> (Vec, Vec) { if path.extension().is_none_or(|x| x != "json") { continue; } - let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); match std::fs::read_to_string(&path) { Ok(raw) => match serde_json::from_str::(&raw) { Ok(pack) => packs.push(pack), @@ -468,7 +500,14 @@ pub fn check_append_only(repo: &Path) -> CheckReport { for path in &paths { let shas = match git( repo, - &["rev-list", "--reverse", "--full-history", "HEAD", "--", path], + &[ + "rev-list", + "--reverse", + "--full-history", + "HEAD", + "--", + path, + ], ) { Ok(shas) => shas, Err(e) => { @@ -602,9 +641,8 @@ pub fn check_restore(repo: &Path, events: &[Event], fixture_facts: bool) -> Chec } let node = |id: &str| graph.get_node(&yoagent_state::NodeId::new(id)); - let prop = |id: &str, key: &str| -> Option { - node(id).and_then(|n| n.props.get(key)).cloned() - }; + let prop = + |id: &str, key: &str| -> Option { node(id).and_then(|n| n.props.get(key)).cloned() }; let edge = |from: &str, rel: &str, to: &str| -> bool { graph .outgoing(&yoagent_state::NodeId::new(from), Some(rel)) @@ -621,7 +659,9 @@ pub fn check_restore(repo: &Path, events: &[Event], fixture_facts: bool) -> Chec .push("fixture: patch_9 --advances--> goal_retry missing".into()); } if prop("patch_9", "status") != Some(Value::from("Promoted")) { - report.failures.push("fixture: patch_9.status != Promoted".into()); + report + .failures + .push("fixture: patch_9.status != Promoted".into()); } if prop("patch_9", "references_commit") != Some(Value::from("abc1234")) { report @@ -677,7 +717,10 @@ pub fn check_pairing(events: &[Event]) -> CheckReport { .push(format!("line {n}: {} payload has no `id`", event.kind)); continue; }; - let claimants = ops_for.get(event.id.as_str()).map(Vec::as_slice).unwrap_or(&[]); + let claimants = ops_for + .get(event.id.as_str()) + .map(Vec::as_slice) + .unwrap_or(&[]); if claimants.is_empty() { report.failures.push(format!( "line {n}: {} `{entity_id}` has no paired state.ops_applied", diff --git a/conformance-check/src/main.rs b/conformance-check/src/main.rs index 1bc5523..f5b4f40 100644 --- a/conformance-check/src/main.rs +++ b/conformance-check/src/main.rs @@ -30,6 +30,16 @@ fn main() -> ExitCode { return ExitCode::from(2); }; + // Name the fold. The checker certifies a store by folding it, so the + // verdict is only meaningful against a stated fold version — and when the + // checker's `yoagent-state` drifted from the runtimes', the skew was + // discoverable only by reading two lockfiles. + println!( + "conformance-check {} — folding with yoagent-state {}", + env!("CARGO_PKG_VERSION"), + env!("GASP_STATE_VERSION"), + ); + let mut failed = false; for report in conformance_check::run_all(&repo, fixture_facts) { let mark = if report.passed() { "PASS" } else { "FAIL" }; diff --git a/conformance-check/tests/fixture.rs b/conformance-check/tests/fixture.rs index 9500538..36c7bbb 100644 --- a/conformance-check/tests/fixture.rs +++ b/conformance-check/tests/fixture.rs @@ -80,7 +80,10 @@ fn envelope_rejects_unknown_field() { let mut lines = fixture_lines(); lines[0] = lines[0].replace("\"causation_id\"", "\"extra\":1,\"causation_id\""); let report = check_envelope(&lines); - assert!(fails_with(&report, "unknown top-level field `extra`"), "{report:?}"); + assert!( + fails_with(&report, "unknown top-level field `extra`"), + "{report:?}" + ); } #[test] @@ -104,7 +107,11 @@ fn envelope_accepts_reordered_keys_and_nested_extras() { fn write_snapshot(repo: &Path, name: &str, snapshot: &Value) { let dir = repo.join("snapshots").join(name); std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("graph.json"), serde_json::to_vec(snapshot).unwrap()).unwrap(); + std::fs::write( + dir.join("graph.json"), + serde_json::to_vec(snapshot).unwrap(), + ) + .unwrap(); } fn valid_snapshot(repo: &Path, line_count: usize) -> Value { @@ -138,42 +145,64 @@ fn snapshot_valid_passes_and_corruptions_fail() { // hash mismatch let mut bad = valid_snapshot(&repo, 5); - bad["integrity"]["sha256"] = json!("0000000000000000000000000000000000000000000000000000000000000000"); + bad["integrity"]["sha256"] = + json!("0000000000000000000000000000000000000000000000000000000000000000"); write_snapshot(&repo, "event_10", &bad); - assert!(fails_with(&check_replay(&repo, &events, &raw), "integrity hash mismatch")); + assert!(fails_with( + &check_replay(&repo, &events, &raw), + "integrity hash mismatch" + )); // line_count out of range let mut bad = valid_snapshot(&repo, 5); bad["integrity"]["line_count"] = json!(0); write_snapshot(&repo, "event_10", &bad); - assert!(fails_with(&check_replay(&repo, &events, &raw), "out of range")); + assert!(fails_with( + &check_replay(&repo, &events, &raw), + "out of range" + )); bad["integrity"]["line_count"] = json!(99); write_snapshot(&repo, "event_10", &bad); - assert!(fails_with(&check_replay(&repo, &events, &raw), "out of range")); + assert!(fails_with( + &check_replay(&repo, &events, &raw), + "out of range" + )); // missing integrity fields are reported as what they are let mut bad = valid_snapshot(&repo, 5); bad["integrity"].as_object_mut().unwrap().remove("sha256"); write_snapshot(&repo, "event_10", &bad); - assert!(fails_with(&check_replay(&repo, &events, &raw), "missing `sha256`")); + assert!(fails_with( + &check_replay(&repo, &events, &raw), + "missing `sha256`" + )); // wrong terminal event id let mut bad = valid_snapshot(&repo, 5); bad["integrity"]["event_id"] = json!("event_99"); write_snapshot(&repo, "event_10", &bad); - assert!(fails_with(&check_replay(&repo, &events, &raw), "not the last event")); + assert!(fails_with( + &check_replay(&repo, &events, &raw), + "not the last event" + )); // tampered graph diverges from the prefix fold let mut bad = valid_snapshot(&repo, 5); bad["graph"]["version"] = json!(999); write_snapshot(&repo, "event_10", &bad); - assert!(fails_with(&check_replay(&repo, &events, &raw), "differs from folding")); + assert!(fails_with( + &check_replay(&repo, &events, &raw), + "differs from folding" + )); // a snapshot dir without graph.json is noted, not silently skipped std::fs::remove_file(repo.join("snapshots/event_10/graph.json")).unwrap(); let report = check_replay(&repo, &events, &raw); assert!(report.passed()); - assert!(report.notes.iter().any(|n| n.contains("not verified")), "{report:?}"); + assert!( + report.notes.iter().any(|n| n.contains("not verified")), + "{report:?}" + ); } // ---- check 3: vocabulary + packs ---- @@ -201,7 +230,10 @@ fn pack_admits_custom_kind_and_malformed_pack_fails() { let events = parse_events(&lines).unwrap(); // without a pack: undeclared - assert!(fails_with(&check_vocabulary(&repo, &events), "undeclared kind")); + assert!(fails_with( + &check_vocabulary(&repo, &events), + "undeclared kind" + )); // with a full pack declaring it: admitted std::fs::write( @@ -331,9 +363,15 @@ fn append_only_fails_on_non_git_directory() { fn causation_rejects_dangling_reference() { let mut lines = fixture_lines(); let i = line_of(&lines, |v| v["id"] == "event_10"); - lines[i] = lines[i].replace("\"causation_id\":\"event_09\"", "\"causation_id\":\"event_99\""); + lines[i] = lines[i].replace( + "\"causation_id\":\"event_09\"", + "\"causation_id\":\"event_99\"", + ); let events = parse_events(&lines).unwrap(); - assert!(fails_with(&check_causation(&events), "does not reference an earlier event")); + assert!(fails_with( + &check_causation(&events), + "does not reference an earlier event" + )); } #[test] @@ -367,7 +405,10 @@ fn causation_rejects_duplicate_and_self_referencing_ids() { r#"{"id":"event_x","schema_version":1,"ts_ms":1,"actor":{"kind":"agent","id":"a"},"kind":"goal.created","payload":{"id":"g"},"causation_id":"event_x","correlation_id":null}"#.to_string(), ]; let events = parse_events(&lines).unwrap(); - assert!(fails_with(&check_causation(&events), "does not reference an earlier event")); + assert!(fails_with( + &check_causation(&events), + "does not reference an earlier event" + )); } // ---- check 6: restore ---- @@ -382,7 +423,10 @@ fn restore_asserts_fixture_facts() { ); let events = parse_events(&lines).unwrap(); let report = check_restore(&fixture_dir(), &events, true); - assert!(fails_with(&report, "patch_9.status != Promoted"), "{report:?}"); + assert!( + fails_with(&report, "patch_9.status != Promoted"), + "{report:?}" + ); } // ---- check 7: pairing ---- @@ -393,7 +437,10 @@ fn pairing_rejects_missing_ops_event() { let i = line_of(&lines, |v| v["id"] == "event_02"); lines.remove(i); let events = parse_events(&lines).unwrap(); - assert!(fails_with(&check_pairing(&events), "no paired state.ops_applied")); + assert!(fails_with( + &check_pairing(&events), + "no paired state.ops_applied" + )); } #[test] @@ -421,7 +468,10 @@ fn pairing_rejects_wrong_created_kind() { lines[i] = lines[i].replace("\"kind\":\"goal\"", "\"kind\":\"task\""); let events = parse_events(&lines).unwrap(); // `task` is baseline vocabulary, so ONLY pairing catches this - assert!(fails_with(&check_pairing(&events), "created as kind `task`")); + assert!(fails_with( + &check_pairing(&events), + "created as kind `task`" + )); } #[test] @@ -431,7 +481,10 @@ fn pairing_rejects_ops_chained_to_ops() { r#"{"id":"event_x","schema_version":1,"ts_ms":1739000001000,"actor":{"kind":"agent","id":"evolve"},"kind":"state.ops_applied","payload":[{"MarkStale":{"id":"goal_retry","reason":"x"}}],"causation_id":"event_02","correlation_id":null}"#.to_string(), ); let events = parse_events(&lines).unwrap(); - assert!(fails_with(&check_pairing(&events), "chained to another ops event")); + assert!(fails_with( + &check_pairing(&events), + "chained to another ops event" + )); } // ---- run_all + CLI ---- @@ -452,8 +505,17 @@ fn cli_exit_codes() { let dir = fixture_in_temp_git(); let repo = dir.path().join("repo"); - let ok = Command::new(bin).arg(&repo).arg("--fixture").output().unwrap(); - assert_eq!(ok.status.code(), Some(0), "{}", String::from_utf8_lossy(&ok.stdout)); + let ok = Command::new(bin) + .arg(&repo) + .arg("--fixture") + .output() + .unwrap(); + assert_eq!( + ok.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&ok.stdout) + ); // non-conformant repo -> 1 let raw = std::fs::read_to_string(repo.join("state/events.jsonl")).unwrap(); @@ -468,12 +530,24 @@ fn cli_exit_codes() { // usage errors -> 2 assert_eq!(Command::new(bin).output().unwrap().status.code(), Some(2)); assert_eq!( - Command::new(bin).arg(&repo).arg("--fixtrue").output().unwrap().status.code(), + Command::new(bin) + .arg(&repo) + .arg("--fixtrue") + .output() + .unwrap() + .status + .code(), Some(2), "a typo'd flag must be a usage error, not a silently skipped assertion" ); assert_eq!( - Command::new(bin).arg(&repo).arg(&repo).output().unwrap().status.code(), + Command::new(bin) + .arg(&repo) + .arg(&repo) + .output() + .unwrap() + .status + .code(), Some(2), "multiple repo paths must be a usage error, not last-wins" ); @@ -506,7 +580,12 @@ async fn gitventstore_emitted_repo_passes_all_checks() { .await .unwrap(); let patch_id = state - .propose_patch(StatePatch::new(PatchId::new("patch_it"), "t", "s", actor.clone())) + .propose_patch(StatePatch::new( + PatchId::new("patch_it"), + "t", + "s", + actor.clone(), + )) .await .unwrap(); state @@ -546,7 +625,12 @@ async fn gitventstore_emitted_repo_passes_all_checks() { .await .unwrap(); store - .commit_run(&RunId::new("run_it"), &GoalId::new("goal_it"), "promoted", &[]) + .commit_run( + &RunId::new("run_it"), + &GoalId::new("goal_it"), + "promoted", + &[], + ) .unwrap() .expect("boundary commit"); @@ -560,3 +644,64 @@ async fn gitventstore_emitted_repo_passes_all_checks() { ); } } + +/// A store with a dangling op is **conformant**, and the certificate says so. +/// +/// The checker answers "can a conformant runtime fold and restore this store?". +/// Since yoagent-state 0.5.1 the answer for an op naming a missing node is +/// yes — the op is skipped and the fold completes (yologdev/yoagent#168). +/// +/// Failing here would report non-conformance for a store every runtime can +/// restore, which inverts what the badge means. It is also the only outcome a +/// user could not act on: an append-only log cannot have the op removed, and +/// inserting a fix before it rewrites published history — permanently failing +/// check 4, the property the format exists to guarantee. +/// +/// Regression for the real case: `yologdev/yoyo-gasp` at 8,882 events with one +/// dangling `UpdateNode`, which failed checks 2 and 6 before this. +#[test] +fn a_dangling_op_is_readable_and_reported_not_failed() { + let dir = fixture_in_temp_git(); + let repo = dir.path().join("repo"); + let log = repo.join("state/events.jsonl"); + + // Append an ops event updating a node nobody created — exactly the shape a + // multi-process writer produces when the creating process ran an older + // build. + // Derive the envelope from a real event rather than inventing one, so this + // tests the fold rather than the parser's required fields. + let mut raw = std::fs::read_to_string(&log).unwrap(); + let template: Value = log_lines(&raw) + .iter() + .map(|l| serde_json::from_str::(l).unwrap()) + .find(|v| v["kind"] == "state.ops_applied") + .expect("fixture has an ops event to model"); + let mut dangling = template.clone(); + dangling["id"] = json!("event_dangling_probe"); + dangling["ts_ms"] = json!(template["ts_ms"].as_i64().unwrap_or(0) + 1); + dangling["payload"] = + json!([{"UpdateNode": {"id": "node_never_created", "props": {"status": "closed"}}}]); + raw.push_str(&serde_json::to_string(&dangling).unwrap()); + raw.push('\n'); + std::fs::write(&log, &raw).unwrap(); + + let raw = read_log_raw(&repo).unwrap(); + let events = parse_events(&log_lines(&raw)).expect("log parses"); + let report = check_replay(&repo, &events, &raw); + + assert!( + report.passed(), + "a store a runtime can restore must certify as conformant; failures: {:?}", + report.failures + ); + assert!( + report + .notes + .iter() + .any(|n| n.contains("UpdateNode") && n.contains("skipped")), + "the skip must appear in the certificate — a report that hides it cannot be \ + audited, and masking corruption is the failure mode this must not become. \ + notes: {:?}", + report.notes + ); +}