Skip to main content

memstead_base/
check.rs

1//! Check records — the engine-recorded act of verification
2//! (agent-trust plan 14).
3//!
4//! A check is an agent recording "entity E checked, verdict ok |
5//! failed, via method M". It is engine state, never entity content:
6//! absent from markdown and `content_hash`, and it produces no mem
7//! commit — checking-touches-nothing is what makes check-staleness
8//! computable. Records are append-only JSONL under the workspace
9//! store (`.memstead/state/checks/checks.jsonl`); a newer check of
10//! the same kind supersedes older ones for state derivation but
11//! never erases them (kinds derive independently, see [`CheckKind`]).
12//!
13//! Unlike the friction ledger next door, recording here is NOT
14//! best-effort: a check the ledger failed to persist must refuse —
15//! the caller believes the act was recorded, and a silently dropped
16//! check is exactly the self-report dishonesty this tier exists to
17//! end. For the same reason there is no rotation cap: check history
18//! is the substrate process state derives from, not disposable
19//! telemetry.
20//!
21//! Each record carries plan-13 provenance (actor, client, declared
22//! role) plus the entity's `content_hash` at check time. State
23//! derivation compares that hash against the current one:
24//!
25//! - no record            → `never_checked`
26//! - hash matches, ok     → `checked_ok`
27//! - hash matches, failed → `check_failed`
28//! - hash differs         → `check_stale` (whatever the verdict was,
29//!   it no longer speaks to the current content — stated, never
30//!   silently carried forward)
31//!
32//! A `conformance` record additionally carries the mem's schema pin
33//! and goes stale when the pin moves ([`derive_state_pinned`]): the
34//! prose it judged against is no longer the prose in force.
35
36use std::io::Write;
37use std::path::{Path, PathBuf};
38
39use serde::{Deserialize, Serialize};
40
41/// The closed verdict vocabulary. Nuance goes in the method note or
42/// in process-mem entities — never in new verdict values.
43pub const VERDICTS: [&str; 2] = ["ok", "failed"];
44
45/// The closed kind vocabulary. `verification` is the default and
46/// today's behaviour: "I checked this entity's content". `conformance`
47/// is the semantic judgment "this entity satisfies its type's
48/// schema prose (`write_rules` / `writing_guidance`)" — recorded with
49/// the mem's schema pin, stamped by the engine at record time, so the
50/// verdict's freshness against both the content AND the prose version
51/// stays computable. A third kind is a separate decision; closed
52/// kinds keep health aggregation well-defined, matching the closed
53/// verdict vocabulary.
54pub const CHECK_KINDS: [&str; 2] = ["verification", "conformance"];
55
56/// Prefix of a caller-declared check kind the engine records verbatim
57/// and never interprets: `x-<name>`, `name` lowercase letters, digits
58/// and hyphens. The prefix makes the declaration deliberate — a typo of
59/// an engine kind cannot silently become a new kind — mirroring the
60/// rule that a third ENGINE kind is a separate decision. Foreign kinds
61/// never influence `check_state`; health lists them by count.
62pub const FOREIGN_KIND_PREFIX: &str = "x-";
63
64/// The typed code for a malformed finding.
65pub const INVALID_CHECK_FINDING_CODE: &str = "INVALID_CHECK_FINDING";
66
67/// A check kind from the closed vocabulary.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum CheckKind {
70    Verification,
71    Conformance,
72}
73
74/// What a caller may declare as a check's kind: one of the engine's
75/// two kinds, or a foreign `x-<name>` kind recorded verbatim.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum RecordKind {
78    Engine(CheckKind),
79    Foreign(String),
80}
81
82impl RecordKind {
83    /// Parse a wire kind: an engine kind, an `x-` kind (name non-empty,
84    /// lowercase letters, digits and hyphens), or `None` for anything
85    /// else — the vocabulary the refusal names is [`CHECK_KINDS`] plus
86    /// the `x-<name>` form.
87    pub fn from_wire(s: &str) -> Option<Self> {
88        if let Some(k) = CheckKind::from_wire(s) {
89            return Some(Self::Engine(k));
90        }
91        let name = s.strip_prefix(FOREIGN_KIND_PREFIX)?;
92        let well_formed = !name.is_empty()
93            && name
94                .chars()
95                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
96            && !name.starts_with('-')
97            && !name.ends_with('-');
98        well_formed.then(|| Self::Foreign(s.to_string()))
99    }
100
101    /// The engine kind, when this is one.
102    pub fn engine_kind(&self) -> Option<CheckKind> {
103        match self {
104            Self::Engine(k) => Some(*k),
105            Self::Foreign(_) => None,
106        }
107    }
108
109    /// Stable wire form.
110    pub fn as_wire(&self) -> &str {
111        match self {
112            Self::Engine(k) => k.as_str(),
113            Self::Foreign(s) => s.as_str(),
114        }
115    }
116
117    /// The vocabulary sentence a refusal carries.
118    pub fn vocabulary_hint() -> String {
119        format!(
120            "{}, or a caller-declared `{FOREIGN_KIND_PREFIX}<name>` kind (lowercase letters, digits, hyphens) the engine records verbatim and never interprets",
121            CHECK_KINDS.join(", ")
122        )
123    }
124}
125
126/// A structured finding riding a check record: WHAT failed (or what was
127/// observed) in a locatable form, so a `failed` verdict never forces
128/// the author to re-derive the failure from a free-text method note.
129/// `code` is the checker's own vocabulary (free for callers); the
130/// wrapper shape is fixed and refuses unknown keys.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct CheckFinding {
134    /// The checker's finding code (`hidden-premise`, `stale-source`, …).
135    pub code: String,
136    /// The section key the finding concerns, when it concerns one.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub section: Option<String>,
139    /// One or two sentences a reader can act on.
140    pub message: String,
141    /// What the finding rests on: a quote, a coordinate, a reference.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub evidence: Option<String>,
144}
145
146impl CheckFinding {
147    /// The shape sentence every refusal names.
148    pub const SHAPE: &'static str =
149        "{code: <non-empty>, message: <non-empty>, section?: <key>, evidence?: <text>}";
150
151    /// `code` and `message` are required and non-empty; `section` and
152    /// `evidence`, when present, are non-empty too.
153    pub fn validate(&self) -> Result<(), String> {
154        if self.code.trim().is_empty() {
155            return Err(format!(
156                "finding.code is required and must be non-empty — shape {}",
157                Self::SHAPE
158            ));
159        }
160        if self.message.trim().is_empty() {
161            return Err(format!(
162                "finding.message is required and must be non-empty — shape {}",
163                Self::SHAPE
164            ));
165        }
166        if self.section.as_deref().is_some_and(|s| s.trim().is_empty()) {
167            return Err(format!(
168                "finding.section, when given, must be non-empty — shape {}",
169                Self::SHAPE
170            ));
171        }
172        if self
173            .evidence
174            .as_deref()
175            .is_some_and(|s| s.trim().is_empty())
176        {
177            return Err(format!(
178                "finding.evidence, when given, must be non-empty — shape {}",
179                Self::SHAPE
180            ));
181        }
182        Ok(())
183    }
184
185    /// Parse and validate a finding from JSON (the CLI's `--finding` and
186    /// batch entries, the MCP param). An unknown key, a missing required
187    /// key, or an empty value is one typed refusal naming the shape.
188    pub fn from_json(value: serde_json::Value) -> Result<Self, String> {
189        let finding: CheckFinding = serde_json::from_value(value)
190            .map_err(|e| format!("finding does not match the shape {} ({e})", Self::SHAPE))?;
191        finding.validate()?;
192        Ok(finding)
193    }
194}
195
196impl CheckKind {
197    /// Parse a wire value; `None` for anything outside the vocabulary.
198    pub fn from_wire(s: &str) -> Option<Self> {
199        match s {
200            "verification" => Some(Self::Verification),
201            "conformance" => Some(Self::Conformance),
202            _ => None,
203        }
204    }
205
206    pub fn as_str(self) -> &'static str {
207        match self {
208            Self::Verification => "verification",
209            Self::Conformance => "conformance",
210        }
211    }
212}
213
214/// A check verdict from the closed vocabulary.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum Verdict {
217    Ok,
218    Failed,
219}
220
221impl Verdict {
222    /// Parse a wire value; `None` for anything outside the vocabulary.
223    pub fn from_wire(s: &str) -> Option<Self> {
224        match s {
225            "ok" => Some(Self::Ok),
226            "failed" => Some(Self::Failed),
227            _ => None,
228        }
229    }
230
231    pub fn as_str(self) -> &'static str {
232        match self {
233            Self::Ok => "ok",
234            Self::Failed => "failed",
235        }
236    }
237}
238
239/// One recorded check — the full ledger line.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct CheckRecord {
242    /// Unix epoch seconds at record time.
243    pub ts: u64,
244    /// Full entity id (`mem--slug`).
245    pub entity: String,
246    /// `ok` | `failed`.
247    pub verdict: String,
248    /// Optional free-text method note ("diffed against source spec",
249    /// "re-ran the derivation").
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub method: Option<String>,
252    /// The entity's `content_hash` at check time — the staleness
253    /// baseline.
254    pub entity_hash: String,
255    /// Recorded actor identity (plan-13 provenance).
256    pub actor: String,
257    /// Recorded client identity (`name@version`), when known.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub client: Option<String>,
260    /// The caller-declared role, or `"unspecified"` — recorded
261    /// honestly; downstream gates treat unspecified as
262    /// cannot-confirm, never as any real role.
263    pub role: String,
264    /// The caller-declared identity (agent-trust plan 15): an opaque
265    /// caller-chosen string, the ONLY comparator the independence
266    /// gate uses. Absent on ledger lines written before identities
267    /// existed and on identity-less callers — both downgrade every
268    /// comparison to `unconfirmable`, never to a guessed category.
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub identity: Option<String>,
271    /// The check kind, from [`CHECK_KINDS`]. Absent on ledger lines
272    /// written before kinds existed AND on freshly recorded
273    /// `verification` checks — both read as `verification`, so an
274    /// existing ledger upgrades with no migration and a kind-omitted
275    /// caller's lines stay byte-identical to before.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub kind: Option<String>,
278    /// For `conformance` records: the mem's schema pin
279    /// (`name@x.y.z`) as stamped by the engine at record time — never
280    /// caller-supplied, so a verdict cannot claim a prose version the
281    /// caller never read. Absent on `verification` records.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub schema_ref: Option<String>,
284    /// The structured finding the checker attached, when one was.
285    /// Absent on every line written before findings existed and on
286    /// finding-less checks — serde-default, so every existing line
287    /// still parses and finding-less lines stay byte-identical.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub finding: Option<CheckFinding>,
290}
291
292impl CheckRecord {
293    /// The record's ENGINE kind, legacy lines included: an absent or
294    /// unrecognised kind reads as `verification`, which is exactly what
295    /// every pre-kind line was; a foreign `x-<name>` kind is `None` —
296    /// recorded, listed, never aggregated into a state.
297    pub fn resolved_kind(&self) -> Option<CheckKind> {
298        match self.kind.as_deref() {
299            None => Some(CheckKind::Verification),
300            Some(k) if k.starts_with(FOREIGN_KIND_PREFIX) => None,
301            Some(k) => Some(CheckKind::from_wire(k).unwrap_or(CheckKind::Verification)),
302        }
303    }
304
305    /// The foreign kind, when the record carries one.
306    pub fn foreign_kind(&self) -> Option<&str> {
307        self.kind
308            .as_deref()
309            .filter(|k| k.starts_with(FOREIGN_KIND_PREFIX))
310    }
311}
312
313/// Derived per-entity check state.
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum CheckState {
316    NeverChecked,
317    CheckedOk,
318    CheckFailed,
319    CheckStale,
320}
321
322impl CheckState {
323    pub fn as_str(self) -> &'static str {
324        match self {
325            Self::NeverChecked => "never_checked",
326            Self::CheckedOk => "checked_ok",
327            Self::CheckFailed => "check_failed",
328            Self::CheckStale => "check_stale",
329        }
330    }
331}
332
333/// Derive the state from the newest record (if any) and the entity's
334/// current `content_hash`. Hash-only: this is the `verification`
335/// derivation, and stays the whole story for that kind — a schema
336/// re-pin never stales a verification verdict.
337pub fn derive_state(latest: Option<&CheckRecord>, current_hash: &str) -> CheckState {
338    derive_state_pinned(latest, current_hash, None)
339}
340
341/// Derive the state with schema-pin awareness: beyond the hash
342/// comparison, a record that carries a `schema_ref` (a `conformance`
343/// record) is stale when the mem's current pin differs from the
344/// recorded one — the prose the verdict judged against is no longer
345/// the prose in force. A mem that has since lost its pin entirely
346/// stales the verdict the same way. Records without a `schema_ref`
347/// (every `verification` record) are unaffected by the pin argument.
348pub fn derive_state_pinned(
349    latest: Option<&CheckRecord>,
350    current_hash: &str,
351    current_schema_ref: Option<&str>,
352) -> CheckState {
353    match latest {
354        None => CheckState::NeverChecked,
355        Some(rec) if rec.entity_hash != current_hash => CheckState::CheckStale,
356        Some(rec)
357            if rec.schema_ref.is_some() && rec.schema_ref.as_deref() != current_schema_ref =>
358        {
359            CheckState::CheckStale
360        }
361        Some(rec) if rec.verdict == "failed" => CheckState::CheckFailed,
362        Some(_) => CheckState::CheckedOk,
363    }
364}
365
366/// The ledger's directory under the workspace store:
367/// `<root>/.memstead/state/checks/`.
368fn checks_dir(workspace_root: &Path) -> PathBuf {
369    workspace_root
370        .join(crate::workspace_store::WORKSPACE_STORE_DIR)
371        .join("state")
372        .join("checks")
373}
374
375/// The ledger file path for a workspace.
376pub fn check_ledger_path(workspace_root: &Path) -> PathBuf {
377    checks_dir(workspace_root).join("checks.jsonl")
378}
379
380/// Append/read handle for a workspace's check ledger.
381#[derive(Debug, Clone)]
382pub struct CheckLedger {
383    path: PathBuf,
384}
385
386impl CheckLedger {
387    pub fn for_workspace(workspace_root: &Path) -> Self {
388        Self {
389            path: check_ledger_path(workspace_root),
390        }
391    }
392
393    /// Append one record. One `write` syscall of one complete line on
394    /// an `O_APPEND` handle — concurrent writers interleave whole
395    /// lines, never tear them. Errors propagate: a check that did not
396    /// persist must refuse at the surface.
397    pub fn record(&self, rec: &CheckRecord) -> std::io::Result<()> {
398        if let Some(dir) = self.path.parent() {
399            std::fs::create_dir_all(dir)?;
400        }
401        let mut line = serde_json::to_string(rec).map_err(std::io::Error::other)?;
402        line.push('\n');
403        let mut f = std::fs::OpenOptions::new()
404            .create(true)
405            .append(true)
406            .open(&self.path)?;
407        f.write_all(line.as_bytes())
408    }
409
410    /// All records, oldest first. A missing ledger is an empty one;
411    /// unparseable lines are skipped (a torn tail must not poison the
412    /// readable history).
413    pub fn all(&self) -> Vec<CheckRecord> {
414        let Ok(content) = std::fs::read_to_string(&self.path) else {
415            return Vec::new();
416        };
417        content
418            .lines()
419            .filter_map(|l| serde_json::from_str(l).ok())
420            .collect()
421    }
422
423    /// The newest record for one entity, of any kind. State
424    /// derivation is per (entity, kind) — use [`Self::latest_for_kind`]
425    /// there; this remains the "what happened last" accessor.
426    pub fn latest_for(&self, entity: &str) -> Option<CheckRecord> {
427        self.all().into_iter().rev().find(|r| r.entity == entity)
428    }
429
430    /// The newest record for one entity of one kind. A later check of
431    /// the OTHER kind never supersedes it: the two derivations answer
432    /// different questions.
433    pub fn latest_for_kind(&self, entity: &str, kind: CheckKind) -> Option<CheckRecord> {
434        self.all()
435            .into_iter()
436            .rev()
437            .find(|r| r.entity == entity && r.resolved_kind() == Some(kind))
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use tempfile::TempDir;
445
446    fn rec(entity: &str, verdict: &str, hash: &str) -> CheckRecord {
447        CheckRecord {
448            ts: 1,
449            entity: entity.to_string(),
450            verdict: verdict.to_string(),
451            method: None,
452            entity_hash: hash.to_string(),
453            actor: "cli".to_string(),
454            client: None,
455            role: "checker".to_string(),
456            identity: None,
457            kind: None,
458            schema_ref: None,
459            finding: None,
460        }
461    }
462
463    #[test]
464    fn state_derivation_covers_all_four_states() {
465        assert_eq!(derive_state(None, "h1"), CheckState::NeverChecked);
466        let ok = rec("m--e", "ok", "h1");
467        assert_eq!(derive_state(Some(&ok), "h1"), CheckState::CheckedOk);
468        assert_eq!(derive_state(Some(&ok), "h2"), CheckState::CheckStale);
469        let failed = rec("m--e", "failed", "h1");
470        assert_eq!(derive_state(Some(&failed), "h1"), CheckState::CheckFailed);
471        // A failed check on changed content is stale too — the verdict
472        // no longer speaks to current content either way.
473        assert_eq!(derive_state(Some(&failed), "h2"), CheckState::CheckStale);
474    }
475
476    #[test]
477    fn ledger_appends_and_serves_newest_per_entity() {
478        let tmp = TempDir::new().unwrap();
479        let ledger = CheckLedger::for_workspace(tmp.path());
480        assert!(ledger.latest_for("m--a").is_none());
481        ledger.record(&rec("m--a", "failed", "h1")).unwrap();
482        ledger.record(&rec("m--b", "ok", "h9")).unwrap();
483        ledger.record(&rec("m--a", "ok", "h2")).unwrap();
484        let latest = ledger.latest_for("m--a").unwrap();
485        assert_eq!(latest.verdict, "ok");
486        assert_eq!(latest.entity_hash, "h2");
487        // Supersession never erases: all three records remain.
488        assert_eq!(ledger.all().len(), 3);
489    }
490
491    #[test]
492    fn verdict_vocabulary_is_closed() {
493        assert!(Verdict::from_wire("ok").is_some());
494        assert!(Verdict::from_wire("failed").is_some());
495        assert!(Verdict::from_wire("passed").is_none());
496        assert!(Verdict::from_wire("OK").is_none());
497    }
498
499    fn conf(entity: &str, verdict: &str, hash: &str, pin: &str) -> CheckRecord {
500        CheckRecord {
501            kind: Some("conformance".to_string()),
502            schema_ref: Some(pin.to_string()),
503            ..rec(entity, verdict, hash)
504        }
505    }
506
507    #[test]
508    fn kind_vocabulary_is_closed() {
509        assert!(CheckKind::from_wire("verification").is_some());
510        assert!(CheckKind::from_wire("conformance").is_some());
511        assert!(CheckKind::from_wire("semantic").is_none());
512        assert!(CheckKind::from_wire("Conformance").is_none());
513    }
514
515    /// Criterion 5: a pre-kind ledger line (no `kind` field) parses
516    /// and derives as a `verification` record, byte-for-byte the old
517    /// shape on the write side too.
518    #[test]
519    fn legacy_lines_read_as_verification() {
520        let legacy = r#"{"ts":1,"entity":"m--e","verdict":"ok","entity_hash":"h1","actor":"cli","role":"checker"}"#;
521        let parsed: CheckRecord = serde_json::from_str(legacy).unwrap();
522        assert_eq!(parsed.resolved_kind(), Some(CheckKind::Verification));
523        // A freshly built verification record serialises with no kind
524        // and no schema_ref key at all.
525        let fresh = rec("m--e", "ok", "h1");
526        let line = serde_json::to_string(&fresh).unwrap();
527        assert!(!line.contains("kind"));
528        assert!(!line.contains("schema_ref"));
529        // An identity-less record carries no identity key either —
530        // pre-plan-15 lines and identity-less callers stay
531        // byte-identical (agent-trust plan 15, criterion 3).
532        assert!(!line.contains("identity"));
533    }
534
535    /// Criterion 3: state derives per (entity, kind) — a later check
536    /// of the other kind does not supersede.
537    #[test]
538    fn latest_is_per_kind() {
539        let tmp = TempDir::new().unwrap();
540        let ledger = CheckLedger::for_workspace(tmp.path());
541        ledger.record(&rec("m--a", "ok", "h1")).unwrap();
542        ledger
543            .record(&conf("m--a", "failed", "h1", "planning@1.0.0"))
544            .unwrap();
545        let v = ledger
546            .latest_for_kind("m--a", CheckKind::Verification)
547            .unwrap();
548        assert_eq!(v.verdict, "ok");
549        let c = ledger
550            .latest_for_kind("m--a", CheckKind::Conformance)
551            .unwrap();
552        assert_eq!(c.verdict, "failed");
553        assert_eq!(c.schema_ref.as_deref(), Some("planning@1.0.0"));
554    }
555
556    /// Criterion 4: a conformance verdict is stale on a content move
557    /// AND on a pin move; a verification verdict ignores pin moves.
558    #[test]
559    fn conformance_stales_on_pin_move_verification_does_not() {
560        let c = conf("m--e", "ok", "h1", "planning@1.0.0");
561        assert_eq!(
562            derive_state_pinned(Some(&c), "h1", Some("planning@1.0.0")),
563            CheckState::CheckedOk
564        );
565        assert_eq!(
566            derive_state_pinned(Some(&c), "h2", Some("planning@1.0.0")),
567            CheckState::CheckStale
568        );
569        assert_eq!(
570            derive_state_pinned(Some(&c), "h1", Some("planning@2.0.0")),
571            CheckState::CheckStale
572        );
573        // The mem losing its pin stales the verdict too.
574        assert_eq!(
575            derive_state_pinned(Some(&c), "h1", None),
576            CheckState::CheckStale
577        );
578        // Verification: unaffected by any pin argument.
579        let v = rec("m--e", "ok", "h1");
580        assert_eq!(
581            derive_state_pinned(Some(&v), "h1", Some("planning@9.0.0")),
582            CheckState::CheckedOk
583        );
584        assert_eq!(derive_state(Some(&v), "h1"), CheckState::CheckedOk);
585    }
586
587    // --- findings and open kinds ---
588
589    #[test]
590    fn finding_shape_is_fixed_and_validated_whole() {
591        let ok = CheckFinding::from_json(serde_json::json!({
592            "code": "hidden-premise", "message": "The step assumes X.", "section": "step"
593        }))
594        .unwrap();
595        assert_eq!(ok.code, "hidden-premise");
596        assert_eq!(ok.section.as_deref(), Some("step"));
597        for bad in [
598            serde_json::json!({ "message": "no code" }),
599            serde_json::json!({ "code": "x" }),
600            serde_json::json!({ "code": "", "message": "empty code" }),
601            serde_json::json!({ "code": "x", "message": "   " }),
602            serde_json::json!({ "code": "x", "message": "m", "severity": "high" }),
603            serde_json::json!({ "code": "x", "message": "m", "section": "" }),
604        ] {
605            let err = CheckFinding::from_json(bad.clone()).unwrap_err();
606            assert!(err.contains("shape"), "{bad}: {err}");
607        }
608    }
609
610    #[test]
611    fn open_kinds_parse_only_with_the_prefix_and_never_resolve_to_an_engine_kind() {
612        assert_eq!(
613            RecordKind::from_wire("verification"),
614            Some(RecordKind::Engine(CheckKind::Verification))
615        );
616        assert_eq!(
617            RecordKind::from_wire("x-step-walk"),
618            Some(RecordKind::Foreign("x-step-walk".to_string()))
619        );
620        for bad in ["step-walk", "x-", "x-Step", "x--a", "x-a-", "X-a"] {
621            assert!(RecordKind::from_wire(bad).is_none(), "{bad}");
622        }
623        assert!(RecordKind::vocabulary_hint().contains("x-<name>"));
624        let mut r = rec("m--e", "ok", "h");
625        r.kind = Some("x-step-walk".to_string());
626        assert_eq!(r.resolved_kind(), None);
627        assert_eq!(r.foreign_kind(), Some("x-step-walk"));
628        r.kind = None;
629        assert_eq!(r.resolved_kind(), Some(CheckKind::Verification));
630        r.kind = Some("conformance".to_string());
631        assert_eq!(r.resolved_kind(), Some(CheckKind::Conformance));
632        // A pre-`x-` unrecognised kind keeps its legacy reading.
633        r.kind = Some("mystery".to_string());
634        assert_eq!(r.resolved_kind(), Some(CheckKind::Verification));
635    }
636
637    #[test]
638    fn pre_finding_ledger_lines_parse_and_derive_unchanged_and_findings_round_trip() {
639        let tmp = TempDir::new().unwrap();
640        let ledger = CheckLedger::for_workspace(tmp.path());
641        let dir = check_ledger_path(tmp.path());
642        std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
643        // A line written before findings existed: no `finding`, no `kind`.
644        std::fs::write(
645            &dir,
646            "{\"ts\":1,\"entity\":\"m--e\",\"verdict\":\"failed\",\"entity_hash\":\"h\",\"actor\":\"cli\",\"role\":\"unspecified\"}\n",
647        )
648        .unwrap();
649        let old = ledger
650            .latest_for_kind("m--e", CheckKind::Verification)
651            .unwrap();
652        assert!(old.finding.is_none());
653        assert_eq!(derive_state(Some(&old), "h"), CheckState::CheckFailed);
654
655        let mut with = rec("m--e", "failed", "h");
656        with.ts = 2;
657        with.finding = Some(CheckFinding {
658            code: "hidden-premise".into(),
659            section: Some("step".into()),
660            message: "The step assumes X.".into(),
661            evidence: None,
662        });
663        ledger.record(&with).unwrap();
664        // A foreign-kind line beside it moves no verification state.
665        let mut foreign = rec("m--e", "ok", "h");
666        foreign.ts = 3;
667        foreign.kind = Some("x-step-walk".into());
668        ledger.record(&foreign).unwrap();
669        let latest = ledger
670            .latest_for_kind("m--e", CheckKind::Verification)
671            .unwrap();
672        assert_eq!(
673            latest.ts, 2,
674            "the foreign record is not the latest verification record"
675        );
676        assert_eq!(latest.finding.as_ref().unwrap().code, "hidden-premise");
677        assert_eq!(derive_state(Some(&latest), "h"), CheckState::CheckFailed);
678        let text = std::fs::read_to_string(&dir).unwrap();
679        assert!(text.contains("\"finding\":{\"code\":\"hidden-premise\",\"section\":\"step\",\"message\":\"The step assumes X.\"}"), "{text}");
680        assert!(text.contains("\"kind\":\"x-step-walk\""));
681        assert_eq!(text.lines().count(), 3, "append-only");
682    }
683}