Skip to main content

dbmd_core/
validate.rs

1//! `validate` — the validation engine.
2//!
3//! The canonical issue-code vocabulary is **SPEC.md § Validation** (that table
4//! is the single source of truth). This module implements exactly those codes
5//! — no more, no fewer. If a code is added here it must be added to the SPEC
6//! table in the same change. The codes are exposed as the [`codes`] constants
7//! so call sites never spell a code as a bare string literal.
8//!
9//! **Two scopes.** [`validate_working_set`] is the loop default: content files
10//! changed since `since`, plus any file whose wiki-links target a changed path.
11//! The changed set and the per-file checks are O(changed); the incoming linkers
12//! are found by a *single* embedded-ripgrep pass over the store for the whole
13//! changed set at once ([`Store::find_links_to_any`], one scan — not a full read
14//! per changed object, and not the parse-the-tree walk `--all` does). On this
15//! changed-set path it never builds the global cross-file state.
16//!
17//! The **one** exception is the vacuous-pass guard: when the change log records
18//! no objects since the cutoff and no explicit `--since` was given (a fresh
19//! store, a missing/empty `log.md`, or external edits never logged), the default
20//! call falls back to a single per-file content sweep ([`Store::walk`]) so an
21//! externally edited or freshly copied store cannot pass validation vacuously.
22//! That fallback is O(store) by design; the O(changed) guarantee is about the
23//! normal post-write path, not this safety net.
24//!
25//! [`validate_all`] is the full SWEEP: it adds the checks that need the global
26//! cross-file state — entity-dedup `DUP_*`, every-index sync, and `log.md`
27//! ordering.
28//!
29//! ## Why this module is self-contained
30//!
31//! Validation does its own frontmatter split, YAML parse, wiki-link scan,
32//! log-header parse, and file walk here, reading only the two public,
33//! caller-populated fields of a [`Store`]: [`Store::root`] and
34//! [`Store::config`] — rather than routing through the sibling modules
35//! ([`crate::parser`], [`crate::store`], [`crate::log`], [`crate::index`]).
36//! Keeping the checks local lets the validator report precise, per-issue
37//! diagnostics (exact codes, file, and context) without coupling its output to
38//! incidental behavior of the shared readers; the public surface and the
39//! emitted issue vocabulary are the contract.
40
41use std::collections::{BTreeMap, BTreeSet, HashMap};
42use std::path::{Component, Path, PathBuf};
43
44use chrono::{DateTime, FixedOffset, NaiveDateTime};
45use serde_norway::Value;
46
47use crate::parser::{Schema, Shape};
48use crate::store::Store;
49
50/// Severity of a validation [`Issue`]. Any [`Severity::Error`] fails validation
51/// (non-zero exit); warnings and info do not.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Severity {
54    /// Blocks: a hard violation of the format or doctrine.
55    Error,
56    /// A decision point the agent resolves at its discretion.
57    Warning,
58    /// Visibility only; never affects exit status.
59    Info,
60}
61
62/// A single structured validation finding. Agent-primary and machine-parseable
63/// via `--json`; `suggestion` is a deterministic remediation hint the agent
64/// applies without guessing.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Issue {
67    /// The severity; only [`Severity::Error`] fails validation.
68    pub severity: Severity,
69    /// The structured code, e.g. `"WIKI_LINK_SHORT_FORM"` — one of [`codes`].
70    pub code: &'static str,
71    /// The file the issue is about.
72    pub file: PathBuf,
73    /// The 1-based line, when applicable.
74    pub line: Option<u32>,
75    /// The frontmatter key, when the issue is about a specific field.
76    pub key: Option<String>,
77    /// A human-readable message.
78    pub message: String,
79    /// A deterministic remediation hint, when one exists.
80    pub suggestion: Option<String>,
81    /// Other files involved (e.g. the duplicate partner in a collision).
82    pub related: Vec<PathBuf>,
83}
84
85impl Issue {
86    /// True if this issue fails validation (i.e. its severity is
87    /// [`Severity::Error`]).
88    pub fn is_error(&self) -> bool {
89        matches!(self.severity, Severity::Error)
90    }
91}
92
93/// The canonical validation issue codes — one constant per row of the SPEC.md
94/// § Validation table. Call sites reference these instead of bare strings so
95/// the code and the SPEC table can never silently drift.
96pub mod codes {
97    /// path has no `DB.md`; not a db.md store.
98    pub const NOT_A_STORE: &str = "NOT_A_STORE";
99    /// the store's `DB.md` is not `type: db-md`.
100    pub const DB_MD_BAD_TYPE: &str = "DB_MD_BAD_TYPE";
101    /// the store's `DB.md` frontmatter lacks `scope` or `owner`.
102    pub const DB_MD_MISSING_FIELD: &str = "DB_MD_MISSING_FIELD";
103    /// `DB.md` has an `##` section other than the three recognized ones.
104    pub const DB_MD_UNKNOWN_SECTION: &str = "DB_MD_UNKNOWN_SECTION";
105    /// a `DB.md ## Schemas` field declaration is malformed (empty or duplicate
106    /// field name) or carries an unrecognized modifier.
107    pub const DB_MD_SCHEMA_FIELD: &str = "DB_MD_SCHEMA_FIELD";
108    /// content file has no `type:`.
109    pub const FM_MISSING_TYPE: &str = "FM_MISSING_TYPE";
110    /// content file has no `created:`.
111    pub const FM_MISSING_CREATED: &str = "FM_MISSING_CREATED";
112    /// content file has no `updated:`.
113    pub const FM_MISSING_UPDATED: &str = "FM_MISSING_UPDATED";
114    /// content file can't be read (not valid UTF-8, or an I/O error).
115    pub const FM_UNREADABLE: &str = "FM_UNREADABLE";
116    /// frontmatter block isn't valid YAML.
117    pub const FM_MALFORMED_YAML: &str = "FM_MALFORMED_YAML";
118    /// `created` or `updated` isn't ISO-8601.
119    pub const FM_BAD_TIMESTAMP: &str = "FM_BAD_TIMESTAMP";
120    /// `meta-type` is present but not one of fact / operational / conclusion.
121    pub const FM_BAD_META_TYPE: &str = "FM_BAD_META_TYPE";
122    /// `id` is present but unusable as an identifier (non-scalar, empty, or
123    /// contains whitespace). Warning: the recommended lowercase-ULID form is
124    /// never enforced — hand-authored opaque ids stay legal (SPEC v0.4).
125    pub const FM_BAD_ID: &str = "FM_BAD_ID";
126    /// content file has no `summary`.
127    pub const SUMMARY_MISSING: &str = "SUMMARY_MISSING";
128    /// `summary` present but empty.
129    pub const SUMMARY_EMPTY: &str = "SUMMARY_EMPTY";
130    /// `summary` contains newlines.
131    pub const SUMMARY_MULTILINE: &str = "SUMMARY_MULTILINE";
132    /// `summary` > 200 chars.
133    pub const SUMMARY_TOO_LONG: &str = "SUMMARY_TOO_LONG";
134    /// wiki-link target isn't a full store-relative path.
135    pub const WIKI_LINK_SHORT_FORM: &str = "WIKI_LINK_SHORT_FORM";
136    /// wiki-link target file doesn't exist.
137    pub const WIKI_LINK_BROKEN: &str = "WIKI_LINK_BROKEN";
138    /// wiki-link target matches multiple files (defensive).
139    pub const WIKI_LINK_AMBIGUOUS: &str = "WIKI_LINK_AMBIGUOUS";
140    /// wiki-link target carries a `.md` extension — drop it.
141    pub const WIKI_LINK_HAS_EXTENSION: &str = "WIKI_LINK_HAS_EXTENSION";
142    /// frontmatter list uses inline `[[[a]], [[b]]]` — use block form.
143    pub const WIKI_LINK_FLOW_FORM_LIST: &str = "WIKI_LINK_FLOW_FORM_LIST";
144    /// two files declare the same explicit `id`.
145    pub const DUP_ID: &str = "DUP_ID";
146    /// two records of a type collide on a `DB.md ## Schemas` `unique:` key.
147    pub const DUP_UNIQUE_KEY: &str = "DUP_UNIQUE_KEY";
148    /// a `DB.md` schema requires a field that's absent.
149    pub const SCHEMA_MISSING_REQUIRED: &str = "SCHEMA_MISSING_REQUIRED";
150    /// a value doesn't match the schema's shape modifier.
151    pub const SCHEMA_SHAPE_MISMATCH: &str = "SCHEMA_SHAPE_MISMATCH";
152    /// a `link to <prefix>/` field has a plain or wrong-prefix value.
153    pub const SCHEMA_LINK_PREFIX_MISMATCH: &str = "SCHEMA_LINK_PREFIX_MISMATCH";
154    /// a value isn't in the schema's `enum`.
155    pub const SCHEMA_ENUM_VIOLATION: &str = "SCHEMA_ENUM_VIOLATION";
156    /// a write was attempted on a `### Frozen pages` path (write-time).
157    pub const POLICY_FROZEN_PAGE: &str = "POLICY_FROZEN_PAGE";
158    /// a file with an `### Ignored types` type exists.
159    pub const POLICY_IGNORED_TYPE_PRESENT: &str = "POLICY_IGNORED_TYPE_PRESENT";
160    /// a `meta-type: conclusion` record derives from an ignored-type record.
161    pub const POLICY_IGNORED_TYPE_DERIVED: &str = "POLICY_IGNORED_TYPE_DERIVED";
162    /// a `log.md` entry header timestamp is unparseable.
163    pub const LOG_BAD_TIMESTAMP: &str = "LOG_BAD_TIMESTAMP";
164    /// a `log.md` entry kind isn't recognized.
165    pub const LOG_UNKNOWN_KIND: &str = "LOG_UNKNOWN_KIND";
166    /// `log.md` entries aren't in non-decreasing time order (possible rewrite).
167    pub const LOG_OUT_OF_ORDER: &str = "LOG_OUT_OF_ORDER";
168    /// a non-empty canonical folder lacks `index.md`.
169    pub const INDEX_MISSING: &str = "INDEX_MISSING";
170    /// an `index.md` lists a file that no longer exists.
171    pub const INDEX_STALE_ENTRY: &str = "INDEX_STALE_ENTRY";
172    /// a file isn't listed in its folder's `index.md`.
173    pub const INDEX_MISSING_ENTRY: &str = "INDEX_MISSING_ENTRY";
174    /// an `index.md` sits in an empty / non-canonical folder.
175    pub const INDEX_ORPHAN: &str = "INDEX_ORPHAN";
176    /// an index's `scope:` doesn't match its filesystem location.
177    pub const INDEX_WRONG_SCOPE: &str = "INDEX_WRONG_SCOPE";
178    /// an index entry's text doesn't match the target file's `summary`.
179    pub const INDEX_SUMMARY_MISMATCH: &str = "INDEX_SUMMARY_MISMATCH";
180    /// a type-folder's `index.jsonl` twin is missing.
181    pub const INDEX_JSONL_MISSING: &str = "INDEX_JSONL_MISSING";
182    /// a file isn't in the `index.jsonl`, or a jsonl record points at a missing
183    /// file.
184    pub const INDEX_JSONL_DESYNC: &str = "INDEX_JSONL_DESYNC";
185    /// a `index.jsonl` record's fields don't match the file's frontmatter.
186    pub const INDEX_JSONL_STALE: &str = "INDEX_JSONL_STALE";
187    /// `tags` isn't a flat YAML list of short scalar labels.
188    pub const TAGS_MALFORMED: &str = "TAGS_MALFORMED";
189    /// a line in `assets.jsonl` is not a valid asset record.
190    pub const ASSET_MANIFEST_MALFORMED: &str = "ASSET_MANIFEST_MALFORMED";
191    /// a content file references an `asset`/`assets` path with no record in
192    /// `assets.jsonl` (run `dbmd assets scan`).
193    pub const ASSET_UNDECLARED: &str = "ASSET_UNDECLARED";
194    /// an `assets.jsonl` record names a wrapper file that does not exist.
195    pub const ASSET_WRAPPER_BROKEN: &str = "ASSET_WRAPPER_BROKEN";
196    /// an `assets.jsonl` record's path is referenced by no wrapper.
197    pub const ASSET_MANIFEST_ORPHAN: &str = "ASSET_MANIFEST_ORPHAN";
198    /// an `asset`/`assets` path points at a tracked markdown content file.
199    pub const ASSET_PATH_IS_CONTENT: &str = "ASSET_PATH_IS_CONTENT";
200}
201
202/// The SPEC's `summary` length bound (chars). Over it → `SUMMARY_TOO_LONG`.
203const MAX_SUMMARY_LEN: usize = 200;
204
205/// Recognized `log.md` entry kinds (SPEC § `log.md`). Anything else →
206/// `LOG_UNKNOWN_KIND` (warning, not error).
207const RECOGNIZED_LOG_KINDS: &[&str] = &[
208    "ingest",
209    "create",
210    "update",
211    "delete",
212    "rename",
213    "link",
214    "validate",
215    "index-rebuild",
216    "contradiction",
217];
218
219// ─────────────────────────────────────────────────────────────────────────────
220//  Public entrypoints
221// ─────────────────────────────────────────────────────────────────────────────
222
223/// **Loop default.** Validate the working set: content files changed since
224/// `since` (default: the last `validate` entry in `log.md`), plus any file whose
225/// wiki-links target a changed/renamed/removed path. Per-file *checks* only —
226/// none of the cross-file global passes (entity-dedup, every-index sync,
227/// `log.md` ordering) that `--all` adds. If the default call finds no logged
228/// changed objects, it falls back to a per-file content sweep so an externally
229/// edited or freshly copied store cannot pass vacuously.
230///
231/// **Cost.** The changed set is read from `log.md` — O(changed): every
232/// `create`/`update`/`ingest`/`rename`/`delete`/`link` entry newer than the
233/// cutoff names an object. Per-file frontmatter + link-doctrine checks then run
234/// over that set plus its incoming linkers — also O(changed). The one part that
235/// is *not* O(changed) is discovering those incoming linkers: a link to a
236/// changed path can live in the body or a typed frontmatter field of any file,
237/// so it is found by a **single** embedded-ripgrep pass over the store
238/// ([`Store::find_links_to_any`]) for the whole changed set at once — one store
239/// scan, flat in the changed-set size. (It was previously a full store read
240/// *per* changed object — `O(changed × store)`; that is the blow-up this path
241/// no longer pays.) The unavoidable single content scan is the same shape as
242/// free-text `dbmd search`; the sidecar `links` projection can't replace it
243/// because it omits body/typed-field edges.
244pub fn validate_working_set(
245    store: &Store,
246    since: Option<DateTime<FixedOffset>>,
247) -> crate::Result<Vec<Issue>> {
248    if !store_marker_present(store) {
249        return Ok(vec![not_a_store_issue(store)]);
250    }
251
252    let cutoff = match since {
253        Some(ts) => Some(ts),
254        None => last_validate_at(store),
255    };
256
257    // 1. Changed objects, straight from the log (O(changed) — never a walk).
258    let changed = changed_objects_since(store, cutoff);
259    if changed.is_empty() && since.is_none() {
260        return validate_content_sweep(store);
261    }
262
263    // 2. Add every file with an incoming wiki-link to a changed/renamed/removed
264    //    path (the linker may now be stale even though it didn't change). The
265    //    incoming-linker scan is `Store::find_links_to_any` — ONE embedded-ripgrep
266    //    pass over the store for the WHOLE changed set (one `.md` walk, one
267    //    presence-only/early-exit scan per file), not one walk per object. This
268    //    is the fix for the `O(changed × store)` blow-up that calling
269    //    `find_links_to` in a loop produced (a full store read per changed
270    //    object); the cost is now a single store scan regardless of how many
271    //    objects changed. A returned self-link is harmlessly deduped by the set
272    //    (the object is already inserted below).
273    let changed_targets: Vec<PathBuf> = changed.iter().cloned().collect();
274    let mut working: BTreeSet<PathBuf> = changed;
275    for linker in store.find_links_to_any(&changed_targets)? {
276        working.insert(linker);
277    }
278
279    let mut issues = Vec::new();
280    for rel in &working {
281        let abs = store.root.join(rel);
282        // A changed path can be a *deletion* — skip files that no longer exist;
283        // the incoming-linker scan above already flagged links into them.
284        if !abs.is_file() {
285            continue;
286        }
287        // `None` basename index: the working-set pass does not build the
288        // store-wide basename map (that is a `--all`-only structure), so a bare
289        // short-form target is reported as plain `WIKI_LINK_SHORT_FORM` and the
290        // `--all` sweep does the ambiguity upgrade.
291        check_content_file(store, rel, &abs, None, &mut issues);
292    }
293    issues.sort_by(issue_order);
294    Ok(issues)
295}
296
297fn validate_content_sweep(store: &Store) -> crate::Result<Vec<Issue>> {
298    let mut issues = Vec::new();
299    for rel in store.walk()? {
300        let abs = store.root.join(&rel);
301        check_content_file(store, &rel, &abs, None, &mut issues);
302    }
303    issues.sort_by(issue_order);
304    Ok(issues)
305}
306
307/// **Full SWEEP (O(store)).** Validate every file, every link, and every index,
308/// adding the cross-file checks that need global state: entity-dedup `DUP_*`,
309/// every-index sync (md + jsonl), and `log.md` ordering. CI / recovery, not the
310/// loop.
311pub fn validate_all(store: &Store) -> crate::Result<Vec<Issue>> {
312    if !store_marker_present(store) {
313        return Ok(vec![not_a_store_issue(store)]);
314    }
315
316    let mut issues = Vec::new();
317
318    // Store-identity file: `DB.md` shape (type / required fields / section
319    // headers). A single root file, checked once in the sweep — not a content
320    // file (it carries no `summary`), so it is not part of `walk_content_files`.
321    check_db_md(store, &mut issues);
322
323    let files = walk_content_files(&store.root);
324
325    // The basename index makes the short-form wiki-link check able to upgrade a
326    // bare-basename target to `WIKI_LINK_AMBIGUOUS` when it matches ≥2 files.
327    // Built once from the already-gathered sweep list (no extra walk); only the
328    // `--all` path has it (the working-set path stays O(changed)).
329    let basenames = build_basename_index(&files);
330
331    // Per-file checks over the whole store.
332    let mut parsed: Vec<(PathBuf, Parsed)> = Vec::new();
333    for rel in &files {
334        let abs = store.root.join(rel);
335        if let Some(p) = check_content_file(store, rel, &abs, Some(&basenames), &mut issues) {
336            parsed.push((rel.clone(), p));
337        }
338    }
339
340    // Cross-file: hard `id` + soft schema-declared `unique:` dedup collisions.
341    check_duplicates(store, &parsed, &mut issues);
342
343    // Cross-file: hierarchical index.md + index.jsonl sync.
344    check_indexes(store, &files, &mut issues);
345
346    // Cross-file: log.md well-formedness + ordering.
347    check_log(store, &mut issues);
348
349    // Cross-file: asset manifest (assets.jsonl) integrity against wrapper
350    // declarations. Text-only, no hashing, no byte reads — a SWEEP check like
351    // dedup. Byte presence/correctness is `dbmd assets verify`, not validate, so
352    // a fresh clone with no restored bytes still passes here.
353    check_assets(store, &parsed, &mut issues);
354
355    issues.sort_by(issue_order);
356    Ok(issues)
357}
358
359// ─────────────────────────────────────────────────────────────────────────────
360//  Per-file content checks (shared by both scopes)
361// ─────────────────────────────────────────────────────────────────────────────
362
363/// What `validate_all`'s cross-file pass needs from a per-file parse: the
364/// parsed YAML mapping (for dedup keys) and the raw frontmatter text (for
365/// text-based wiki-link extraction). The body and fence-line are consumed
366/// inline during the per-file pass and not carried here.
367struct Parsed {
368    /// The parsed top-level YAML mapping, keyed by string. `None` ⇒ malformed
369    /// YAML (a `FM_MALFORMED_YAML` was already emitted).
370    fm: Option<BTreeMap<String, Value>>,
371    /// The raw frontmatter YAML text (between the fences) — the source for
372    /// text-based wiki-link extraction in dedup.
373    fm_yaml: String,
374}
375
376/// Run every per-file check on one content file, pushing issues. Returns the
377/// parsed file so `validate_all` can reuse it for cross-file checks. Returns
378/// `None` only when the file is unreadable or has no frontmatter block at all
379/// (which for a content file is itself reported).
380fn check_content_file(
381    store: &Store,
382    rel: &Path,
383    abs: &Path,
384    basenames: Option<&BasenameIndex>,
385    issues: &mut Vec<Issue>,
386) -> Option<Parsed> {
387    let text = match std::fs::read_to_string(abs) {
388        Ok(t) => t,
389        Err(e) => {
390            // The file exists in the walk but can't be read as UTF-8 text
391            // (invalid bytes) or hit an I/O error. Returning `None` silently
392            // here let a store whose only content file was binary garbage pass
393            // `dbmd validate` with exit 0 — the exact vacuous-pass the fallback
394            // sweep exists to prevent. Report it so the agent gets an actionable
395            // diagnostic naming the unreadable file (and `index rebuild`, which
396            // hard-fails on the same file, isn't the only signal).
397            let detail = if e.kind() == std::io::ErrorKind::InvalidData {
398                "file is not valid UTF-8 text".to_string()
399            } else {
400                format!("file could not be read: {e}")
401            };
402            push(
403                issues,
404                Severity::Error,
405                codes::FM_UNREADABLE,
406                rel,
407                None,
408                None,
409                format!("content file is unreadable: {detail}"),
410                Some(
411                    "save the file as UTF-8 text, or remove it if it isn't a db.md content file"
412                        .into(),
413                ),
414                vec![],
415            );
416            return None;
417        }
418    };
419
420    let is_content = is_content_file(rel);
421
422    let (fm_yaml, body, fm_end_line) = match split_frontmatter(&text) {
423        Some(split) => split,
424        None => {
425            // No frontmatter at all. For a content file that means there's no
426            // `type:` and no `summary:` — report both the way a parsed-but-empty
427            // file would, so the agent gets the same actionable codes.
428            if is_content {
429                push(
430                    issues,
431                    Severity::Error,
432                    codes::FM_MISSING_TYPE,
433                    rel,
434                    None,
435                    Some("type".into()),
436                    "content file has no frontmatter `type:`".into(),
437                    Some("add a YAML frontmatter block with `type:`".into()),
438                    vec![],
439                );
440                push(
441                    issues,
442                    Severity::Error,
443                    codes::SUMMARY_MISSING,
444                    rel,
445                    None,
446                    Some("summary".into()),
447                    "content file has no `summary`".into(),
448                    Some("run `dbmd fm init`".into()),
449                    vec![],
450                );
451            }
452            return None;
453        }
454    };
455
456    // Parse the YAML block.
457    let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
458        Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
459        // An empty frontmatter block parses as Null; treat as an empty mapping.
460        Ok(Value::Null) => Some(BTreeMap::new()),
461        Ok(_) => {
462            // A scalar / sequence at the top level isn't a frontmatter mapping.
463            // Anchor to line 1 — the frontmatter block's opening `---`; the whole
464            // block is opaque, so there is no single offending field line.
465            push(
466                issues,
467                Severity::Error,
468                codes::FM_MALFORMED_YAML,
469                rel,
470                Some(1),
471                None,
472                "frontmatter is not a YAML mapping".into(),
473                Some("repair the frontmatter YAML mapping, then rerun `dbmd validate`".into()),
474                vec![],
475            );
476            None
477        }
478        Err(e) => {
479            // Anchor to line 1 (the opening `---`): an unparseable block has no
480            // single offending field line; the agent re-reads the whole block.
481            push(
482                issues,
483                Severity::Error,
484                codes::FM_MALFORMED_YAML,
485                rel,
486                Some(1),
487                None,
488                format!("frontmatter block isn't valid YAML: {e}"),
489                Some("repair the frontmatter YAML block, then rerun `dbmd validate`".into()),
490                vec![],
491            );
492            None
493        }
494    };
495
496    if let Some(map) = &fm {
497        // The detailed frontmatter checks only run when the YAML parsed.
498        check_frontmatter(store, rel, map, &fm_yaml, basenames, issues, is_content);
499    }
500
501    // Wiki-link doctrine checks run on the body of content files. They are NOT
502    // run on:
503    //   - the root append-only meta files `log.md`/`DB.md` — they reach this
504    //     function only via the working-set incoming-linker scan (`walk_all_md`
505    //     includes them), and `validate --all` never link-checks their bodies. A
506    //     historical `[[deleted-page]]` mention in a `log.md` note, or a `[[…]]`
507    //     in DB.md's `## Agent instructions`, must not be `WIKI_LINK_BROKEN`; the
508    //     log is append-only, so "fix the link" can't even be applied.
509    //   - the derived catalogs `index.md`/`index.jsonl` — their "links" are
510    //     GENERATED catalog entries, not authored body wiki-links. A folder's
511    //     `index.md` is pulled into the working set as an incoming linker (an
512    //     entry `[[records/contacts/a]]` IS a wiki-link to a member, so touching
513    //     or deleting any member drags its folder `index.md` in). Its integrity
514    //     is the job of `check_indexes` under `--all`, which reports a dangling
515    //     entry as `INDEX_STALE_ENTRY` ("run `dbmd index rebuild`"). Body-link-
516    //     checking it here instead emitted `WIKI_LINK_BROKEN` ("create the
517    //     target") for the SAME condition — a different code with the OPPOSITE
518    //     remedy across the loop default vs the sweep, steering an agent to
519    //     recreate deleted data. `walk_content_files` skips `index.md` under
520    //     `--all` for exactly this reason; the working-set scope must match.
521    // Without these guards the two scopes disagree on the same store.
522    if !is_root_meta_file(rel) && !is_index_catalog_file(rel) {
523        check_body_wiki_links(store, rel, &body, fm_end_line, basenames, issues);
524    }
525
526    Some(Parsed { fm, fm_yaml })
527}
528
529/// All frontmatter-level checks for a content file with valid YAML.
530fn check_frontmatter(
531    store: &Store,
532    rel: &Path,
533    fm: &BTreeMap<String, Value>,
534    fm_yaml: &str,
535    basenames: Option<&BasenameIndex>,
536    issues: &mut Vec<Issue>,
537    is_content: bool,
538) {
539    let type_ = fm.get("type").and_then(scalar_string);
540
541    // ── type ────────────────────────────────────────────────────────────────
542    if is_content && type_.is_none() {
543        push(
544            issues,
545            Severity::Error,
546            codes::FM_MISSING_TYPE,
547            rel,
548            fm_key_line_or_top(fm_yaml, "type"),
549            Some("type".into()),
550            "content file has no `type:`".into(),
551            Some("add a `type:` field (e.g. `type: contact`)".into()),
552            vec![],
553        );
554    }
555
556    // ── meta-type (records-only epistemic class; closed enum) ─────────────────
557    // Present-but-out-of-enum is an error; absent is fine (effective default
558    // `fact`). Sources don't normally carry one, but validating the value when
559    // present is layer-agnostic and harmless.
560    if is_content {
561        // Branch on the raw value, NOT `and_then(scalar_string)`. Pre-filtering
562        // through `scalar_string` made a list/mapping value (which returns `None`)
563        // short-circuit the whole check, so a structurally-wrong `meta-type`
564        // slipped through clean AND was silently reclassified as the default
565        // `fact` by the rest of the toolkit. Absent or explicit-`null` is fine
566        // (effective default `fact`); a present non-null value must be a scalar in
567        // the closed enum. This mirrors the sibling timestamp check below, which
568        // was already hardened against the same non-scalar escape.
569        if let Some(v) = fm.get("meta-type").filter(|v| !v.is_null()) {
570            match scalar_string(v) {
571                Some(mt) if matches!(mt.as_str(), "fact" | "operational" | "conclusion") => {}
572                Some(mt) => push(
573                    issues,
574                    Severity::Error,
575                    codes::FM_BAD_META_TYPE,
576                    rel,
577                    fm_key_line_or_top(fm_yaml, "meta-type"),
578                    Some("meta-type".into()),
579                    format!("`meta-type: {mt}` is not one of fact / operational / conclusion"),
580                    Some(
581                        "use one of: fact, operational, conclusion (or omit for the default `fact`)"
582                            .into(),
583                    ),
584                    vec![],
585                ),
586                None => push(
587                    issues,
588                    Severity::Error,
589                    codes::FM_BAD_META_TYPE,
590                    rel,
591                    fm_key_line_or_top(fm_yaml, "meta-type"),
592                    Some("meta-type".into()),
593                    "`meta-type` is not one of fact / operational / conclusion: expected a scalar \
594                     string, found a list or mapping"
595                        .to_string(),
596                    Some(
597                        "use one of: fact, operational, conclusion (or omit for the default `fact`)"
598                            .into(),
599                    ),
600                    vec![],
601                ),
602            }
603        }
604    }
605
606    // ── id (recommended stable identity; opaque token — v0.4) ────────────────
607    // Absent is fully valid (identity falls back to the path; SPEC § The `id`
608    // field). Present, it must be USABLE as an identifier: a non-empty scalar
609    // with no whitespace. The recommended FORM (lowercase ULID, what
610    // `dbmd write` mints) is deliberately not enforced — a hand-authored
611    // opaque id stays legal, which is what keeps v0.4 additive over v0.3
612    // stores — so this warns only on values that break identifier semantics.
613    // A non-scalar `id` matters doubly: `DUP_ID` reads ids via the scalar
614    // coercion, so a list/mapping value silently opts the file out of
615    // duplicate detection.
616    if is_content {
617        if let Some(v) = fm.get("id").filter(|v| !v.is_null()) {
618            let problem = match scalar_string(v) {
619                Some(id) if id.trim().is_empty() => Some("`id` is empty".to_string()),
620                Some(id) if id.chars().any(char::is_whitespace) => {
621                    Some(format!("`id` {id:?} contains whitespace"))
622                }
623                Some(_) => None,
624                None => Some(
625                    "`id` is not a scalar (found a list or mapping), so duplicate detection \
626                     (DUP_ID) cannot see it"
627                        .to_string(),
628                ),
629            };
630            if let Some(message) = problem {
631                push(
632                    issues,
633                    Severity::Warning,
634                    codes::FM_BAD_ID,
635                    rel,
636                    fm_key_line_or_top(fm_yaml, "id"),
637                    Some("id".into()),
638                    message,
639                    Some(
640                        "use one opaque token with no whitespace — the recommended form is a \
641                         lowercase ULID (`dbmd write` mints one) — or drop `id` to fall back to \
642                         filename identity"
643                            .into(),
644                    ),
645                    vec![],
646                );
647            }
648        }
649    }
650
651    // ── summary (universal on content files) ──────────────────────────────────
652    if is_content {
653        check_summary(rel, fm, fm_yaml, issues);
654    }
655
656    // ── timestamps: created / updated ─────────────────────────────────────────
657    // The `created`/`updated` contract is content-file-only; meta files
658    // (`DB.md`, `log.md`, index twins) legitimately carry no such timestamps.
659    if is_content {
660        for (key, missing_code) in [
661            ("created", codes::FM_MISSING_CREATED),
662            ("updated", codes::FM_MISSING_UPDATED),
663        ] {
664            // A key that is absent, or present-but-`null`, has *no* timestamp →
665            // `FM_MISSING_*`. The toolkit's parser also treats a null value as
666            // "no timestamp", so a null `created:` must read as missing, not
667            // silently pass.
668            let value = fm.get(key);
669            let missing = value.is_none() || value.is_some_and(Value::is_null);
670            if missing {
671                push(
672                    issues,
673                    Severity::Error,
674                    missing_code,
675                    rel,
676                    fm_key_line_or_top(fm_yaml, key),
677                    Some(key.into()),
678                    format!("content file has no `{key}:` timestamp"),
679                    Some(format!(
680                        "set `{key}` to an RFC3339 timestamp, e.g. 2026-05-27T08:00:00-07:00"
681                    )),
682                    vec![],
683                );
684            } else if let Some(v) = value {
685                // Present and non-null. A scalar is checked for ISO-8601; a
686                // sequence/mapping is not a timestamp string at all and so
687                // cannot be ISO-8601 → `FM_BAD_TIMESTAMP` (it must not slip
688                // through the way it did when `scalar_string` returned `None`
689                // and the branch silently no-oped).
690                match scalar_string(v) {
691                    Some(s) if is_iso8601(&s) => {}
692                    Some(s) => push(
693                        issues,
694                        Severity::Error,
695                        codes::FM_BAD_TIMESTAMP,
696                        rel,
697                        fm_key_line(fm_yaml, key),
698                        Some(key.into()),
699                        format!("`{key}` is not ISO-8601: {s:?}"),
700                        Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
701                        vec![],
702                    ),
703                    None => push(
704                        issues,
705                        Severity::Error,
706                        codes::FM_BAD_TIMESTAMP,
707                        rel,
708                        fm_key_line(fm_yaml, key),
709                        Some(key.into()),
710                        format!(
711                            "`{key}` is not ISO-8601: expected a timestamp string, found a list or mapping"
712                        ),
713                        Some("use RFC3339, e.g. 2026-05-27T08:00:00-07:00".into()),
714                        vec![],
715                    ),
716                }
717            }
718        }
719    }
720    // ── tags shape ────────────────────────────────────────────────────────────
721    if let Some(tags) = fm.get("tags") {
722        if !is_flat_scalar_list(tags) {
723            push(
724                issues,
725                Severity::Warning,
726                codes::TAGS_MALFORMED,
727                rel,
728                fm_key_line(fm_yaml, "tags"),
729                Some("tags".into()),
730                "`tags` must be a flat YAML list of short scalar labels".into(),
731                Some("use block form: one `- <tag>` per line".into()),
732                vec![],
733            );
734        }
735    }
736
737    // ── inline flow-form wiki-link lists in frontmatter ──────────────────────
738    for key in detect_flow_form_link_lists(fm_yaml) {
739        push(
740            issues,
741            Severity::Error,
742            codes::WIKI_LINK_FLOW_FORM_LIST,
743            rel,
744            fm_key_line(fm_yaml, &key),
745            Some(key.clone()),
746            format!("`{key}` uses inline flow form `[[[a]], [[b]]]`"),
747            Some("use YAML block-sequence form: one `- [[...]]` per line".into()),
748            vec![],
749        );
750    }
751
752    // ── frontmatter wiki-link fields: doctrine + integrity ───────────────────
753    // Skip keys that have an explicit `link to` schema spec — those are checked
754    // (with prefix enforcement) in `check_schema`, and double-reporting the same
755    // link via two paths would be noise.
756    let schema_link_keys: BTreeSet<String> =
757        effective_schema(store, type_.as_deref().unwrap_or(""))
758            .map(|s| {
759                s.fields
760                    .iter()
761                    .filter(|f| f.link_prefix.is_some())
762                    .map(|f| f.name.clone())
763                    .collect()
764            })
765            .unwrap_or_default();
766    for (key, link) in frontmatter_link_fields_text(fm_yaml, 2) {
767        if schema_link_keys.contains(&key) {
768            continue;
769        }
770        check_wiki_link(
771            store,
772            rel,
773            &link,
774            Some(link.line),
775            Some(&key),
776            basenames,
777            issues,
778        );
779    }
780
781    // ── policies: ignored types ──────────────────────────────────────────────
782    if let Some(t) = &type_ {
783        if store.config.ignored_types.iter().any(|it| it == t) {
784            push(
785                issues,
786                Severity::Info,
787                codes::POLICY_IGNORED_TYPE_PRESENT,
788                rel,
789                fm_key_line(fm_yaml, "type"),
790                Some("type".into()),
791                format!("file has ignored type `{t}` (per DB.md ## Policies)"),
792                Some(
793                    "change the `type`, or remove it from DB.md `### Ignored types` if it should be managed"
794                        .into(),
795                ),
796                // The policy source: `DB.md` declares the ignored type.
797                vec![PathBuf::from("DB.md")],
798            );
799        }
800        // A conclusion record (`meta-type: conclusion`) deriving from an
801        // ignored-type record → warning. The decision lives in the shared
802        // `derived_from_ignored_type` entry point; this side only supplies the
803        // `derived_from` targets (with their line, which the issue carries) and
804        // renders the finding.
805        let meta_type = fm
806            .get("meta-type")
807            .and_then(scalar_string)
808            .unwrap_or_else(|| "fact".to_string());
809        for link in frontmatter_links_for_key(fm_yaml, "derived_from", 2) {
810            if let Some(hit) =
811                derived_from_ignored_type(store, &meta_type, std::iter::once(link.target.as_str()))
812            {
813                push(
814                    issues,
815                    Severity::Warning,
816                    codes::POLICY_IGNORED_TYPE_DERIVED,
817                    rel,
818                    Some(link.line),
819                    Some("derived_from".into()),
820                    format!(
821                        "conclusion record derives from ignored-type record `{}` (type `{}`)",
822                        hit.target, hit.target_type
823                    ),
824                    Some(
825                        "drop this `derived_from` link, or remove the target type from DB.md `### Ignored types`"
826                            .into(),
827                    ),
828                    // The ignored-type source record, plus `DB.md` (the policy
829                    // source that lists the ignored type).
830                    vec![
831                        PathBuf::from(format!("{}.md", hit.target)),
832                        PathBuf::from("DB.md"),
833                    ],
834                );
835            }
836        }
837    }
838
839    // ── schema enforcement: DB.md ## Schemas (the only schema source) ─────────
840    if let Some(t) = &type_ {
841        if let Some(schema) = effective_schema(store, t) {
842            check_schema(store, rel, fm, fm_yaml, &schema, issues);
843        }
844    }
845}
846
847/// `summary` rules: required, non-empty, single-line, ≤ 200 chars.
848fn check_summary(rel: &Path, fm: &BTreeMap<String, Value>, fm_yaml: &str, issues: &mut Vec<Issue>) {
849    let line = fm_key_line(fm_yaml, "summary");
850    match fm.get("summary") {
851        None => push(
852            issues,
853            Severity::Error,
854            codes::SUMMARY_MISSING,
855            rel,
856            // A missing `summary` key has no line of its own → anchor to the
857            // frontmatter block top (line 1), the EXPECTED field-absence rule.
858            fm_key_line_or_top(fm_yaml, "summary"),
859            Some("summary".into()),
860            "content file has no `summary`".into(),
861            Some("run `dbmd fm init`".into()),
862            vec![],
863        ),
864        Some(v) => {
865            let s = scalar_string(v).unwrap_or_default();
866            if s.trim().is_empty() {
867                push(
868                    issues,
869                    Severity::Error,
870                    codes::SUMMARY_EMPTY,
871                    rel,
872                    line,
873                    Some("summary".into()),
874                    "`summary` is present but empty".into(),
875                    Some("write a one-line summary, or run `dbmd fm init`".into()),
876                    vec![],
877                );
878            } else if s.contains('\n') {
879                push(
880                    issues,
881                    Severity::Error,
882                    codes::SUMMARY_MULTILINE,
883                    rel,
884                    line,
885                    Some("summary".into()),
886                    "`summary` must be one line (contains a newline)".into(),
887                    Some("collapse the summary to a single line".into()),
888                    vec![],
889                );
890            } else if s.chars().count() > MAX_SUMMARY_LEN {
891                push(
892                    issues,
893                    Severity::Warning,
894                    codes::SUMMARY_TOO_LONG,
895                    rel,
896                    line,
897                    Some("summary".into()),
898                    format!(
899                        "`summary` is {} chars (> {MAX_SUMMARY_LEN})",
900                        s.chars().count()
901                    ),
902                    Some(format!("trim the summary to ≤ {MAX_SUMMARY_LEN} chars")),
903                    vec![],
904                );
905            }
906        }
907    }
908}
909
910/// Wiki-link checks for a body. Per-link doctrine (`WIKI_LINK_*`).
911fn check_body_wiki_links(
912    store: &Store,
913    rel: &Path,
914    body: &str,
915    fm_end_line: u32,
916    basenames: Option<&BasenameIndex>,
917    issues: &mut Vec<Issue>,
918) {
919    for link in extract_wiki_links(body) {
920        // Body lines are offset past the frontmatter block. `link.line` is
921        // 1-based within `body`; the body starts at `fm_end_line + 1`.
922        let abs_line = fm_end_line + link.line;
923        check_wiki_link(store, rel, &link, Some(abs_line), None, basenames, issues);
924    }
925}
926
927/// A store-wide map from a file's bare basename (its stem, no `.md`) to every
928/// store-relative path carrying that basename. Built once per `validate --all`
929/// sweep so the short-form wiki-link check can distinguish a merely short-form
930/// target (`WIKI_LINK_SHORT_FORM`) from one that is *ambiguous* because the bare
931/// basename matches two or more files (`WIKI_LINK_AMBIGUOUS`, the defensive
932/// code). `None` in the working-set path — that loop is O(changed) and never
933/// walks the store, so it reports the plain short-form error without the scan.
934type BasenameIndex = HashMap<String, Vec<PathBuf>>;
935
936/// Build the [`BasenameIndex`] from the swept file list (already gathered by
937/// `validate_all`; no extra walk).
938fn build_basename_index(files: &[PathBuf]) -> BasenameIndex {
939    let mut idx: BasenameIndex = HashMap::new();
940    for rel in files {
941        if let Some(stem) = rel.file_stem().and_then(|s| s.to_str()) {
942            idx.entry(stem.to_string()).or_default().push(rel.clone());
943        }
944    }
945    idx
946}
947
948/// The shared per-wiki-link doctrine + integrity check used by both body links
949/// and frontmatter link-fields. `basenames` is `Some` only in the `--all`
950/// sweep, where a no-slash short-form target is upgraded to `WIKI_LINK_AMBIGUOUS`
951/// when its bare basename matches ≥2 files.
952fn check_wiki_link(
953    store: &Store,
954    rel: &Path,
955    link: &Link,
956    line: Option<u32>,
957    key: Option<&str>,
958    basenames: Option<&BasenameIndex>,
959    issues: &mut Vec<Issue>,
960) {
961    let bare = link.target.trim_end_matches(".md");
962
963    // Short-form: not a full store-relative path (no `/`, or first segment isn't
964    // a known layer).
965    if !is_full_store_path(bare) {
966        // Ambiguous (defensive) takes precedence over plain short-form when the
967        // target is a bare basename (no `/`) that matches ≥2 files in the store.
968        // Only computable in the sweep (where `basenames` is populated); the
969        // working-set path falls through to the plain short-form error.
970        if !bare.contains('/') {
971            if let Some(idx) = basenames {
972                if let Some(matches) = idx.get(bare) {
973                    if matches.len() >= 2 {
974                        let mut related = matches.clone();
975                        related.sort();
976                        push(
977                            issues,
978                            Severity::Error,
979                            codes::WIKI_LINK_AMBIGUOUS,
980                            rel,
981                            line,
982                            key.map(str::to_string),
983                            format!(
984                                "short-form wiki-link `[[{}]]` matches multiple files",
985                                link.target
986                            ),
987                            Some("use the full store-relative path to disambiguate".into()),
988                            related,
989                        );
990                        return;
991                    }
992                }
993            }
994        }
995        push(
996            issues,
997            Severity::Error,
998            codes::WIKI_LINK_SHORT_FORM,
999            rel,
1000            line,
1001            key.map(str::to_string),
1002            format!(
1003                "wiki-link `[[{}]]` is not a full store-relative path",
1004                link.target
1005            ),
1006            short_form_suggestion(bare),
1007            vec![],
1008        );
1009        // Don't also report broken; the agent must fix the form first.
1010        return;
1011    }
1012
1013    // `.md` extension → warning, then still check existence.
1014    if link.target.ends_with(".md") {
1015        push(
1016            issues,
1017            Severity::Warning,
1018            codes::WIKI_LINK_HAS_EXTENSION,
1019            rel,
1020            line,
1021            key.map(str::to_string),
1022            format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1023            Some(format!("drop the extension: [[{bare}]]")),
1024            vec![],
1025        );
1026    }
1027
1028    // Broken: target file doesn't exist (O(1) stat). Resolve the target the
1029    // same way the graph engine does — the literal path first (so a link to a
1030    // raw `.eml`/`.pdf` source kept verbatim under `sources/` resolves), then
1031    // the `.md`-appended path.
1032    match resolve_wiki_target(store, bare) {
1033        TargetResolution::Exists => {}
1034        TargetResolution::Missing => push(
1035            issues,
1036            Severity::Error,
1037            codes::WIKI_LINK_BROKEN,
1038            rel,
1039            line,
1040            key.map(str::to_string),
1041            format!("wiki-link target `{bare}` doesn't exist"),
1042            Some(format!(
1043                "create `{bare}.md`, or point the link at an existing file"
1044            )),
1045            vec![],
1046        ),
1047        TargetResolution::Unsafe => push(
1048            issues,
1049            Severity::Error,
1050            codes::WIKI_LINK_BROKEN,
1051            rel,
1052            line,
1053            key.map(str::to_string),
1054            format!("wiki-link target `{bare}` is not a safe store-relative path"),
1055            Some("use a full store-relative path under sources/ or records/".into()),
1056            vec![],
1057        ),
1058    }
1059}
1060
1061// ─────────────────────────────────────────────────────────────────────────────
1062//  Schema enforcement (user-declared DB.md ## Schemas — the only source)
1063// ─────────────────────────────────────────────────────────────────────────────
1064
1065/// The effective schema for a type: the store's explicit `DB.md ## Schemas`
1066/// block, or `None`. This is the **only** source of schema enforcement — the
1067/// toolkit ships no implicit or built-in per-type schema (SPEC § Schemas). A
1068/// store that wants its `contact` / `expense` / etc. fields enforced declares
1069/// them in `## Schemas`; the example schema pack in SPEC § Example types is a
1070/// copy-in starting point.
1071fn effective_schema(store: &Store, type_: &str) -> Option<Schema> {
1072    store.config.schemas.get(type_).cloned()
1073}
1074
1075/// Validate a file's frontmatter against a schema's [`FieldSpec`]s.
1076fn check_schema(
1077    store: &Store,
1078    rel: &Path,
1079    fm: &BTreeMap<String, Value>,
1080    fm_yaml: &str,
1081    schema: &Schema,
1082    issues: &mut Vec<Issue>,
1083) {
1084    for spec in &schema.fields {
1085        let present = fm.get(&spec.name);
1086        let line = fm_key_line(fm_yaml, &spec.name);
1087
1088        // Required. "Empty" means: the key is absent, or its value carries no
1089        // content — a YAML `null` (`name:`), an empty list (`name: []`), an
1090        // empty mapping (`name: {}`), or a blank/whitespace-only scalar
1091        // (`name: ""`). `scalar_string` returns `None` for null/list/mapping, so
1092        // a bare `.unwrap_or(false)` wrongly treated those as non-empty and let
1093        // a required field with a null or empty-collection value pass silently;
1094        // route them through `is_empty_value` instead.
1095        let is_empty = match present {
1096            None => true,
1097            Some(v) => is_empty_value(v),
1098        };
1099        if spec.required && is_empty {
1100            push(
1101                issues,
1102                Severity::Error,
1103                codes::SCHEMA_MISSING_REQUIRED,
1104                rel,
1105                // Absent key → anchor to the frontmatter top (line 1); a
1106                // present-but-empty value keeps its own line.
1107                fm_key_line_or_top(fm_yaml, &spec.name),
1108                Some(spec.name.clone()),
1109                format!("required field `{}` is absent or empty", spec.name),
1110                Some(format!("set `{}` to a non-empty value", spec.name)),
1111                vec![],
1112            );
1113            continue;
1114        }
1115        let Some(value) = present else { continue };
1116
1117        // An OPTIONAL field that is `null` or empty is simply unset — there is
1118        // no value to shape/enum/link-check. (The required+empty case already
1119        // returned above as `SCHEMA_MISSING_REQUIRED`.) Without this, an
1120        // `paid_at: null` on an `invoice` whose schema marks `paid_at (date)`
1121        // would wrongly fire `SCHEMA_SHAPE_MISMATCH` against the empty string.
1122        let value_empty = value.is_null()
1123            || scalar_string(value)
1124                .map(|s| s.trim().is_empty())
1125                .unwrap_or(false);
1126        if !spec.required && value_empty {
1127            continue;
1128        }
1129
1130        // link to <prefix>/ — extract the link target(s) from the raw frontmatter
1131        // text (unquoted `[[...]]` is a YAML nested-sequence, not a string).
1132        if let Some(prefix) = &spec.link_prefix {
1133            check_schema_link(store, rel, &spec.name, fm_yaml, prefix, line, issues);
1134            continue; // a link field is never also shape/enum-checked
1135        }
1136
1137        // A shape- or enum-constrained field expects a SCALAR. A YAML sequence
1138        // or mapping satisfies neither, and would otherwise slip through both
1139        // checks (`scalar_string` returns `None` for non-scalars, so the enum
1140        // and shape bodies silently no-op). Flag it as a shape mismatch rather
1141        // than let a structurally-wrong value validate clean. (Link fields,
1142        // which legitimately take block-form sequences, already `continue`d.)
1143        if (spec.shape.is_some() || spec.enum_values.is_some()) && scalar_string(value).is_none() {
1144            push(
1145                issues,
1146                Severity::Error,
1147                codes::SCHEMA_SHAPE_MISMATCH,
1148                rel,
1149                line,
1150                Some(spec.name.clone()),
1151                format!(
1152                    "`{}` must be a scalar value, found a list or mapping",
1153                    spec.name
1154                ),
1155                Some(format!("set `{}` to a single scalar value", spec.name)),
1156                vec![],
1157            );
1158            continue;
1159        }
1160
1161        // enum
1162        if let Some(allowed) = &spec.enum_values {
1163            if let Some(s) = scalar_string(value) {
1164                if !allowed.iter().any(|a| a == &s) {
1165                    push(
1166                        issues,
1167                        Severity::Error,
1168                        codes::SCHEMA_ENUM_VIOLATION,
1169                        rel,
1170                        line,
1171                        Some(spec.name.clone()),
1172                        format!("`{}` value {s:?} not in enum {allowed:?}", spec.name),
1173                        Some(format!("use one of: {}", allowed.join(", "))),
1174                        vec![],
1175                    );
1176                }
1177            }
1178            continue;
1179        }
1180
1181        // shape
1182        if let Some(shape) = spec.shape {
1183            check_schema_shape(rel, &spec.name, value, shape, line, issues);
1184        }
1185    }
1186}
1187
1188/// `link to <prefix>/` enforcement: the value must be a wiki-link whose target
1189/// starts with `<prefix>`. Reads the link target(s) from the raw frontmatter
1190/// text so unquoted `field: [[...]]` (a YAML nested-sequence, not a string) is
1191/// recognized exactly like the quoted form.
1192fn check_schema_link(
1193    store: &Store,
1194    rel: &Path,
1195    field: &str,
1196    fm_yaml: &str,
1197    prefix: &Path,
1198    line: Option<u32>,
1199    issues: &mut Vec<Issue>,
1200) {
1201    let prefix_str = prefix.to_string_lossy();
1202    let prefix_str = prefix_str.trim_end_matches('/');
1203    let suggestion = |target_leaf: &str| {
1204        Some(format!(
1205            "expected `link to {prefix_str}/`; replace with [[{prefix_str}/{target_leaf}]]"
1206        ))
1207    };
1208
1209    let links = frontmatter_links_for_key(fm_yaml, field, 2);
1210    if links.is_empty() {
1211        // No wiki-link in the field's value → it's a plain string.
1212        let raw = frontmatter_raw_value_for_key(fm_yaml, field, 2).unwrap_or_default();
1213        let raw = raw.trim().trim_matches('"').trim_matches('\'').trim();
1214        let leaf = slugish(raw);
1215        push(
1216            issues,
1217            Severity::Error,
1218            codes::SCHEMA_LINK_PREFIX_MISMATCH,
1219            rel,
1220            line,
1221            Some(field.to_string()),
1222            format!(
1223                "`{field}` is a plain string {raw:?}, expected a wiki-link under `{prefix_str}/`"
1224            ),
1225            suggestion(&leaf),
1226            vec![],
1227        );
1228        return;
1229    }
1230
1231    for link in links {
1232        if link.target.ends_with(".md") {
1233            let bare = link.target.trim_end_matches(".md");
1234            push(
1235                issues,
1236                Severity::Warning,
1237                codes::WIKI_LINK_HAS_EXTENSION,
1238                rel,
1239                Some(link.line),
1240                Some(field.to_string()),
1241                format!("wiki-link `[[{}]]` carries a `.md` extension", link.target),
1242                Some(format!("drop the extension: [[{bare}]]")),
1243                vec![],
1244            );
1245        }
1246        let bare = link.target.trim_end_matches(".md");
1247        if !path_under_prefix(bare, prefix_str) {
1248            let leaf = bare.rsplit('/').next().unwrap_or(bare);
1249            push(
1250                issues,
1251                Severity::Error,
1252                codes::SCHEMA_LINK_PREFIX_MISMATCH,
1253                rel,
1254                line,
1255                Some(field.to_string()),
1256                format!("`{field}` target `{bare}` is not under `{prefix_str}/`"),
1257                suggestion(leaf),
1258                vec![],
1259            );
1260        } else {
1261            // Correct prefix — still surface a broken target so the agent sees
1262            // one consistent vocabulary. Resolve like the graph engine (literal
1263            // path first, then `.md`) so a `link to sources/` field pointing at a
1264            // raw `.eml`/`.pdf` source isn't wrongly flagged broken.
1265            match resolve_wiki_target(store, bare) {
1266                TargetResolution::Exists => {}
1267                TargetResolution::Missing => push(
1268                    issues,
1269                    Severity::Error,
1270                    codes::WIKI_LINK_BROKEN,
1271                    rel,
1272                    line,
1273                    Some(field.to_string()),
1274                    format!("wiki-link target `{bare}` doesn't exist"),
1275                    Some(format!(
1276                        "create `{bare}.md`, or point the link at an existing file"
1277                    )),
1278                    vec![],
1279                ),
1280                TargetResolution::Unsafe => push(
1281                    issues,
1282                    Severity::Error,
1283                    codes::WIKI_LINK_BROKEN,
1284                    rel,
1285                    line,
1286                    Some(field.to_string()),
1287                    format!("wiki-link target `{bare}` is not a safe store-relative path"),
1288                    Some("use a full store-relative path under sources/ or records/".into()),
1289                    vec![],
1290                ),
1291            }
1292        }
1293    }
1294}
1295
1296/// Shape enforcement for a non-link, non-enum schema field.
1297fn check_schema_shape(
1298    rel: &Path,
1299    field: &str,
1300    value: &Value,
1301    shape: Shape,
1302    line: Option<u32>,
1303    issues: &mut Vec<Issue>,
1304) {
1305    let s = scalar_string(value).unwrap_or_default();
1306    let ok = match shape {
1307        Shape::String => true, // any scalar string
1308        Shape::Int => value.is_i64() || value.is_u64() || s.trim().parse::<i64>().is_ok(),
1309        Shape::Bool => value.is_bool() || matches!(s.trim(), "true" | "false"),
1310        Shape::Date => is_iso8601_date_or_datetime(&s),
1311        Shape::Email => is_email(&s),
1312        Shape::Currency => is_currency(&s),
1313        Shape::Url => is_url(&s),
1314    };
1315    if !ok {
1316        push(
1317            issues,
1318            Severity::Error,
1319            codes::SCHEMA_SHAPE_MISMATCH,
1320            rel,
1321            line,
1322            Some(field.to_string()),
1323            format!("`{field}` value {s:?} doesn't match shape {shape:?}"),
1324            Some(shape_suggestion(shape)),
1325            vec![],
1326        );
1327    }
1328}
1329
1330// ─────────────────────────────────────────────────────────────────────────────
1331//  Cross-file: entity-dedup collisions (validate_all only)
1332// ─────────────────────────────────────────────────────────────────────────────
1333
1334/// Hard `DUP_ID` + the soft, schema-declared `DUP_UNIQUE_KEY` collisions.
1335///
1336/// `DUP_ID` is universal (two files with the same explicit `id`).
1337/// `DUP_UNIQUE_KEY` is driven entirely by the store's `DB.md ## Schemas`: each
1338/// `- unique: <field>[, <field> …]` directive on a `### <type>` declares a
1339/// uniqueness constraint, and two records of that type whose declared values
1340/// collide warn. No type carries a built-in dedup key — the store opts in.
1341///
1342/// **Reporting precedence (rule #1 in `corpus-b-edges/EXPECTED/README.md`):** a
1343/// collision group of N files yields exactly ONE issue, not N. Its `file` is the
1344/// lexicographically smallest store-relative path in the group (a total order →
1345/// deterministic); `related` is the rest, sorted. A single-field key anchors to
1346/// that field's line on the reported file and carries it as `key`; a multi-field
1347/// key anchors to line 1 with a null key.
1348fn check_duplicates(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
1349    // Path → frontmatter YAML, for resolving the anchor field's line on the
1350    // reported (smallest-path) member.
1351    let fm_yaml_of: HashMap<&PathBuf, &str> = parsed
1352        .iter()
1353        .map(|(rel, p)| (rel, p.fm_yaml.as_str()))
1354        .collect();
1355
1356    // ── DUP_ID (hard error): two files with the same explicit `id`. ──────────
1357    let mut by_id: HashMap<String, Vec<PathBuf>> = HashMap::new();
1358    for (rel, p) in parsed {
1359        if let Some(map) = &p.fm {
1360            if let Some(id) = map.get("id").and_then(scalar_string) {
1361                if !id.trim().is_empty() {
1362                    by_id.entry(id).or_default().push(rel.clone());
1363                }
1364            }
1365        }
1366    }
1367    for (id, files) in &by_id {
1368        if files.len() > 1 {
1369            let (reported, related) = canonical_and_related(files);
1370            let line = fm_yaml_of.get(&reported).and_then(|y| fm_key_line(y, "id"));
1371            push(
1372                issues,
1373                Severity::Error,
1374                codes::DUP_ID,
1375                &reported,
1376                line,
1377                Some("id".into()),
1378                format!("id {id:?} is declared by more than one file"),
1379                Some("give each file a unique `id` (or drop it to derive from the path)".into()),
1380                related,
1381            );
1382        }
1383    }
1384
1385    // ── DUP_UNIQUE_KEY (warning): schema-declared `unique:` collisions. ───────
1386    // Every constraint comes from the store's `## Schemas`; a type with no
1387    // `unique:` directive is never dedup-checked. Iteration over the BTreeMap is
1388    // key-ordered, so emitted issues are deterministic across runs.
1389    for (type_name, schema) in &store.config.schemas {
1390        for key_fields in &schema.unique_keys {
1391            soft_dup(parsed, issues, type_name, key_fields, &fm_yaml_of);
1392        }
1393    }
1394}
1395
1396/// Emit ONE `DUP_UNIQUE_KEY` warning per group of ≥2 files of `type_` whose
1397/// declared `key_fields` render to the same token tuple. Files missing any key
1398/// field are skipped — an incomplete key is never a collision.
1399///
1400/// Per reporting rule #1 the issue is keyed on the lexicographically smallest
1401/// store-relative path; `related` is the rest. A single-field key anchors to
1402/// that field's line on the reported file and carries it as `key`; a multi-field
1403/// key anchors to line 1 with a null key. `fm_yaml_of` resolves the field line.
1404fn soft_dup(
1405    parsed: &[(PathBuf, Parsed)],
1406    issues: &mut Vec<Issue>,
1407    type_: &str,
1408    key_fields: &[String],
1409    fm_yaml_of: &HashMap<&PathBuf, &str>,
1410) {
1411    if key_fields.is_empty() {
1412        return;
1413    }
1414    let mut groups: HashMap<Vec<String>, Vec<PathBuf>> = HashMap::new();
1415    for (rel, p) in parsed {
1416        let is_type =
1417            p.fm.as_ref()
1418                .and_then(|m| m.get("type"))
1419                .and_then(scalar_string)
1420                .map(|t| t == type_)
1421                .unwrap_or(false);
1422        if !is_type {
1423            continue;
1424        }
1425        if let Some(key) = dedup_key(p, key_fields) {
1426            groups.entry(key).or_default().push(rel.clone());
1427        }
1428    }
1429    // HashMap iteration is nondeterministic; sort by reported member so the
1430    // emitted issue order is stable across runs.
1431    let mut collisions: Vec<(PathBuf, Vec<PathBuf>)> = groups
1432        .values()
1433        .filter(|files| files.len() > 1)
1434        .map(|files| canonical_and_related(files))
1435        .collect();
1436    collisions.sort_by(|a, b| a.0.cmp(&b.0));
1437
1438    let fields_disp = key_fields.join(", ");
1439    for (reported, related) in collisions {
1440        // Single-field keys anchor to the field's line + carry the key; multi-
1441        // field keys anchor to line 1 with a null key.
1442        let (line, key) = if key_fields.len() == 1 {
1443            (
1444                fm_yaml_of
1445                    .get(&reported)
1446                    .and_then(|y| fm_key_line(y, &key_fields[0])),
1447                Some(key_fields[0].clone()),
1448            )
1449        } else {
1450            (Some(1), None)
1451        };
1452        let n = related.len();
1453        push(
1454            issues,
1455            Severity::Warning,
1456            codes::DUP_UNIQUE_KEY,
1457            &reported,
1458            line,
1459            key,
1460            format!("`{type_}` unique key ({fields_disp}) collides with {n} other record(s)"),
1461            Some("merge with `dbmd rename`, or cross-link with `dbmd link`".into()),
1462            related,
1463        );
1464    }
1465}
1466
1467/// Render a type's `unique:` key for one file: each field's dedup token in
1468/// order, or `None` if any field is absent/empty (an incomplete key never
1469/// collides).
1470fn dedup_key(p: &Parsed, key_fields: &[String]) -> Option<Vec<String>> {
1471    let mut out = Vec::with_capacity(key_fields.len());
1472    for f in key_fields {
1473        out.push(dedup_token(p, f)?);
1474    }
1475    Some(out)
1476}
1477
1478/// One field's normalized dedup token, or `None` when absent/empty. Wiki-link
1479/// values (single or block-sequence list) reduce to their lower-cased target
1480/// path(s); a list collapses to a sorted, de-duplicated set so item order never
1481/// matters. Plain scalars (and YAML scalar lists) lower-case and trim.
1482fn dedup_token(p: &Parsed, field: &str) -> Option<String> {
1483    // Wiki-links first — read from the raw frontmatter text so the unquoted
1484    // `field: [[...]]` (a YAML nested-sequence, not a string) is handled.
1485    let links = frontmatter_links_for_key(&p.fm_yaml, field, 2);
1486    if !links.is_empty() {
1487        let set: BTreeSet<String> = links
1488            .into_iter()
1489            .map(|l| l.target.trim_end_matches(".md").to_lowercase())
1490            .filter(|t| !t.is_empty())
1491            .collect();
1492        return if set.is_empty() {
1493            None
1494        } else {
1495            Some(set.into_iter().collect::<Vec<_>>().join(","))
1496        };
1497    }
1498    match p.fm.as_ref()?.get(field) {
1499        Some(Value::Sequence(items)) => {
1500            let set: BTreeSet<String> = items
1501                .iter()
1502                .filter_map(scalar_string)
1503                .map(|s| s.trim().to_lowercase())
1504                .filter(|t| !t.is_empty())
1505                .collect();
1506            if set.is_empty() {
1507                None
1508            } else {
1509                Some(set.into_iter().collect::<Vec<_>>().join(","))
1510            }
1511        }
1512        Some(v) => {
1513            let s = scalar_string(v)?.trim().to_lowercase();
1514            if s.is_empty() {
1515                None
1516            } else {
1517                Some(s)
1518            }
1519        }
1520        None => None,
1521    }
1522}
1523
1524/// Split a non-empty collision group into `(reported, related)`: the
1525/// lexicographically smallest store-relative path is the reported member; the
1526/// rest, sorted ascending, are `related`. Deterministic because store-relative
1527/// path is a total order — the property reporting rule #1 relies on.
1528fn canonical_and_related(files: &[PathBuf]) -> (PathBuf, Vec<PathBuf>) {
1529    let mut sorted = files.to_vec();
1530    sorted.sort();
1531    let reported = sorted[0].clone();
1532    let related = sorted[1..].to_vec();
1533    (reported, related)
1534}
1535
1536// ─────────────────────────────────────────────────────────────────────────────
1537//  Cross-file: hierarchical index.md + index.jsonl sync (validate_all only)
1538// ─────────────────────────────────────────────────────────────────────────────
1539
1540/// All `INDEX_*` and `INDEX_JSONL_*` checks across the three canonical levels.
1541fn check_indexes(store: &Store, files: &[PathBuf], issues: &mut Vec<Issue>) {
1542    // Group content files by their immediate parent folder (the type-folder,
1543    // *across date shards* — a sharded file's "type folder" is the folder right
1544    // under the layer). We key on the type-folder so shards roll up correctly.
1545    let mut type_folders: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1546    for rel in files {
1547        if let Some(tf) = type_folder_of(rel) {
1548            type_folders.entry(tf).or_default().push(rel.clone());
1549        }
1550    }
1551
1552    // Layers that actually contain a type-folder. The index WRITER creates a
1553    // layer/root `index.md` ONLY when a type-folder exists to roll up:
1554    // `Index::build_root`/`build_layer` populate `child_counts` from type-folders
1555    // alone, and `rebuild_all`/`write_level` remove the `index.md` when that map
1556    // is empty. A layer with ONLY loose files therefore has NO `index.md` — its
1557    // loose records live in the layer's own `index.jsonl` (checked in the loose
1558    // block below). Gating the `index.md` requirement on type-folder presence
1559    // (not on "any content file") keeps `validate --all` in parity with
1560    // `dbmd index rebuild`: requiring an `index.md` for a loose-only layer would
1561    // demand an artifact the canonical rebuild never creates, permanently
1562    // wedging the sweep on a correct store.
1563    let mut layers_with_type_folders: BTreeSet<&'static str> = BTreeSet::new();
1564    for tf in type_folders.keys() {
1565        match tf.iter().next().and_then(|s| s.to_str()) {
1566            Some("sources") => {
1567                layers_with_type_folders.insert("sources");
1568            }
1569            Some("records") => {
1570                layers_with_type_folders.insert("records");
1571            }
1572            _ => {}
1573        }
1574    }
1575
1576    // ── Root index.md ──── (only when a type-folder exists to roll up) ──────────
1577    if !type_folders.is_empty() {
1578        let root_index = store.root.join("index.md");
1579        if !root_index.is_file() {
1580            push(
1581                issues,
1582                Severity::Error,
1583                codes::INDEX_MISSING,
1584                Path::new("index.md"),
1585                None,
1586                None,
1587                "store has files but no root `index.md`".into(),
1588                Some("run `dbmd index rebuild`".into()),
1589                vec![],
1590            );
1591        } else {
1592            check_index_scope(store, Path::new("index.md"), "root", None, issues);
1593        }
1594    }
1595
1596    // ── Layer index.md ──── (only layers that contain a type-folder) ───────────
1597    for layer in &layers_with_type_folders {
1598        let layer_index_rel = PathBuf::from(layer).join("index.md");
1599        let abs = store.root.join(&layer_index_rel);
1600        if !abs.is_file() {
1601            push(
1602                issues,
1603                Severity::Error,
1604                codes::INDEX_MISSING,
1605                &layer_index_rel,
1606                None,
1607                None,
1608                format!("layer `{layer}/` has files but no `index.md`"),
1609                Some("run `dbmd index rebuild`".into()),
1610                vec![],
1611            );
1612        } else {
1613            check_index_scope(store, &layer_index_rel, "layer", Some(layer), issues);
1614        }
1615    }
1616
1617    // ── Type-folder index.md + index.jsonl ───────────────────────────────────
1618    for (tf, members) in &type_folders {
1619        let index_md_rel = tf.join("index.md");
1620        let index_md_abs = store.root.join(&index_md_rel);
1621        let index_md_present = index_md_abs.is_file();
1622        if !index_md_present {
1623            // The whole folder index is absent → a single `INDEX_MISSING` keyed
1624            // on the FOLDER (not the would-be `index.md` path). When the index is
1625            // entirely missing we do NOT additionally evaluate per-entry
1626            // completeness or the `index.jsonl` twin: one `INDEX_MISSING` covers
1627            // the folder (precedence rule #4 in `corpus-b-edges/EXPECTED`).
1628            push(
1629                issues,
1630                Severity::Error,
1631                codes::INDEX_MISSING,
1632                tf,
1633                None,
1634                None,
1635                format!("non-empty folder `{}` has no index.md", tf.display()),
1636                Some(format!(
1637                    "run `dbmd index rebuild --folder {}`",
1638                    tf.display()
1639                )),
1640                vec![],
1641            );
1642            continue;
1643        }
1644
1645        check_index_scope(store, &index_md_rel, "type-folder", tf.to_str(), issues);
1646        check_type_folder_index_md(store, tf, &index_md_rel, members, issues);
1647
1648        // index.jsonl twin — must exist and be complete (uncapped). Only checked
1649        // when the `index.md` is present (above): a folder whose entire index is
1650        // missing is one `INDEX_MISSING`, not also an `INDEX_JSONL_MISSING`.
1651        let jsonl_rel = tf.join("index.jsonl");
1652        let jsonl_abs = store.root.join(&jsonl_rel);
1653        if !jsonl_abs.is_file() {
1654            push(
1655                issues,
1656                Severity::Error,
1657                codes::INDEX_JSONL_MISSING,
1658                &jsonl_rel,
1659                None,
1660                None,
1661                format!("type-folder `{}/` has no `index.jsonl` twin", tf.display()),
1662                Some("run `dbmd index rebuild`".into()),
1663                vec![],
1664            );
1665        } else {
1666            check_type_folder_index_jsonl(store, tf, &jsonl_rel, members, issues);
1667        }
1668    }
1669
1670    // ── Loose files: content directly at a layer root (no type-folder). ──────
1671    // They are catalogued in the layer's own `index.jsonl` (the layer `index.md`
1672    // stays a type-folder rollup), so structured reads — `query`, dedup, `graph`
1673    // — see them the same way they see canonical files. Require that sidecar and
1674    // sync-check it, so a loose file is never silently absent from the catalog.
1675    // Only genuinely-loose files land here: `type_folder_of` already grouped
1676    // every file two-or-more levels under a layer into its type-folder above.
1677    let mut loose_by_layer: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
1678    for rel in files {
1679        if !is_content_file(rel) || type_folder_of(rel).is_some() {
1680            continue;
1681        }
1682        if let Some(layer_dir) = loose_layer_dir(rel) {
1683            loose_by_layer
1684                .entry(layer_dir)
1685                .or_default()
1686                .push(rel.clone());
1687        }
1688    }
1689    for (layer_dir, members) in &loose_by_layer {
1690        let jsonl_rel = layer_dir.join("index.jsonl");
1691        if !store.root.join(&jsonl_rel).is_file() {
1692            push(
1693                issues,
1694                Severity::Error,
1695                codes::INDEX_JSONL_MISSING,
1696                &jsonl_rel,
1697                None,
1698                None,
1699                format!(
1700                    "loose files at `{}/` are not catalogued — the layer has no `index.jsonl`",
1701                    layer_dir.display()
1702                ),
1703                Some("run `dbmd index rebuild`".into()),
1704                members.clone(),
1705            );
1706        } else {
1707            // `check_type_folder_index_jsonl` ignores its `tf` arg (`let _ = tf`)
1708            // and only checks jsonl-vs-files-vs-frontmatter — exactly the layer
1709            // sidecar's contract, so it is reused verbatim.
1710            check_type_folder_index_jsonl(store, layer_dir, &jsonl_rel, members, issues);
1711        }
1712    }
1713
1714    // ── Orphan index.md: an index file in a folder with no content. ──────────
1715    for rel in walk_index_files(&store.root) {
1716        let parent = rel.parent().unwrap_or(Path::new("")).to_path_buf();
1717        let parent_str = parent.to_string_lossy().to_string();
1718        let is_canonical = parent_str.is_empty() // root
1719            || matches!(parent_str.as_str(), "sources" | "records")
1720            || type_folders.contains_key(&parent);
1721        if !is_canonical {
1722            push(
1723                issues,
1724                Severity::Warning,
1725                codes::INDEX_ORPHAN,
1726                &rel,
1727                None,
1728                None,
1729                format!(
1730                    "`{}` sits in an empty or non-canonical folder",
1731                    rel.display()
1732                ),
1733                Some("remove it, or run `dbmd index rebuild`".into()),
1734                vec![],
1735            );
1736        }
1737    }
1738}
1739
1740/// Check a type-folder `index.md`'s entries against the folder's actual files:
1741/// stale entries (target gone), missing entries (file not listed), and
1742/// summary mismatches.
1743fn check_type_folder_index_md(
1744    store: &Store,
1745    tf: &Path,
1746    index_rel: &Path,
1747    members: &[PathBuf],
1748    issues: &mut Vec<Issue>,
1749) {
1750    let abs = store.root.join(index_rel);
1751    let Ok(text) = std::fs::read_to_string(&abs) else {
1752        return;
1753    };
1754    let entries = parse_index_entries(&text);
1755
1756    let listed: BTreeSet<PathBuf> = entries
1757        .iter()
1758        .map(|e| PathBuf::from(e.target.trim_end_matches(".md")))
1759        .collect();
1760
1761    // Stale entries + summary mismatch.
1762    for entry in &entries {
1763        let bare = entry.target.trim_end_matches(".md");
1764        // Resolve like the graph engine (literal path first, then `.md`) so an
1765        // index entry naming a raw `.eml`/`.pdf` source isn't reported stale.
1766        let target_abs = match resolved_target_abs(store, bare) {
1767            Some(abs) => abs,
1768            None => {
1769                if matches!(resolve_wiki_target(store, bare), TargetResolution::Unsafe) {
1770                    push(
1771                        issues,
1772                        Severity::Error,
1773                        codes::INDEX_STALE_ENTRY,
1774                        index_rel,
1775                        Some(entry.line),
1776                        None,
1777                        format!("index entry `[[{bare}]]` is not a safe store-relative path"),
1778                        Some("run `dbmd index rebuild`".into()),
1779                        vec![],
1780                    );
1781                } else {
1782                    push(
1783                        issues,
1784                        Severity::Error,
1785                        codes::INDEX_STALE_ENTRY,
1786                        index_rel,
1787                        Some(entry.line),
1788                        None,
1789                        format!("index entry `[[{bare}]]` points at a missing file"),
1790                        Some("run `dbmd index rebuild`".into()),
1791                        // The stale target the entry names (the file that no
1792                        // longer exists) — so the agent can locate the dangling
1793                        // reference.
1794                        vec![PathBuf::from(format!("{bare}.md"))],
1795                    );
1796                }
1797                continue;
1798            }
1799        };
1800        // Summary mismatch: the entry text must equal the file's `summary`. A
1801        // bare `- [[path]]` entry (no `— <text>`) when the file HAS a non-empty
1802        // summary is also a mismatch — the SPEC requires every type-folder index
1803        // entry to quote the file's `summary` (`- [[path]] — <summary>`), so a
1804        // missing quote can't validate clean just because there's nothing to
1805        // compare.
1806        if let Some(expected) = read_summary(&target_abs) {
1807            match &entry.summary_text {
1808                // Compare with the SAME whitespace normalization the renderer
1809                // applies when it writes the `index.md` browse line
1810                // (`format_md_entry` -> `collapse_whitespace`). `text_part` is the
1811                // already-collapsed text parsed back out of `index.md`; `expected`
1812                // is the RAW file summary. Comparing a collapsed value against a
1813                // raw one falsely flagged any valid one-line summary that carries
1814                // internal whitespace (a double space, a tab) — a permanent,
1815                // rebuild-immune INDEX_SUMMARY_MISMATCH that wedged the store, since
1816                // `index rebuild` regenerates the byte-identical collapsed line.
1817                // Normalizing both sides makes the check compare like with like.
1818                Some(text_part)
1819                    if crate::summary::collapse_whitespace(text_part)
1820                        != crate::summary::collapse_whitespace(&expected) =>
1821                {
1822                    push(
1823                        issues,
1824                        Severity::Error,
1825                        codes::INDEX_SUMMARY_MISMATCH,
1826                        index_rel,
1827                        Some(entry.line),
1828                        None,
1829                        format!("index entry for `{bare}` text doesn't match the file's `summary`"),
1830                        Some("run `dbmd index rebuild`".into()),
1831                        vec![PathBuf::from(format!("{bare}.md"))],
1832                    );
1833                }
1834                None if !expected.trim().is_empty() => {
1835                    push(
1836                        issues,
1837                        Severity::Error,
1838                        codes::INDEX_SUMMARY_MISMATCH,
1839                        index_rel,
1840                        Some(entry.line),
1841                        None,
1842                        format!("index entry for `{bare}` is missing its summary text (the file has a `summary`)"),
1843                        Some("run `dbmd index rebuild`".into()),
1844                        vec![PathBuf::from(format!("{bare}.md"))],
1845                    );
1846                }
1847                _ => {}
1848            }
1849        }
1850    }
1851
1852    // Missing entries: a member file not listed. Skip the index/log meta files.
1853    // The browse view caps at 500; only flag a missing entry when the folder is
1854    // under the cap (a capped folder legitimately omits older files).
1855    let content_members: Vec<&PathBuf> = members.iter().filter(|m| is_content_file(m)).collect();
1856    if content_members.len() <= 500 {
1857        for m in content_members {
1858            let bare = PathBuf::from(m.to_string_lossy().trim_end_matches(".md").to_string());
1859            if !listed.contains(&bare) {
1860                push(
1861                    issues,
1862                    Severity::Error,
1863                    codes::INDEX_MISSING_ENTRY,
1864                    index_rel,
1865                    None,
1866                    None,
1867                    format!(
1868                        "file `{}` is not listed in its folder's `index.md`",
1869                        m.display()
1870                    ),
1871                    Some("run `dbmd index rebuild`".into()),
1872                    vec![(*m).clone()],
1873                );
1874            }
1875        }
1876    }
1877    let _ = tf;
1878}
1879
1880/// Check a type-folder `index.jsonl` twin: it must list **every** file in the
1881/// folder (uncapped), every record must point at a real file, and each record's
1882/// fields must match the file's frontmatter.
1883fn check_type_folder_index_jsonl(
1884    store: &Store,
1885    tf: &Path,
1886    jsonl_rel: &Path,
1887    members: &[PathBuf],
1888    issues: &mut Vec<Issue>,
1889) {
1890    let abs = store.root.join(jsonl_rel);
1891    let Ok(text) = std::fs::read_to_string(&abs) else {
1892        return;
1893    };
1894
1895    // Parse records (last-write-wins by path), tolerating tombstones/blank lines.
1896    let mut records: BTreeMap<PathBuf, serde_json::Value> = BTreeMap::new();
1897    for (i, line) in text.lines().enumerate() {
1898        let line = line.trim();
1899        if line.is_empty() {
1900            continue;
1901        }
1902        let rec: serde_json::Value = match serde_json::from_str(line) {
1903            Ok(v) => v,
1904            Err(e) => {
1905                push(
1906                    issues,
1907                    Severity::Error,
1908                    codes::INDEX_JSONL_DESYNC,
1909                    jsonl_rel,
1910                    Some((i + 1) as u32),
1911                    None,
1912                    format!("`index.jsonl` line {} is not valid JSON: {e}", i + 1),
1913                    Some("run `dbmd index rebuild`".into()),
1914                    vec![],
1915                );
1916                continue;
1917            }
1918        };
1919        if let Some(path) = rec.get("path").and_then(|v| v.as_str()) {
1920            if !is_safe_store_relative_path(Path::new(path)) {
1921                push(
1922                    issues,
1923                    Severity::Error,
1924                    codes::INDEX_JSONL_DESYNC,
1925                    jsonl_rel,
1926                    Some((i + 1) as u32),
1927                    None,
1928                    format!("`index.jsonl` record path `{path}` is not a safe store-relative path"),
1929                    Some("run `dbmd index rebuild`".into()),
1930                    vec![],
1931                );
1932                continue;
1933            }
1934            records.insert(PathBuf::from(path), rec);
1935        }
1936    }
1937
1938    let member_set: BTreeSet<PathBuf> = members
1939        .iter()
1940        .filter(|m| is_content_file(m))
1941        .cloned()
1942        .collect();
1943
1944    // jsonl record → missing file = desync.
1945    for path in records.keys() {
1946        let target_abs = store.root.join(path);
1947        if !target_abs.is_file() {
1948            push(
1949                issues,
1950                Severity::Error,
1951                codes::INDEX_JSONL_DESYNC,
1952                jsonl_rel,
1953                None,
1954                None,
1955                format!(
1956                    "`index.jsonl` record points at missing file `{}`",
1957                    path.display()
1958                ),
1959                Some("run `dbmd index rebuild`".into()),
1960                vec![],
1961            );
1962        }
1963    }
1964
1965    // file not in jsonl = desync (the jsonl is the complete twin — no cap).
1966    for m in &member_set {
1967        if !records.contains_key(m) {
1968            push(
1969                issues,
1970                Severity::Error,
1971                codes::INDEX_JSONL_DESYNC,
1972                jsonl_rel,
1973                None,
1974                None,
1975                format!(
1976                    "file `{}` is missing from the complete `index.jsonl`",
1977                    m.display()
1978                ),
1979                Some("run `dbmd index rebuild`".into()),
1980                vec![m.clone()],
1981            );
1982        }
1983    }
1984
1985    // Record fields stale vs. frontmatter. SPEC § Validation defines
1986    // `INDEX_JSONL_STALE` as "an `index.jsonl` record's fields don't match the
1987    // file's frontmatter" — ANY field, not just `summary`/`type`. The query and
1988    // search paths read every field straight from these sidecars (`tags`,
1989    // `links`, `created`, `updated`, plus type-specific `email` / `domain` /
1990    // `company` / `amount` / `vendor` …), so a single field left unchecked lets
1991    // a stale value answer queries with data that exists in no `.md` file.
1992    //
1993    // Rather than re-list (and drift from) every projected key, rebuild the
1994    // record the canonical projection would write for this file
1995    // ([`IndexRecord::expected_from_file`], the same path `index rebuild` uses)
1996    // and diff the two as flat JSON maps. Every key the projection emits is
1997    // covered automatically; `path` is the join key and is skipped.
1998    for (path, rec) in &records {
1999        let target_abs = store.root.join(path);
2000        if !target_abs.is_file() {
2001            continue;
2002        }
2003        let Ok(expected) = crate::index::IndexRecord::expected_from_file(&target_abs, path.clone())
2004        else {
2005            continue; // unreadable / unparseable frontmatter is reported elsewhere
2006        };
2007        let Ok(expected_json) = serde_json::to_value(&expected) else {
2008            continue;
2009        };
2010        let (Some(have), Some(want)) = (rec.as_object(), expected_json.as_object()) else {
2011            continue;
2012        };
2013
2014        // Compare the union of keys present on either side; a key the file
2015        // projects but the sidecar omits is just as stale as a wrong value.
2016        let mut mismatched_keys: BTreeSet<&str> = BTreeSet::new();
2017        for key in have.keys().chain(want.keys()) {
2018            if key == "path" {
2019                continue;
2020            }
2021            if have.get(key) != want.get(key) {
2022                mismatched_keys.insert(key);
2023            }
2024        }
2025
2026        if !mismatched_keys.is_empty() {
2027            let keys: Vec<&str> = mismatched_keys.into_iter().collect();
2028            push(
2029                issues,
2030                Severity::Error,
2031                codes::INDEX_JSONL_STALE,
2032                jsonl_rel,
2033                None,
2034                Some(keys.join(",")),
2035                format!(
2036                    "`index.jsonl` record for `{}` is stale ({})",
2037                    path.display(),
2038                    keys.join(", ")
2039                ),
2040                Some("run `dbmd index rebuild`".into()),
2041                vec![path.clone()],
2042            );
2043        }
2044    }
2045    let _ = tf;
2046}
2047
2048/// Check an index's `scope:` frontmatter against its filesystem location.
2049fn check_index_scope(
2050    store: &Store,
2051    index_rel: &Path,
2052    expected_scope: &str,
2053    expected_folder: Option<&str>,
2054    issues: &mut Vec<Issue>,
2055) {
2056    let abs = store.root.join(index_rel);
2057    let Ok(text) = std::fs::read_to_string(&abs) else {
2058        return;
2059    };
2060    let Some((yaml, _, _)) = split_frontmatter(&text) else {
2061        return;
2062    };
2063    let Ok(Value::Mapping(map)) = serde_norway::from_str::<Value>(&yaml) else {
2064        return;
2065    };
2066    let fm = yaml_map_to_btree(&map);
2067
2068    if let Some(scope) = fm.get("scope").and_then(scalar_string) {
2069        // Accept "type-folder" and the SPEC example's looser "folder" alias.
2070        let scope_ok =
2071            scope == expected_scope || (expected_scope == "type-folder" && scope == "folder");
2072        if !scope_ok {
2073            push(
2074                issues,
2075                Severity::Warning,
2076                codes::INDEX_WRONG_SCOPE,
2077                index_rel,
2078                fm_key_line(&yaml, "scope"),
2079                Some("scope".into()),
2080                format!(
2081                    "index `scope: {scope}` doesn't match location (expected `{expected_scope}`)"
2082                ),
2083                Some(format!("set `scope: {expected_scope}`")),
2084                vec![],
2085            );
2086        }
2087    }
2088    // folder: must match for layer/type-folder indexes.
2089    if let Some(expected) = expected_folder {
2090        if let Some(folder) = fm.get("folder").and_then(scalar_string) {
2091            if folder.trim_end_matches('/') != expected.trim_end_matches('/') {
2092                push(
2093                    issues,
2094                    Severity::Warning,
2095                    codes::INDEX_WRONG_SCOPE,
2096                    index_rel,
2097                    fm_key_line(&yaml, "folder"),
2098                    Some("folder".into()),
2099                    format!("index `folder: {folder}` doesn't match location `{expected}`"),
2100                    Some(format!("set `folder: {expected}`")),
2101                    vec![],
2102                );
2103            }
2104        }
2105    }
2106}
2107
2108// ─────────────────────────────────────────────────────────────────────────────
2109//  Cross-file: log.md well-formedness + ordering (validate_all only)
2110// ─────────────────────────────────────────────────────────────────────────────
2111
2112/// `LOG_*` checks: bad timestamps, unknown kinds, out-of-order entries — across
2113/// the active `log.md` AND the rotated `log/<YYYY-MM>.md` archives.
2114///
2115/// [`Log::append`] rolls strictly-prior-month entries into `log/<YYYY-MM>.md`,
2116/// and `Log::tail`/`Log::since` deliberately read those archives back. If the
2117/// LOG_* checks read only the active file, an entry `validate --all` flagged
2118/// while it lived in `log.md` would stop being flagged the moment a newer-month
2119/// append rotated it into an archive — even though the log readers still surface
2120/// that exact entry to the curator. Scanning the archives too keeps validate and
2121/// the readers in agreement after a rotation.
2122///
2123/// Order: archives oldest-month first, then the active `log.md` last — the true
2124/// chronological timeline — so the out-of-order check threads `prev` across the
2125/// rotation boundary the same way it does within a single file.
2126fn check_log(store: &Store, issues: &mut Vec<Issue>) {
2127    let mut prev: Option<DateTime<FixedOffset>> = None;
2128    for rel in log_files_chronological(store) {
2129        check_log_file(store, &rel, &mut prev, issues);
2130    }
2131}
2132
2133/// The log files to scan, in chronological order: every `log/<YYYY-MM>.md`
2134/// archive oldest-month first, then the active `log.md` last. Missing files are
2135/// simply absent from the list.
2136fn log_files_chronological(store: &Store) -> Vec<PathBuf> {
2137    let mut files: Vec<PathBuf> = Vec::new();
2138    let archive_dir = store.root.join("log");
2139    if let Ok(entries) = std::fs::read_dir(&archive_dir) {
2140        let mut archives: Vec<PathBuf> = entries
2141            .flatten()
2142            .map(|e| e.path())
2143            .filter(|p| {
2144                p.is_file()
2145                    && p.file_name()
2146                        .and_then(|s| s.to_str())
2147                        .and_then(|n| n.strip_suffix(".md"))
2148                        .is_some_and(is_year_month_archive)
2149            })
2150            .filter_map(|p| p.strip_prefix(&store.root).ok().map(Path::to_path_buf))
2151            .collect();
2152        // `YYYY-MM` stems sort lexically == chronologically; oldest first.
2153        archives.sort();
2154        files.extend(archives);
2155    }
2156    // The active file holds the current month — newest, so it comes last.
2157    if store.root.join("log.md").is_file() {
2158        files.push(PathBuf::from("log.md"));
2159    }
2160    files
2161}
2162
2163/// Scan one log file's entry headers, threading the running `prev` timestamp so
2164/// the out-of-order check spans file (rotation) boundaries. Issues anchor to the
2165/// given store-relative path so an archived entry points at its archive file.
2166fn check_log_file(
2167    store: &Store,
2168    log_rel: &Path,
2169    prev: &mut Option<DateTime<FixedOffset>>,
2170    issues: &mut Vec<Issue>,
2171) {
2172    let abs = store.root.join(log_rel);
2173    let Ok(text) = std::fs::read_to_string(&abs) else {
2174        return;
2175    };
2176
2177    for (i, line) in text.lines().enumerate() {
2178        if !line.starts_with("## [") {
2179            continue;
2180        }
2181        let line_no = (i + 1) as u32;
2182        match parse_log_header(line) {
2183            None => push(
2184                issues,
2185                Severity::Error,
2186                codes::LOG_BAD_TIMESTAMP,
2187                log_rel,
2188                Some(line_no),
2189                None,
2190                format!("log entry header has an unparseable timestamp: {line:?}"),
2191                Some("use `## [YYYY-MM-DD HH:MM] <kind> | <object>`".into()),
2192                vec![],
2193            ),
2194            Some((ts, kind, _object)) => {
2195                if !RECOGNIZED_LOG_KINDS.contains(&kind.as_str()) {
2196                    push(
2197                        issues,
2198                        Severity::Warning,
2199                        codes::LOG_UNKNOWN_KIND,
2200                        log_rel,
2201                        Some(line_no),
2202                        None,
2203                        format!("log entry kind `{kind}` is not recognized"),
2204                        Some(format!("use one of: {}", RECOGNIZED_LOG_KINDS.join(", "))),
2205                        vec![],
2206                    );
2207                }
2208                if let Some(p) = *prev {
2209                    if ts < p {
2210                        push(
2211                            issues,
2212                            Severity::Warning,
2213                            codes::LOG_OUT_OF_ORDER,
2214                            log_rel,
2215                            Some(line_no),
2216                            None,
2217                            "log entry is older than the entry above it (possible rewrite)".into(),
2218                            Some("append corrective entries; never reorder past ones".into()),
2219                            vec![],
2220                        );
2221                    }
2222                }
2223                *prev = Some(ts);
2224            }
2225        }
2226    }
2227}
2228
2229// ─────────────────────────────────────────────────────────────────────────────
2230//  Self-contained primitives (collapse onto sibling modules once they land)
2231// ─────────────────────────────────────────────────────────────────────────────
2232
2233/// A minimal wiki-link found in a body: target, optional display, 1-based line.
2234#[derive(Debug)]
2235struct Link {
2236    target: String,
2237    line: u32,
2238}
2239
2240/// True if the store marker (`DB.md`, uppercase) is present at the root. On a
2241/// case-insensitive filesystem `db.md` would also match `DB.md`; we require the
2242/// exact-cased directory entry to be present.
2243fn store_marker_present(store: &Store) -> bool {
2244    let want = store.root.join("DB.md");
2245    if !want.is_file() {
2246        return false;
2247    }
2248    // Reject a case-folded match (`db.md`) on case-insensitive filesystems.
2249    match std::fs::read_dir(&store.root) {
2250        Ok(entries) => entries
2251            .flatten()
2252            .any(|e| e.file_name().to_str() == Some("DB.md")),
2253        Err(_) => true, // can't enumerate; trust the is_file() above
2254    }
2255}
2256
2257/// Validate the store's identity file, `DB.md`: its frontmatter `type:` must be
2258/// `db-md`, it must carry both `scope` and `owner`, and its body may contain
2259/// only the three recognized `##` sections (`Agent instructions`, `Policies`,
2260/// `Schemas`).
2261///
2262/// `DB.md` is not a content file (no `summary`), so it is checked here rather
2263/// than through `check_content_file`. The marker presence is established by the
2264/// caller (`store_marker_present`); a malformed-frontmatter `DB.md` still counts
2265/// as a store (the marker is the filename), so we report its shape rather than
2266/// `NOT_A_STORE`. Issues anchor to `DB.md` as the store-relative path.
2267fn check_db_md(store: &Store, issues: &mut Vec<Issue>) {
2268    let rel = Path::new("DB.md");
2269    let abs = store.root.join("DB.md");
2270    let Ok(text) = std::fs::read_to_string(&abs) else {
2271        return; // marker present but unreadable: nothing more to say.
2272    };
2273
2274    let Some((fm_yaml, body, fm_end_line)) = split_frontmatter(&text) else {
2275        // No frontmatter block at all → it cannot declare `type: db-md` and has
2276        // neither required field. Report the type and both missing fields,
2277        // anchored to line 1 (the would-be opening fence).
2278        push(
2279            issues,
2280            Severity::Error,
2281            codes::DB_MD_BAD_TYPE,
2282            rel,
2283            Some(1),
2284            Some("type".into()),
2285            "DB.md has no frontmatter; it must declare `type: db-md`".into(),
2286            Some("add a `---` frontmatter block with `type: db-md`".into()),
2287            vec![],
2288        );
2289        for field in ["scope", "owner"] {
2290            push(
2291                issues,
2292                Severity::Error,
2293                codes::DB_MD_MISSING_FIELD,
2294                rel,
2295                Some(1),
2296                Some(field.into()),
2297                format!("DB.md frontmatter is missing required field `{field}`"),
2298                Some(format!("add `{field}:` to the DB.md frontmatter")),
2299                vec![],
2300            );
2301        }
2302        return;
2303    };
2304
2305    // Parse the frontmatter mapping. If it doesn't parse, we can still say the
2306    // identity contract is unmet (no provable `type: db-md`, no provable fields).
2307    let fm: Option<BTreeMap<String, Value>> = match serde_norway::from_str::<Value>(&fm_yaml) {
2308        Ok(Value::Mapping(map)) => Some(yaml_map_to_btree(&map)),
2309        Ok(Value::Null) => Some(BTreeMap::new()),
2310        _ => None,
2311    };
2312
2313    match &fm {
2314        Some(map) => {
2315            // ── type: db-md ──────────────────────────────────────────────────
2316            let type_ = map.get("type").and_then(scalar_string);
2317            if type_.as_deref() != Some("db-md") {
2318                let (line, msg) = match &type_ {
2319                    Some(t) => (
2320                        fm_key_line(&fm_yaml, "type"),
2321                        format!("DB.md has `type: {t}`; a store's DB.md must be `type: db-md`"),
2322                    ),
2323                    None => (
2324                        Some(1),
2325                        "DB.md frontmatter has no `type:`; it must be `type: db-md`".to_string(),
2326                    ),
2327                };
2328                push(
2329                    issues,
2330                    Severity::Error,
2331                    codes::DB_MD_BAD_TYPE,
2332                    rel,
2333                    line,
2334                    Some("type".into()),
2335                    msg,
2336                    Some("set `type: db-md` in the DB.md frontmatter".into()),
2337                    vec![],
2338                );
2339            }
2340
2341            // ── required fields: scope + owner ───────────────────────────────
2342            for field in ["scope", "owner"] {
2343                let present = map
2344                    .get(field)
2345                    .and_then(scalar_string)
2346                    .map(|s| !s.trim().is_empty())
2347                    .unwrap_or(false);
2348                if !present {
2349                    push(
2350                        issues,
2351                        Severity::Error,
2352                        codes::DB_MD_MISSING_FIELD,
2353                        rel,
2354                        // A present-but-empty field anchors to its line; a fully
2355                        // absent one to the block top.
2356                        fm_key_line_or_top(&fm_yaml, field),
2357                        Some(field.into()),
2358                        format!("DB.md frontmatter is missing required field `{field}`"),
2359                        Some(format!("add `{field}:` to the DB.md frontmatter")),
2360                        vec![],
2361                    );
2362                }
2363            }
2364        }
2365        None => {
2366            // Unparseable frontmatter: the identity contract is unprovable. Emit
2367            // the type error and both field errors, anchored to the block top.
2368            push(
2369                issues,
2370                Severity::Error,
2371                codes::DB_MD_BAD_TYPE,
2372                rel,
2373                Some(1),
2374                Some("type".into()),
2375                "DB.md frontmatter isn't valid YAML; it must declare `type: db-md`".into(),
2376                Some("fix the DB.md frontmatter and set `type: db-md`".into()),
2377                vec![],
2378            );
2379            for field in ["scope", "owner"] {
2380                push(
2381                    issues,
2382                    Severity::Error,
2383                    codes::DB_MD_MISSING_FIELD,
2384                    rel,
2385                    Some(1),
2386                    Some(field.into()),
2387                    format!("DB.md frontmatter is missing required field `{field}`"),
2388                    Some(format!("add `{field}:` to the DB.md frontmatter")),
2389                    vec![],
2390                );
2391            }
2392        }
2393    }
2394
2395    // ── recognized `##` section headers only ─────────────────────────────────
2396    // The body's H2 headings must be one of the four the toolkit reads; any
2397    // other is a likely typo / misplacement (warning — the parser ignores it,
2398    // so the config is not corrupted, but the operator wrote a section that will
2399    // never be read). H3 sub-headings (Frozen pages, Ignored types, `### <type>`
2400    // schema blocks) live under their H2 and are not flagged here.
2401    //
2402    // `## Folders` is recognized: `parse_db_md` reads it into `Config.folders`
2403    // (parser.rs) and the index renders folder display names + descriptions from
2404    // it (index.rs `render_*_md_from_stats`). Flagging it `DB_MD_UNKNOWN_SECTION`
2405    // with "remove this heading" told the operator to delete a working,
2406    // round-tripped config block — destroying curator-authored rollup names. It
2407    // is a real, shipped section; SPEC.md documents it alongside the other three.
2408    for section in crate::parser::extract_sections(&body) {
2409        if section.level != 2 {
2410            continue;
2411        }
2412        let name = section.heading.trim().to_ascii_lowercase();
2413        if matches!(
2414            name.as_str(),
2415            "agent instructions" | "policies" | "schemas" | "folders"
2416        ) {
2417            continue;
2418        }
2419        // `Section::line` is 1-based within the body; the body begins at file
2420        // line `fm_end_line + 1`.
2421        let file_line = fm_end_line + section.line;
2422        push(
2423            issues,
2424            Severity::Warning,
2425            codes::DB_MD_UNKNOWN_SECTION,
2426            rel,
2427            Some(file_line),
2428            None,
2429            format!(
2430                "DB.md has an unrecognized `## {}` section",
2431                section.heading.trim()
2432            ),
2433            Some(
2434                "DB.md sections are `## Agent instructions`, `## Policies`, `## Schemas`, \
2435                 `## Folders` — remove or rename this heading"
2436                    .into(),
2437            ),
2438            vec![],
2439        );
2440    }
2441
2442    // ── `## Schemas` field-declaration lint ──────────────────────────────────
2443    // Without this, every schema misparse is silent: the operator/agent gets no
2444    // signal that DB.md is interpreting their schema differently from what they
2445    // wrote, and downstream records are validated against the degraded schema.
2446    check_db_md_schemas(store, rel, &body, fm_end_line, issues);
2447}
2448
2449/// Lint the parsed `## Schemas` field declarations: an empty field name, a
2450/// duplicate field name within a type, or an unrecognized modifier all parse
2451/// "successfully" into a degraded [`Schema`] today, so a bad declaration never
2452/// surfaces. The parsed schemas live in `store.config.schemas` (directives
2453/// already separated out); this pass reports the suspicious *field* shapes,
2454/// anchored to the `### <type>` heading line so the agent can find the block.
2455fn check_db_md_schemas(
2456    store: &Store,
2457    rel: &Path,
2458    body: &str,
2459    fm_end_line: u32,
2460    issues: &mut Vec<Issue>,
2461) {
2462    if store.config.schemas.is_empty() {
2463        return;
2464    }
2465
2466    // Map each `### <type>` heading (under `## Schemas`) to its file line, so a
2467    // per-type issue can anchor to the declaration block. `extract_sections`
2468    // returns a flat list with 1-based body lines; the body starts at file line
2469    // `fm_end_line + 1`.
2470    let mut type_line: BTreeMap<String, u32> = BTreeMap::new();
2471    let mut current_h2: Option<String> = None;
2472    for section in crate::parser::extract_sections(body) {
2473        match section.level {
2474            2 => current_h2 = Some(section.heading.trim().to_ascii_lowercase()),
2475            3 if current_h2.as_deref() == Some("schemas") => {
2476                // The H3 heading text (as written) is the type name — the same
2477                // key `parse_db_md` inserts into `config.schemas`.
2478                type_line
2479                    .entry(section.heading.trim().to_string())
2480                    .or_insert(fm_end_line + section.line);
2481            }
2482            _ => {}
2483        }
2484    }
2485
2486    for (type_name, schema) in &store.config.schemas {
2487        let line = type_line.get(type_name).copied();
2488        let mut seen: BTreeSet<String> = BTreeSet::new();
2489        for field in &schema.fields {
2490            let name = field.name.trim();
2491
2492            // Empty field name: a `- (string)` / bare `- ` bullet parses to a
2493            // nameless field that can never match a frontmatter key, so its
2494            // required/shape/enum constraints silently never apply.
2495            if name.is_empty() {
2496                push(
2497                    issues,
2498                    Severity::Warning,
2499                    codes::DB_MD_SCHEMA_FIELD,
2500                    rel,
2501                    line,
2502                    None,
2503                    format!("`### {type_name}` has a schema field bullet with no field name"),
2504                    Some(
2505                        "write each field as `- <name> (<modifiers>)`, e.g. `- email (required, email)`"
2506                            .into(),
2507                    ),
2508                    vec![],
2509                );
2510                continue;
2511            }
2512
2513            // Duplicate field name within a type: the second declaration's
2514            // constraints are interpreted independently of the first, so the
2515            // author's intent is ambiguous and likely wrong.
2516            if !seen.insert(name.to_string()) {
2517                push(
2518                    issues,
2519                    Severity::Warning,
2520                    codes::DB_MD_SCHEMA_FIELD,
2521                    rel,
2522                    line,
2523                    Some(name.to_string()),
2524                    format!("`### {type_name}` declares field `{name}` more than once"),
2525                    Some(
2526                        "remove the duplicate field bullet, or merge the modifiers onto one".into(),
2527                    ),
2528                    vec![],
2529                );
2530            }
2531
2532            // Unrecognized modifiers: the parser stashes anything outside the
2533            // known vocabulary (`required` / a shape / `link to …` / `default …`
2534            // / `enum: …`) in `unknown_modifiers`. Surface them as Info so a
2535            // typo'd modifier (`requierd`, `unqiue`) doesn't silently do nothing.
2536            for modifier in &field.unknown_modifiers {
2537                let modifier = modifier.trim();
2538                if modifier.is_empty() {
2539                    continue;
2540                }
2541                push(
2542                    issues,
2543                    Severity::Info,
2544                    codes::DB_MD_SCHEMA_FIELD,
2545                    rel,
2546                    line,
2547                    Some(name.to_string()),
2548                    format!(
2549                        "`### {type_name}` field `{name}` has an unrecognized modifier `{modifier}`"
2550                    ),
2551                    Some(
2552                        "recognized modifiers are `required`, a shape (`string`/`int`/`bool`/`date`/`email`/`currency`/`url`), `link to <prefix>/`, `default <value>`, `enum: <v1>, <v2>, …`"
2553                            .into(),
2554                    ),
2555                    vec![],
2556                );
2557            }
2558        }
2559
2560        // A `unique:` key silently skips any record missing (or leaving empty)
2561        // one of its fields — an incomplete key never collides (`dedup_key`).
2562        // So a key that names a field the schema doesn't mark `required` stops
2563        // checking exactly the records most likely to be re-entered partially
2564        // filled. Surface the gap at the declaration: every key field should
2565        // be a `required` field. (A field declared more than once counts as
2566        // required if any declaration marks it — the duplicate itself is
2567        // already flagged above.)
2568        let mut declared: BTreeMap<&str, bool> = BTreeMap::new();
2569        for f in &schema.fields {
2570            let e = declared.entry(f.name.trim()).or_insert(false);
2571            *e = *e || f.required;
2572        }
2573        let mut flagged: BTreeSet<&str> = BTreeSet::new();
2574        for key_fields in &schema.unique_keys {
2575            for field in key_fields {
2576                let name = field.trim();
2577                if name.is_empty()
2578                    || declared.get(name).copied() == Some(true)
2579                    || !flagged.insert(name)
2580                {
2581                    continue;
2582                }
2583                let message = if declared.contains_key(name) {
2584                    format!(
2585                        "`### {type_name}` `unique:` key field `{name}` is not `required` — a record missing or leaving it empty is silently skipped by the unique check"
2586                    )
2587                } else {
2588                    format!(
2589                        "`### {type_name}` `unique:` key field `{name}` is not declared in the schema, so it can never be `required` — a record missing it is silently skipped by the unique check"
2590                    )
2591                };
2592                push(
2593                    issues,
2594                    Severity::Warning,
2595                    codes::DB_MD_SCHEMA_FIELD,
2596                    rel,
2597                    line,
2598                    Some(name.to_string()),
2599                    message,
2600                    Some(format!(
2601                        "mark `{name}` `required` in `### {type_name}`, or build the `unique:` key from required fields only"
2602                    )),
2603                    vec![],
2604                );
2605            }
2606        }
2607    }
2608}
2609
2610/// The `NOT_A_STORE` issue for a root with no `DB.md`.
2611fn not_a_store_issue(store: &Store) -> Issue {
2612    Issue {
2613        severity: Severity::Error,
2614        code: codes::NOT_A_STORE,
2615        file: store.root.clone(),
2616        line: None,
2617        key: None,
2618        message: format!("{} has no DB.md; not a db.md store", store.root.display()),
2619        suggestion: Some("create a `DB.md` at the store root".into()),
2620        related: vec![],
2621    }
2622}
2623
2624/// True if a store-relative path is a content file: under `sources/` or
2625/// `records/` and not an `index.md`/`index.jsonl`/`log.md`.
2626fn is_content_file(rel: &Path) -> bool {
2627    // Defense in depth: a real content file is always a forward (Normal-only)
2628    // store-relative path. Reject any `..`/absolute/prefix component so a
2629    // malformed object slot judged only by its FIRST component (`records/../..`)
2630    // can never turn a per-file read into a store escape, even if a future caller
2631    // forgets the path-safety gate `changed_objects_since` now applies.
2632    if !is_safe_store_relative_path(rel) {
2633        return false;
2634    }
2635    let Some(first) = rel.iter().next().and_then(|s| s.to_str()) else {
2636        return false;
2637    };
2638    if !matches!(first, "sources" | "records") {
2639        return false;
2640    }
2641    let name = rel.file_name().and_then(|s| s.to_str()).unwrap_or("");
2642    // Only the derived catalog twins are meta INSIDE a layer. `DB.md` / `log.md`
2643    // are reserved meta only at the store ROOT, which the `first` layer check
2644    // above already excludes — so a content file named `log.md` / `DB.md` inside
2645    // a layer (e.g. `records/docs/log.md`) is real content, consistent with
2646    // `Store::walk`.
2647    if matches!(name, "index.md" | "index.jsonl") {
2648        return false;
2649    }
2650    name.ends_with(".md")
2651}
2652
2653/// True for the store's ROOT append-only meta files (`DB.md` / `log.md`): a
2654/// single-component store-relative path whose name is one of those two. An
2655/// in-layer `records/docs/log.md` is real content (multiple components), not a
2656/// root meta file. These reach `check_content_file` only via the working-set
2657/// incoming-linker scan; their bodies are deliberately not link-checked there
2658/// because `validate --all` doesn't link-check them either.
2659fn is_root_meta_file(rel: &Path) -> bool {
2660    let mut comps = rel.components();
2661    let Some(Component::Normal(only)) = comps.next() else {
2662        return false;
2663    };
2664    if comps.next().is_some() {
2665        return false; // has a parent dir → not a root file
2666    }
2667    matches!(only.to_str(), Some("DB.md") | Some("log.md"))
2668}
2669
2670/// True for a derived index-catalog file (`index.md` / `index.jsonl`) at any
2671/// depth. Its entries are GENERATED wiki-links to type-folder members, not
2672/// authored body links: in the working-set scope it is pulled in as an incoming
2673/// linker, but its integrity belongs to `check_indexes` under `--all` (which
2674/// reports a dangling entry as `INDEX_STALE_ENTRY`, not `WIKI_LINK_BROKEN`). So
2675/// `check_content_file` never body-link-checks it, matching `walk_content_files`
2676/// (which skips `index.md` under `--all`).
2677fn is_index_catalog_file(rel: &Path) -> bool {
2678    matches!(
2679        rel.file_name().and_then(|n| n.to_str()),
2680        Some("index.md") | Some("index.jsonl")
2681    )
2682}
2683
2684/// Split a file into `(frontmatter_yaml, body, closing_fence_line)`. The block
2685/// must start at the very first line with `---` and end at the next `---`.
2686/// Returns `None` if there's no leading frontmatter block.
2687fn split_frontmatter(text: &str) -> Option<(String, String, u32)> {
2688    // Tolerate a single leading UTF-8 BOM, matching parser/store/index (which
2689    // already strip it). Without this, a BOM-prefixed file is read as having no
2690    // frontmatter here while the catalog still indexes it — so validate would
2691    // silently skip frontmatter checks on a file the rest of the toolkit sees.
2692    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
2693    let mut lines = text.lines();
2694    let first = lines.next()?;
2695    if first.trim_end() != "---" {
2696        return None;
2697    }
2698    let mut yaml = String::new();
2699    let mut close_line: Option<u32> = None;
2700    // line 1 is the opening fence; YAML starts at line 2.
2701    let mut current = 1u32;
2702    for line in lines {
2703        current += 1;
2704        if line.trim_end() == "---" {
2705            close_line = Some(current);
2706            break;
2707        }
2708        yaml.push_str(line);
2709        yaml.push('\n');
2710    }
2711    let close_line = close_line?;
2712    // Body = everything after the closing fence.
2713    let body: String = text
2714        .lines()
2715        .skip(close_line as usize)
2716        .collect::<Vec<_>>()
2717        .join("\n");
2718    Some((yaml, body, close_line))
2719}
2720
2721/// Read just the `summary` field of a file, or `None` if absent/unparseable.
2722fn read_summary(abs: &Path) -> Option<String> {
2723    let text = std::fs::read_to_string(abs).ok()?;
2724    let (yaml, _, _) = split_frontmatter(&text)?;
2725    let value: Value = serde_norway::from_str(&yaml).ok()?;
2726    if let Value::Mapping(m) = value {
2727        m.get(Value::String("summary".into()))
2728            .and_then(scalar_string)
2729    } else {
2730        None
2731    }
2732}
2733
2734/// Convert a `serde_norway` mapping into a string-keyed [`BTreeMap`], dropping
2735/// non-string keys (frontmatter keys are always strings).
2736fn yaml_map_to_btree(map: &serde_norway::Mapping) -> BTreeMap<String, Value> {
2737    let mut out = BTreeMap::new();
2738    for (k, v) in map {
2739        if let Value::String(s) = k {
2740            out.insert(s.clone(), v.clone());
2741        }
2742    }
2743    out
2744}
2745
2746/// A scalar YAML value as a string (`String`/`Number`/`Bool`); `None` for
2747/// sequences/mappings/null.
2748fn scalar_string(v: &Value) -> Option<String> {
2749    match v {
2750        Value::String(s) => Some(s.clone()),
2751        Value::Number(n) => Some(n.to_string()),
2752        Value::Bool(b) => Some(b.to_string()),
2753        _ => None,
2754    }
2755}
2756
2757/// True if a frontmatter value carries no content for a *required*-field check:
2758/// a YAML `null` (`name:`), an empty sequence (`name: []`), an empty mapping
2759/// (`name: {}`), or a blank/whitespace-only scalar (`name: ""`). A non-empty
2760/// list or mapping is NOT treated as empty here — a structurally-wrong value on
2761/// a shape/enum field is caught by the later non-scalar shape check, not by the
2762/// required-presence check.
2763fn is_empty_value(v: &Value) -> bool {
2764    match v {
2765        Value::Null => true,
2766        Value::Sequence(items) => items.is_empty(),
2767        Value::Mapping(map) => map.is_empty(),
2768        other => scalar_string(other)
2769            .map(|s| s.trim().is_empty())
2770            .unwrap_or(true),
2771    }
2772}
2773
2774/// True if `tags` is a flat YAML sequence of scalars. A mapping, a scalar, or a
2775/// sequence containing a nested sequence/mapping → false (`TAGS_MALFORMED`).
2776fn is_flat_scalar_list(v: &Value) -> bool {
2777    match v {
2778        Value::Sequence(items) => items.iter().all(|it| scalar_string(it).is_some()),
2779        _ => false,
2780    }
2781}
2782
2783/// Extract every frontmatter wiki-link, returning `(key, Link)` pairs with the
2784/// link's 1-based file line. **Text-based, by necessity:** an unquoted
2785/// `company: [[records/companies/x]]` parses in YAML as a nested *sequence*, not
2786/// a string (because `[[x]]` is YAML flow-list-in-a-list); a quoted
2787/// `"[[...]]"` parses as a string. Scanning the raw frontmatter text catches
2788/// both forms uniformly, the way the link textually appears — the doctrine view.
2789///
2790/// `fm_start_line` is the file line of the first YAML line (file line 2, since
2791/// line 1 is the opening `---`), so the returned `Link::line` is absolute.
2792fn frontmatter_link_fields_text(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, Link)> {
2793    let mut out = Vec::new();
2794    for (key, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2795        for link in links {
2796            out.push((key.clone(), link));
2797        }
2798    }
2799    out
2800}
2801
2802/// The wiki-link targets declared under a single top-level frontmatter key
2803/// (text-based; handles quoted + unquoted forms). Empty if the key is absent or
2804/// carries no `[[...]]`.
2805fn frontmatter_links_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Vec<Link> {
2806    for (k, _value_text, links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2807        if k == key {
2808            return links;
2809        }
2810    }
2811    Vec::new()
2812}
2813
2814/// The raw value text under a single top-level frontmatter key (the remainder of
2815/// the key line plus any indented continuation/sequence lines), trimmed. Used to
2816/// decide whether a `link to` field holds a plain string vs. a wiki-link.
2817fn frontmatter_raw_value_for_key(fm_yaml: &str, key: &str, fm_start_line: u32) -> Option<String> {
2818    for (k, value_text, _links) in frontmatter_key_blocks(fm_yaml, fm_start_line) {
2819        if k == key {
2820            return Some(value_text);
2821        }
2822    }
2823    None
2824}
2825
2826/// Split a frontmatter YAML block into `(key, raw_value_text, wiki_links)` for
2827/// each top-level key. A top-level key is a line with no leading indentation in
2828/// `name:` form; its value spans the rest of that line plus any deeper-indented
2829/// continuation lines (block scalars, block sequences) until the next top-level
2830/// key. Wiki-links are every `[[...]]` found anywhere in that span, with their
2831/// absolute file line.
2832fn frontmatter_key_blocks(fm_yaml: &str, fm_start_line: u32) -> Vec<(String, String, Vec<Link>)> {
2833    let mut blocks: Vec<(String, String, Vec<Link>)> = Vec::new();
2834    let mut current: Option<(String, String, Vec<Link>)> = None;
2835
2836    for (idx, raw_line) in fm_yaml.lines().enumerate() {
2837        let file_line = fm_start_line + idx as u32;
2838        let indented = raw_line.starts_with(' ') || raw_line.starts_with('\t');
2839        let trimmed = raw_line.trim();
2840
2841        // A new top-level key: no indentation, `name:` prefix, not a list dash or
2842        // comment. (Indented or dash lines belong to the current key's value.)
2843        let new_key = if !indented && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
2844            top_level_key(raw_line)
2845        } else {
2846            None
2847        };
2848
2849        if let Some((key, after)) = new_key {
2850            if let Some(done) = current.take() {
2851                blocks.push(done);
2852            }
2853            let mut links = Vec::new();
2854            collect_line_links(after, file_line, &mut links);
2855            current = Some((key, after.trim().to_string(), links));
2856        } else if let Some((_k, value_text, links)) = current.as_mut() {
2857            // Continuation of the current key's value (indented or dash line).
2858            if !value_text.is_empty() {
2859                value_text.push('\n');
2860            }
2861            value_text.push_str(trimmed);
2862            collect_line_links(raw_line, file_line, links);
2863        }
2864    }
2865    if let Some(done) = current.take() {
2866        blocks.push(done);
2867    }
2868    blocks
2869}
2870
2871/// Parse a top-level frontmatter key line into `(key, value_after_colon)`.
2872/// `None` if the line isn't a `name:` mapping entry.
2873fn top_level_key(line: &str) -> Option<(String, &str)> {
2874    let (key, rest) = line.split_once(':')?;
2875    let key = key.trim();
2876    if key.is_empty()
2877        || !key
2878            .chars()
2879            .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
2880    {
2881        return None;
2882    }
2883    Some((key.to_string(), rest))
2884}
2885
2886/// Append every `[[target]]` / `[[target|display]]` found in `s` to `links`,
2887/// each tagged with `file_line`.
2888fn collect_line_links(s: &str, file_line: u32, links: &mut Vec<Link>) {
2889    let bytes = s.as_bytes();
2890    let mut i = 0;
2891    while i + 1 < bytes.len() {
2892        if bytes[i] == b'[' && bytes[i + 1] == b'[' {
2893            if let Some(close) = s[i + 2..].find("]]") {
2894                let inner = &s[i + 2..i + 2 + close];
2895                // Guard against `[[[` (nested) double-counting: the inner must
2896                // not itself open another `[[`.
2897                let target = inner
2898                    .trim_start_matches('[')
2899                    .split('|')
2900                    .next()
2901                    .unwrap_or(inner)
2902                    .trim()
2903                    .to_string();
2904                if !target.is_empty() {
2905                    links.push(Link {
2906                        target,
2907                        line: file_line,
2908                    });
2909                }
2910                i = i + 2 + close + 2;
2911                continue;
2912            }
2913        }
2914        i += 1;
2915    }
2916}
2917
2918/// Extract every `[[...]]` wiki-link from a body, with 1-based line numbers.
2919/// Skips fenced code blocks, so example links in docs don't trip the validator.
2920///
2921/// Fence tracking matches the toolkit's parser ([`crate::parser`]'s
2922/// `extract_sections`): an open fence is `(fence char, run length)` and closes
2923/// only on a line that is the **same** fence character with a run **at least as
2924/// long**. A naive "toggle a bool on any ``` or ~~~ line" inverts the state when
2925/// a `~~~` block legally contains a ```` ``` ```` line (the standard way to
2926/// document a backtick fence) — the inner backtick line would flip `in_fence`
2927/// off and the demo `[[…]]` inside the code block would be checked as a live
2928/// link, falsely flagging a legal store.
2929fn extract_wiki_links(body: &str) -> Vec<Link> {
2930    let mut out = Vec::new();
2931    let mut fence: Option<(u8, usize)> = None;
2932    for (idx, line) in body.lines().enumerate() {
2933        let content = line.trim_end_matches('\r');
2934        if let Some(f) = fence {
2935            // Inside a fence: the only thing that matters is whether THIS line
2936            // closes it (matching char, run ≥ the opening run). Everything else
2937            // is opaque code — no link extraction.
2938            if fence_closes(content, f) {
2939                fence = None;
2940            }
2941            continue;
2942        }
2943        if let Some(opened) = fence_opens(content) {
2944            fence = Some(opened);
2945            continue;
2946        }
2947        let line_no = (idx + 1) as u32;
2948        let bytes = line.as_bytes();
2949        let mut i = 0;
2950        while i + 1 < bytes.len() {
2951            if bytes[i] == b'[' && bytes[i + 1] == b'[' {
2952                if let Some(close) = line[i + 2..].find("]]") {
2953                    let inner = &line[i + 2..i + 2 + close];
2954                    let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
2955                    // Skip a triple-bracket `[[[…` opening: the inner content
2956                    // starts with `[`, so this is the rejected flow-form list
2957                    // mis-encoding (`[[[a]], [[b]]]`), not a real wiki-link. A
2958                    // legitimate target never starts with `[`. The frontmatter
2959                    // `WIKI_LINK_FLOW_FORM_LIST` check already owns that error;
2960                    // extracting a bogus body link here would double-report it as
2961                    // a spurious `WIKI_LINK_SHORT_FORM`.
2962                    if !target.is_empty() && !target.starts_with('[') {
2963                        out.push(Link {
2964                            target,
2965                            line: line_no,
2966                        });
2967                    }
2968                    i = i + 2 + close + 2;
2969                    continue;
2970                }
2971            }
2972            i += 1;
2973        }
2974    }
2975    out
2976}
2977
2978/// If `line` opens a fenced code block, return `(fence byte, run length)`. A
2979/// local mirror of the parser's `opening_fence` so the validator's fence
2980/// tracking matches the rest of the toolkit: a fence is ``` ``` ``` or `~~~`
2981/// (run ≥ 3) at ≤ 3 spaces of indent, and a backtick fence's info string may
2982/// not itself contain a backtick.
2983fn fence_opens(line: &str) -> Option<(u8, usize)> {
2984    let indent = line.len() - line.trim_start_matches(' ').len();
2985    if indent > 3 {
2986        return None;
2987    }
2988    let rest = &line[indent..];
2989    let byte = rest.bytes().next()?;
2990    if byte != b'`' && byte != b'~' {
2991        return None;
2992    }
2993    let run = rest.len() - rest.trim_start_matches(byte as char).len();
2994    if run < 3 {
2995        return None;
2996    }
2997    // A backtick fence's info string may not itself contain a backtick.
2998    if byte == b'`' && rest[run..].contains('`') {
2999        return None;
3000    }
3001    Some((byte, run))
3002}
3003
3004/// True if `line` closes the currently open `fence`: same char, run at least as
3005/// long, nothing but trailing whitespace after. Local mirror of the parser's
3006/// `is_closing_fence` — so an inner fence of the *other* character (a ``` ``` ```
3007/// line inside a `~~~` block) does NOT close the outer fence.
3008fn fence_closes(line: &str, fence: (u8, usize)) -> bool {
3009    let (byte, open_len) = fence;
3010    let indent = line.len() - line.trim_start_matches(' ').len();
3011    if indent > 3 {
3012        return false;
3013    }
3014    let rest = &line[indent..];
3015    let run = rest.len() - rest.trim_start_matches(byte as char).len();
3016    if run < open_len {
3017        return false;
3018    }
3019    rest[run..].trim().is_empty()
3020}
3021
3022/// Detect the frontmatter INLINE flow-form wiki-link-list mis-encoding —
3023/// `attendees: [[[a]], [[b]]]` — and return the offending keys.
3024///
3025/// **Scoped to the inline value on the key line.** The SPEC's canonical
3026/// list-of-links form is the *unquoted YAML block sequence* (`- [[a]]` per
3027/// indented line), which is explicitly correct (SPEC § Linking) and MUST NOT be
3028/// flagged — even though, parsed whole, it nests the same way the rejected
3029/// inline flow form does. So this check looks only at the value written *inline*
3030/// after the colon: if it opens a flow sequence (`[…]`) whose parsed shape is a
3031/// nested sequence (a list whose items are themselves lists — the wiki-link-list
3032/// mis-encoding), it is flagged. A key with no inline value (the block form,
3033/// whose items live on continuation lines) is never inspected here.
3034///
3035/// Parsing the inline value (rather than a literal `starts_with("[[[")` text
3036/// test) is what catches the whitespace variant `attendees: [ [[a]] ]`, which
3037/// encodes the identical nested sequence but evaded the old prefix match.
3038fn detect_flow_form_link_lists(fm_yaml: &str) -> Vec<String> {
3039    let mut out = Vec::new();
3040    for line in fm_yaml.lines() {
3041        // Top-level key lines only (no indentation, not a comment or list dash).
3042        if line.starts_with(' ') || line.starts_with('\t') {
3043            continue;
3044        }
3045        let Some((key, rest)) = line.split_once(':') else {
3046            continue;
3047        };
3048        let key = key.trim();
3049        if key.is_empty()
3050            || key.starts_with('#')
3051            || key.starts_with('-')
3052            || !key
3053                .chars()
3054                .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
3055        {
3056            continue;
3057        }
3058        let rest = rest.trim();
3059        // Only an inline flow sequence (`[…]`) on the key line is a candidate;
3060        // the unquoted block form has an empty inline value and is never flagged.
3061        if !rest.starts_with('[') {
3062            continue;
3063        }
3064        // Parse just the inline value and test its shape: a list whose items are
3065        // themselves lists is the wiki-link-list mis-encoding (`[[[a]]]` parses
3066        // to `Seq[Seq[Seq[String]]]`; the scalar inline link `[[a]]` is only
3067        // `Seq[Seq[String]]` and is NOT flagged).
3068        if let Ok(Value::Sequence(items)) = serde_norway::from_str::<Value>(rest) {
3069            let nested = items.iter().any(|item| match item {
3070                Value::Sequence(inner) => inner.iter().any(|x| matches!(x, Value::Sequence(_))),
3071                _ => false,
3072            });
3073            if nested {
3074                out.push(key.to_string());
3075            }
3076        }
3077    }
3078    out
3079}
3080
3081/// True if a bare target (no `.md`) is a full store-relative path: it contains a
3082/// `/` and its first segment is a known layer.
3083fn is_full_store_path(bare: &str) -> bool {
3084    let mut parts = bare.splitn(2, '/');
3085    let first = parts.next().unwrap_or("");
3086    let has_rest = parts.next().map(|r| !r.is_empty()).unwrap_or(false);
3087    matches!(first, "sources" | "records") && has_rest
3088}
3089
3090/// True if a path contains only normal relative components. Validator inputs
3091/// come from user-authored markdown/JSON sidecars; never let absolute paths,
3092/// platform prefixes, or `..` turn a validation probe into a filesystem escape.
3093fn is_safe_store_relative_path(path: &Path) -> bool {
3094    let mut saw_component = false;
3095    for component in path.components() {
3096        match component {
3097            Component::Normal(_) => saw_component = true,
3098            Component::CurDir => {}
3099            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
3100        }
3101    }
3102    saw_component
3103}
3104
3105fn safe_md_target_rel(bare: &str) -> Option<PathBuf> {
3106    let path = Path::new(bare);
3107    if !is_safe_store_relative_path(path) {
3108        return None;
3109    }
3110    Some(PathBuf::from(format!("{bare}.md")))
3111}
3112
3113/// How a wiki-link / index-entry target resolves on disk.
3114enum TargetResolution {
3115    /// The target exists (either as the literal path or with a `.md` suffix).
3116    Exists,
3117    /// The target is a safe store-relative path but no file exists for it.
3118    Missing,
3119    /// The target escapes the store (absolute, `..`, prefix) — never probe it.
3120    Unsafe,
3121}
3122
3123/// Resolve a bare wiki-link / index-entry target the way the graph engine does
3124/// ([`crate::graph`]'s `resolve_existing`): try the path **as written** first
3125/// (so a link to a raw non-`.md` source file kept verbatim under `sources/` —
3126/// `[[sources/emails/x.eml]]`, `[[sources/contracts/y.pdf]]` — resolves to the
3127/// real file), then the `.md`-appended path (the common case for content
3128/// pages). Without trying the literal path first, a legal link to a raw source
3129/// file is wrongly flagged `WIKI_LINK_BROKEN` even though `graph backlinks`
3130/// resolves it.
3131fn resolve_wiki_target(store: &Store, bare: &str) -> TargetResolution {
3132    // The literal path and the `.md`-appended path share the same safety check
3133    // (`safe_md_target_rel` only differs by appending `.md`), so an unsafe bare
3134    // target is unsafe in both forms.
3135    if !is_safe_store_relative_path(Path::new(bare)) {
3136        return TargetResolution::Unsafe;
3137    }
3138    match resolved_target_abs(store, bare) {
3139        Some(_) => TargetResolution::Exists,
3140        None => TargetResolution::Missing,
3141    }
3142}
3143
3144/// The absolute on-disk path a bare wiki-link / index-entry target resolves to,
3145/// trying the literal path first, then `.md`-appended — mirroring the graph
3146/// engine. `None` when neither exists, or when the bare target escapes the store
3147/// (callers that need to distinguish unsafe from merely-missing use
3148/// [`resolve_wiki_target`]).
3149///
3150/// **Existence is EXACT-CASE, deliberately platform-independent.** A db.md store
3151/// is Git-synced across machines, so a `validate --all` that passes on the
3152/// author's box must guarantee link integrity on the box that serves the store.
3153/// Bare `Path::is_file()` honors the *host* filesystem's case sensitivity: on
3154/// case-insensitive APFS/macOS (or NTFS) a wrong-case link `[[records/x/BOB]]`
3155/// resolves to the on-disk `records/x/bob.md` and passes — but on case-sensitive
3156/// Linux that file genuinely does not exist (`WIKI_LINK_BROKEN`, per SPEC.md
3157/// § Validation: "target file doesn't exist"). To stay platform-independent we
3158/// confirm not just that *a* file exists for the candidate but that its real
3159/// on-disk casing matches the requested store-relative path character-for-
3160/// character (via [`disk_case_matches`]); a case mismatch is treated as NOT
3161/// found, so macOS reports the same broken links Linux would.
3162///
3163/// NOTE on the residual validate-vs-graph divergence on macOS: the graph engine
3164/// ([`crate::graph`]) intentionally mirrors host `is_file()` + ASCII-lowercased
3165/// keys for its internal backlink/rename bookkeeping on a *single* host, so on
3166/// case-insensitive macOS `graph backlinks` will still resolve a wrong-case link
3167/// that `validate` now flags. That divergence is by design: the graph's job is
3168/// single-host consistency; `validate`'s job is cross-platform link integrity.
3169fn resolved_target_abs(store: &Store, bare: &str) -> Option<PathBuf> {
3170    if !is_safe_store_relative_path(Path::new(bare)) {
3171        return None;
3172    }
3173    // The literal path, as written (e.g. an `.eml`/`.pdf` source file kept
3174    // verbatim under `sources/`).
3175    let literal = store.root.join(bare);
3176    if literal.is_file() && disk_case_matches(store, &literal, bare) {
3177        return Some(literal);
3178    }
3179    // The `.md`-appended path (a content page referenced without its extension).
3180    let with_md_rel = format!("{bare}.md");
3181    let with_md = store.root.join(&with_md_rel);
3182    if with_md.is_file() && disk_case_matches(store, &with_md, &with_md_rel) {
3183        return Some(with_md);
3184    }
3185    None
3186}
3187
3188/// True if `abs` (already confirmed to be an existing file under `store.root`)
3189/// has the exact on-disk casing of the requested store-relative path `requested`.
3190///
3191/// Makes wiki-link existence resolution platform-independent: on case-insensitive
3192/// filesystems (APFS/macOS, NTFS) `Path::is_file()` says yes to a wrong-case
3193/// path, so we canonicalize the candidate — which returns the *real* on-disk
3194/// casing — and compare its store-relative portion to `requested`
3195/// case-sensitively. A mismatch means the file the link actually names does not
3196/// exist on a case-sensitive host, so the caller treats it as not found.
3197///
3198/// Conservative on `canonicalize` failure: if we cannot read the real path (a
3199/// transient FS error, a symlink we cannot resolve, a root that is itself a
3200/// symlink we cannot strip), we fall back to accepting the `is_file()` result
3201/// rather than producing a spurious `WIKI_LINK_BROKEN`. This keeps the check
3202/// additive — it only ever *adds* the case-mismatch detection; it never makes a
3203/// genuinely-resolvable correct-case link fail.
3204fn disk_case_matches(store: &Store, abs: &Path, requested: &str) -> bool {
3205    let Ok(canon_abs) = abs.canonicalize() else {
3206        return true; // cannot read real casing — don't invent a broken link
3207    };
3208    // Strip the store root (also canonicalized so a symlinked root still cancels)
3209    // to get the real on-disk store-relative path, then compare to what the link
3210    // asked for. `canonicalize` on the root may itself fail (e.g. the root no
3211    // longer exists by the time we probe) — be conservative there too.
3212    let Ok(canon_root) = store.root.canonicalize() else {
3213        return true;
3214    };
3215    let Ok(disk_rel) = canon_abs.strip_prefix(&canon_root) else {
3216        // The real file lives outside the (canonical) root — e.g. reached via a
3217        // symlink in the store. Containment is already enforced by
3218        // `is_safe_store_relative_path`; here we simply cannot make a
3219        // case-comparison, so don't manufacture a broken link.
3220        return true;
3221    };
3222    // Compare store-relative paths component-by-component, case-sensitively,
3223    // independent of the host's path separator and case folding.
3224    disk_rel == Path::new(requested)
3225}
3226
3227/// True if a bare target path is under `prefix` (both `.md`-stripped).
3228fn path_under_prefix(bare: &str, prefix: &str) -> bool {
3229    let prefix = prefix.trim_end_matches('/');
3230    bare == prefix || bare.starts_with(&format!("{prefix}/"))
3231}
3232
3233/// The type-folder for a store-relative content path: `<layer>/<type-folder>`
3234/// (the folder directly under the layer; date-shards roll up to it). `None` for
3235/// files directly in a layer folder or outside the two layers.
3236fn type_folder_of(rel: &Path) -> Option<PathBuf> {
3237    let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3238    if comps.len() < 3 {
3239        return None; // need layer/type-folder/file at minimum
3240    }
3241    if !matches!(comps[0], "sources" | "records") {
3242        return None;
3243    }
3244    Some(PathBuf::from(comps[0]).join(comps[1]))
3245}
3246
3247/// The layer dir a *loose* content file sits directly in (`records`/`sources`):
3248/// exactly two path components, the first a known layer. `None` for a file
3249/// inside a type-folder or outside any layer. Counterpart to the index crate's
3250/// `loose_layer_of`, kept local so `validate` needs no index internals.
3251fn loose_layer_dir(rel: &Path) -> Option<PathBuf> {
3252    let comps: Vec<&str> = rel.iter().filter_map(|s| s.to_str()).collect();
3253    if comps.len() != 2 || !matches!(comps[0], "sources" | "records") {
3254        return None;
3255    }
3256    Some(PathBuf::from(comps[0]))
3257}
3258
3259/// **SWEEP.** Walk every `.md` content file under `sources/`/`records/`,
3260/// returning store-relative paths to be parsed in full. Skips hidden dirs and
3261/// the index twin (`index.jsonl`). Used only by `validate_all`; the working-set
3262/// incoming-linker scan rides the embedded-ripgrep `Store::find_links_to_any`
3263/// (a single presence-only pass), so the loop default never walks-and-*parses*
3264/// the whole content tree.
3265///
3266/// **`log/` is NOT pruned here.** Only the *root-level* `log/` rotation archive
3267/// is reserved (`Store::is_in_log_dir` checks only the first path component);
3268/// the walk roots are the two layers, so the root archive is already out of
3269/// scope. A `log`-named folder *inside* a layer (e.g. `records/log/` — a
3270/// decision log) is real content (see `is_content_file`), so pruning every
3271/// `name == "log"` made `--all` silently skip those files — reporting fewer
3272/// errors than the default working-set scope on the same store.
3273fn walk_content_files(root: &Path) -> Vec<PathBuf> {
3274    let mut out = Vec::new();
3275    for layer in ["sources", "records"] {
3276        let base = root.join(layer);
3277        if !base.is_dir() {
3278            continue;
3279        }
3280        for entry in walkdir::WalkDir::new(&base)
3281            // Follow symlinks, matching the loop-default `md_walker`
3282            // (store.rs `follow_links(true)`): a content file that is a symlink
3283            // into the store, or that lives in a symlinked-in type-folder, is
3284            // checked by `dbmd validate` (the loop default rides `Store::walk` /
3285            // `walk_all_md`, both following symlinks). Without this the `--all`
3286            // sweep silently SKIPPED such files, so the authoritative superset
3287            // reported FEWER issues than the loop scope on the same store —
3288            // inverting the `--all`-is-the-superset contract. walkdir's loop
3289            // detection drops a symlink cycle (yields an Err that `.flatten()`
3290            // discards), so this cannot hang.
3291            .follow_links(true)
3292            .into_iter()
3293            .filter_entry(|e| {
3294                let name = e.file_name().to_str().unwrap_or("");
3295                !name.starts_with('.')
3296            })
3297            .flatten()
3298        {
3299            if !entry.file_type().is_file() {
3300                continue;
3301            }
3302            let name = entry.file_name().to_str().unwrap_or("");
3303            if name.ends_with(".md") && name != "index.md" {
3304                if let Ok(rel) = entry.path().strip_prefix(root) {
3305                    out.push(rel.to_path_buf());
3306                }
3307            }
3308        }
3309    }
3310    out.sort();
3311    out
3312}
3313
3314/// Every `index.md` under the store (root + layers + type-folders), as
3315/// store-relative paths. Used to detect orphan indexes. Like
3316/// [`walk_content_files`], a `log`-named folder *inside* a layer is real content
3317/// and its `index.md` is not pruned (only the root-level `log/` archive is
3318/// reserved, and the walk roots are the two layers, so it is already
3319/// out of scope).
3320fn walk_index_files(root: &Path) -> Vec<PathBuf> {
3321    let mut out = Vec::new();
3322    if root.join("index.md").is_file() {
3323        out.push(PathBuf::from("index.md"));
3324    }
3325    for layer in ["sources", "records"] {
3326        let base = root.join(layer);
3327        if !base.is_dir() {
3328            continue;
3329        }
3330        for entry in walkdir::WalkDir::new(&base)
3331            // Follow symlinks, matching the loop-default `md_walker`
3332            // (store.rs `follow_links(true)`): a content file that is a symlink
3333            // into the store, or that lives in a symlinked-in type-folder, is
3334            // checked by `dbmd validate` (the loop default rides `Store::walk` /
3335            // `walk_all_md`, both following symlinks). Without this the `--all`
3336            // sweep silently SKIPPED such files, so the authoritative superset
3337            // reported FEWER issues than the loop scope on the same store —
3338            // inverting the `--all`-is-the-superset contract. walkdir's loop
3339            // detection drops a symlink cycle (yields an Err that `.flatten()`
3340            // discards), so this cannot hang.
3341            .follow_links(true)
3342            .into_iter()
3343            .filter_entry(|e| {
3344                let name = e.file_name().to_str().unwrap_or("");
3345                !name.starts_with('.')
3346            })
3347            .flatten()
3348        {
3349            if entry.file_type().is_file() && entry.file_name().to_str() == Some("index.md") {
3350                if let Ok(rel) = entry.path().strip_prefix(root) {
3351                    out.push(rel.to_path_buf());
3352                }
3353            }
3354        }
3355    }
3356    out.sort();
3357    out
3358}
3359
3360/// A parsed `index.md` entry line: the wiki-link target, the optional summary
3361/// text after the `—`, and the 1-based line number.
3362struct IndexEntry {
3363    target: String,
3364    summary_text: Option<String>,
3365    line: u32,
3366}
3367
3368/// Parse the `- [[<path>]] — <summary>` entry lines of an `index.md`. Stops at a
3369/// `## More` footer (those lines aren't file entries). Root/layer entries with a
3370/// `|display` segment and a `(N)` count are parsed too — the target is the bare
3371/// path, the summary text is whatever follows the em dash.
3372fn parse_index_entries(text: &str) -> Vec<IndexEntry> {
3373    let mut out = Vec::new();
3374    let mut in_more = false;
3375    for (idx, line) in text.lines().enumerate() {
3376        let trimmed = line.trim_start();
3377        if trimmed.starts_with("## More") {
3378            in_more = true;
3379            continue;
3380        }
3381        if in_more {
3382            continue;
3383        }
3384        if !trimmed.starts_with("- ") {
3385            continue;
3386        }
3387        // Find the first `[[...]]`.
3388        let Some(open) = trimmed.find("[[") else {
3389            continue;
3390        };
3391        let Some(close_rel) = trimmed[open + 2..].find("]]") else {
3392            continue;
3393        };
3394        let inner = &trimmed[open + 2..open + 2 + close_rel];
3395        let target = inner.split('|').next().unwrap_or(inner).trim().to_string();
3396
3397        // Summary text: whatever follows the first em dash (`—`) or ` - `.
3398        let after = &trimmed[open + 2 + close_rel + 2..];
3399        let summary_text = extract_index_entry_summary(after);
3400
3401        out.push(IndexEntry {
3402            target,
3403            summary_text,
3404            line: (idx + 1) as u32,
3405        });
3406    }
3407    out
3408}
3409
3410/// Pull the summary portion out of the text trailing an index entry's
3411/// wiki-link: drop a leading `(N files)` count, then the `—`/`-` separator, then
3412/// strip a trailing `  ·  #tag` suffix **only when it is a genuine tag block**
3413/// (so a literal `·` inside the summary text is preserved, not mistaken for the
3414/// renderer's tag separator).
3415fn extract_index_entry_summary(after: &str) -> Option<String> {
3416    let mut s = after.trim();
3417    // Drop a leading "(N ...)" count segment, if present.
3418    if s.starts_with('(') {
3419        if let Some(close) = s.find(')') {
3420            s = s[close + 1..].trim_start();
3421        }
3422    }
3423    // Require an em dash or hyphen separator before the summary.
3424    let s = if let Some(rest) = s.strip_prefix('—') {
3425        rest.trim()
3426    } else if let Some(rest) = s.strip_prefix('-') {
3427        rest.trim()
3428    } else {
3429        return None;
3430    };
3431    if s.is_empty() {
3432        return None;
3433    }
3434    // Strip a trailing tag block — but ONLY when it matches the EXACT delimiter
3435    // the renderer emits: `  ·  #tag #tag` (a *double*-spaced middot, per
3436    // `crate::index::format_md_entry`'s `format!("  ·  {tags}")`), dropped when
3437    // the file has no tags. The previous code also accepted a *single*-spaced
3438    // ` · ` separator, which collided with a legal summary whose own text ends
3439    // in a single-spaced middot-plus-hashtag tail — e.g. a tagless file with
3440    // `summary: "Standup notes · #standup"`. The renderer round-trips that
3441    // summary verbatim (no tag block, since there are no tags), but the loose
3442    // strip mistook the ` · #standup` for the renderer's tag suffix, compared
3443    // `"Standup notes"` against the file's full summary, and emitted a spurious
3444    // `INDEX_SUMMARY_MISMATCH` that `dbmd index rebuild` could never fix
3445    // (rebuild regenerates the identical line). Matching the renderer's exact
3446    // double-spaced delimiter makes the comparison round-trip. `rsplit_once`
3447    // matches from the right so only the real trailing tag block is considered.
3448    let s = match s.rsplit_once("  ·  ") {
3449        Some((summary, tags)) if is_tag_suffix(tags) => summary.trim(),
3450        _ => s,
3451    };
3452    Some(s.to_string())
3453}
3454
3455/// True if `s` is a non-empty tag block: one or more whitespace-separated tokens
3456/// each starting with `#`, the exact shape the index renderer appends after the
3457/// `·` separator (`crate::index::format_md_entry`). Used to distinguish the
3458/// renderer's `  ·  #tag` suffix from a literal `·` inside the summary text.
3459fn is_tag_suffix(s: &str) -> bool {
3460    let mut any = false;
3461    for tok in s.split_whitespace() {
3462        if !tok.starts_with('#') || tok.len() < 2 {
3463            return false;
3464        }
3465        any = true;
3466    }
3467    any
3468}
3469
3470/// Parse a `log.md` entry header `## [YYYY-MM-DD HH:MM] <kind> | <object>`.
3471/// Returns `(timestamp, kind, object)`; `None` if the timestamp is unparseable
3472/// or the header isn't well-formed.
3473fn parse_log_header(line: &str) -> Option<(DateTime<FixedOffset>, String, Option<String>)> {
3474    let rest = line.strip_prefix("## [")?;
3475    let close = rest.find(']')?;
3476    let ts_str = &rest[..close];
3477    let tail = rest[close + 1..].trim();
3478
3479    // Parse `YYYY-MM-DD HH:MM` (the SPEC header form) as a naive local time and
3480    // attach a zero offset — the log header carries minute precision, no zone.
3481    let naive = NaiveDateTime::parse_from_str(ts_str.trim(), "%Y-%m-%d %H:%M").ok()?;
3482    let offset = FixedOffset::east_opt(0)?;
3483    let ts = naive.and_local_timezone(offset).single()?;
3484
3485    // kind | object
3486    let (kind, object) = match tail.split_once('|') {
3487        Some((k, o)) => {
3488            let o = o.trim();
3489            (
3490                k.trim().to_string(),
3491                if o.is_empty() {
3492                    None
3493                } else {
3494                    Some(o.to_string())
3495                },
3496            )
3497        }
3498        None => (tail.to_string(), None),
3499    };
3500    if kind.is_empty() {
3501        return None;
3502    }
3503    Some((ts, kind, object))
3504}
3505
3506/// Every log file that holds entries for the working-set scan: the active
3507/// `log.md` plus every `log/<YYYY-MM>.md` archive. [`Log::append`] rotates
3508/// strictly-prior-month entries into the archives, so the active file alone is
3509/// NOT the full timeline — both the last `validate` cutoff and a changed-but-
3510/// unvalidated object can live in an archive after a month rollover. Reading the
3511/// archives here keeps the working-set readers in sync with the rest of the log
3512/// layer (`Log::since`/`Log::tail`), which deliberately cross archives, and
3513/// prevents `dbmd validate` from silently skipping archived changed files. Reads
3514/// only log headers, never the content store, so the loop budget is preserved.
3515fn log_files_for_working_set(store: &Store) -> Vec<PathBuf> {
3516    let mut files = vec![store.root.join("log.md")];
3517    let archive_dir = store.root.join("log");
3518    if let Ok(entries) = std::fs::read_dir(&archive_dir) {
3519        let mut archives: Vec<PathBuf> = entries
3520            .flatten()
3521            .map(|e| e.path())
3522            .filter(|p| {
3523                p.is_file()
3524                    && p.file_name()
3525                        .and_then(|s| s.to_str())
3526                        .and_then(|n| n.strip_suffix(".md"))
3527                        .is_some_and(is_year_month_archive)
3528            })
3529            .collect();
3530        // Deterministic order (oldest month first); the callers fold across all
3531        // files so order doesn't affect the result, but a stable order keeps the
3532        // scan reproducible.
3533        archives.sort();
3534        files.extend(archives);
3535    }
3536    files
3537}
3538
3539/// True if `s` looks like a `YYYY-MM` archive stem (4 digits, `-`, 2 digits) —
3540/// the `log/<YYYY-MM>.md` naming the rotation in [`crate::log`] emits.
3541fn is_year_month_archive(s: &str) -> bool {
3542    let b = s.as_bytes();
3543    b.len() == 7
3544        && b[..4].iter().all(u8::is_ascii_digit)
3545        && b[4] == b'-'
3546        && b[5..7].iter().all(u8::is_ascii_digit)
3547}
3548
3549/// The timestamp of the most recent `validate` entry across the active `log.md`
3550/// **and** the `log/<YYYY-MM>.md` archives — the default working-set cutoff.
3551/// Reads only headers; never the whole store. Archive-aware so a `validate`
3552/// entry that rotated into an archive after a month rollover still anchors the
3553/// cutoff (without this, the cutoff silently resets to `None`).
3554fn last_validate_at(store: &Store) -> Option<DateTime<FixedOffset>> {
3555    let mut latest: Option<DateTime<FixedOffset>> = None;
3556    for file in log_files_for_working_set(store) {
3557        let Ok(text) = std::fs::read_to_string(&file) else {
3558            continue;
3559        };
3560        for line in text.lines() {
3561            if !line.starts_with("## [") {
3562                continue;
3563            }
3564            if let Some((ts, kind, _)) = parse_log_header(line) {
3565                if kind == "validate" {
3566                    latest = Some(match latest {
3567                        Some(p) if p >= ts => p,
3568                        _ => ts,
3569                    });
3570                }
3571            }
3572        }
3573    }
3574    latest
3575}
3576
3577/// The set of content objects changed since `cutoff`, read from log entries
3578/// whose kind mutates a file. When `cutoff` is `None`, every mutating entry
3579/// counts (no prior validate window). Returns store-relative `.md` paths.
3580///
3581/// Scans the active `log.md` **and** every `log/<YYYY-MM>.md` archive: after a
3582/// month rollover [`Log::append`] rotates prior-month entries out of the active
3583/// file, so an object changed-but-never-validated in a prior month lives only in
3584/// an archive. Reading the archives here is what keeps `dbmd validate` from
3585/// silently skipping those files. Reads only log headers, never the content
3586/// store.
3587fn changed_objects_since(
3588    store: &Store,
3589    cutoff: Option<DateTime<FixedOffset>>,
3590) -> BTreeSet<PathBuf> {
3591    let mut out = BTreeSet::new();
3592    for file in log_files_for_working_set(store) {
3593        let Ok(text) = std::fs::read_to_string(&file) else {
3594            continue;
3595        };
3596        for line in text.lines() {
3597            if !line.starts_with("## [") {
3598                continue;
3599            }
3600            let Some((ts, kind, object)) = parse_log_header(line) else {
3601                continue;
3602            };
3603            if let Some(c) = cutoff {
3604                if ts < c {
3605                    continue;
3606                }
3607            }
3608            if !matches!(
3609                kind.as_str(),
3610                "create" | "update" | "ingest" | "rename" | "delete" | "link"
3611            ) {
3612                continue;
3613            }
3614            if let Some(obj) = object {
3615                // The object slot is a store-relative path (or a wiki-link target).
3616                let bare = obj
3617                    .trim()
3618                    .trim_start_matches("[[")
3619                    .trim_end_matches("]]")
3620                    .split('|')
3621                    .next()
3622                    .unwrap_or("")
3623                    .trim()
3624                    .trim_end_matches(".md")
3625                    .to_string();
3626                if bare.is_empty() {
3627                    continue;
3628                }
3629                // Containment: the object slot is a log-header field that can
3630                // carry a `..`/absolute/prefix path (a hand-edited or
3631                // merge-malformed log line). Route it through the same safety gate
3632                // every other disk-touching validator path uses
3633                // (`safe_md_target_rel`, which `link_target_type` already applies)
3634                // so a `records/../../leaky` object cannot make
3635                // `validate_working_set` read + frontmatter-report on a file
3636                // OUTSIDE the store root. An unsafe object is dropped from the
3637                // changed set rather than probed.
3638                if let Some(rel) = safe_md_target_rel(&bare) {
3639                    out.insert(rel);
3640                }
3641            }
3642        }
3643    }
3644    out
3645}
3646
3647/// The result of the [`derived_from_ignored_type`] policy check: the
3648/// `derived_from` target that resolves to an ignored-type record, plus that
3649/// record's type. Carries exactly what both the validate finding and the
3650/// write-time warning need to render their message.
3651#[derive(Debug, Clone, PartialEq, Eq)]
3652pub struct DerivedFromIgnored {
3653    /// The `derived_from` wiki-link target as written (bare store-relative path,
3654    /// no `.md`).
3655    pub target: String,
3656    /// The resolved `type` of that target, which is present in
3657    /// `store.config.ignored_types`.
3658    pub target_type: String,
3659}
3660
3661/// **The single authoritative `### Ignored types` derivation check.** Decides
3662/// whether a conclusion record derives from an ignored-type record: the
3663/// `meta-type` must be `conclusion`, `### Ignored types` must be non-empty, and
3664/// some `derived_from` target must resolve to a record whose `type` is in
3665/// `ignored_types`. Returns the first such target (and its type), or `None`.
3666///
3667/// Both surfaces call this so the policy lives in exactly one place:
3668/// [`check_content_file`] (read side — `dbmd validate`) feeds it the
3669/// `derived_from` targets it scanned from the raw frontmatter, and the write
3670/// surface (`dbmd write`) feeds it the targets from the composed frontmatter.
3671/// The link *extraction* differs per surface (text-scan with line numbers vs.
3672/// the parsed `Frontmatter`); the *decision* — type gate, target-type
3673/// resolution, and `ignored_types` membership — does not.
3674pub fn derived_from_ignored_type<I, S>(
3675    store: &Store,
3676    meta_type: &str,
3677    derived_from_targets: I,
3678) -> Option<DerivedFromIgnored>
3679where
3680    I: IntoIterator<Item = S>,
3681    S: AsRef<str>,
3682{
3683    if meta_type != "conclusion" || store.config.ignored_types.is_empty() {
3684        return None;
3685    }
3686    for target in derived_from_targets {
3687        let target = target.as_ref();
3688        if let Some(target_type) = link_target_type(store, target) {
3689            if store.config.ignored_types.contains(&target_type) {
3690                return Some(DerivedFromIgnored {
3691                    target: target.to_string(),
3692                    target_type,
3693                });
3694            }
3695        }
3696    }
3697    None
3698}
3699
3700/// Resolve the `type` of a wiki-link target file (bare, no `.md`), or `None`.
3701fn link_target_type(store: &Store, target: &str) -> Option<String> {
3702    let bare = target.trim_end_matches(".md");
3703    let abs = store.root.join(safe_md_target_rel(bare)?);
3704    let text = std::fs::read_to_string(&abs).ok()?;
3705    let (yaml, _, _) = split_frontmatter(&text)?;
3706    let value: Value = serde_norway::from_str(&yaml).ok()?;
3707    if let Value::Mapping(m) = value {
3708        m.get(Value::String("type".into())).and_then(scalar_string)
3709    } else {
3710        None
3711    }
3712}
3713
3714// ── Shape validators ─────────────────────────────────────────────────────────
3715
3716/// True if a string is RFC3339 / ISO-8601 with a time + zone (the
3717/// `created`/`updated` contract: `2026-05-27T08:00:00-07:00`).
3718fn is_iso8601(s: &str) -> bool {
3719    DateTime::parse_from_rfc3339(s.trim()).is_ok()
3720}
3721
3722/// True if a string is an ISO-8601 *date* (`2026-05-27`) or a full RFC3339
3723/// datetime. Type-specific date fields (`expense.date`, `contact.last_touch`)
3724/// accept the date-only form per the SPEC's worked example.
3725fn is_iso8601_date_or_datetime(s: &str) -> bool {
3726    let s = s.trim();
3727    if DateTime::parse_from_rfc3339(s).is_ok() {
3728        return true;
3729    }
3730    chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok()
3731}
3732
3733/// True for `<local>@<domain>` with a non-empty local part and a dotted domain.
3734/// There must be exactly one `@`: a domain that still contains an `@` after the
3735/// split (the common double-`@` typo `sarah@@acme.com`, or `a@b@c.com`) is
3736/// rejected — without this the domain `@acme.com` passed every other check.
3737fn is_email(s: &str) -> bool {
3738    let s = s.trim();
3739    let Some((local, domain)) = s.split_once('@') else {
3740        return false;
3741    };
3742    !local.is_empty()
3743        && !domain.contains('@')
3744        && domain.contains('.')
3745        && !domain.starts_with('.')
3746        && !domain.ends_with('.')
3747        && !domain.contains(' ')
3748        && !local.contains(' ')
3749}
3750
3751/// True for a currency amount: an optional symbol or 3-letter ISO code, then a
3752/// plain decimal number with optional thousands separators and ≤ 2 decimals.
3753///
3754/// The numeric part is validated by hand (not `f64::parse`) so the non-numeric
3755/// floats `f64` accepts — `inf`, `-inf`, `NaN`, and `1e3`-style exponents — are
3756/// rejected, and the ≤ 2-decimal rule is actually enforced.
3757fn is_currency(s: &str) -> bool {
3758    let mut t = s.trim();
3759    // Strip a leading currency symbol …
3760    for sym in ["$", "€", "£", "¥"] {
3761        if let Some(rest) = t.strip_prefix(sym) {
3762            t = rest.trim_start();
3763            break;
3764        }
3765    }
3766    // … or a leading 3-letter ISO-4217-ish code (`USD 100`, `EUR 9.50`). The
3767    // code must be exactly three ASCII letters and separated from the number by
3768    // whitespace, so a bare `USD` with no amount still fails.
3769    if let Some((head, rest)) = t.split_once(char::is_whitespace) {
3770        if head.len() == 3 && head.chars().all(|c| c.is_ascii_alphabetic()) {
3771            t = rest.trim_start();
3772        }
3773    }
3774
3775    let cleaned: String = t.chars().filter(|c| *c != ',').collect();
3776    is_plain_amount(cleaned.trim())
3777}
3778
3779/// True for a bare decimal amount: optional sign, ≥ 1 digit, an optional
3780/// fractional part of 1–2 digits. No exponents, no `inf`/`NaN`, no empty string.
3781fn is_plain_amount(s: &str) -> bool {
3782    let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
3783    let (int_part, frac_part) = match digits.split_once('.') {
3784        Some((i, f)) => (i, Some(f)),
3785        None => (digits, None),
3786    };
3787    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
3788        return false;
3789    }
3790    match frac_part {
3791        None => true,
3792        Some(f) => (1..=2).contains(&f.len()) && f.bytes().all(|b| b.is_ascii_digit()),
3793    }
3794}
3795
3796/// True for an http(s) URL: a recognized scheme prefix with at least one
3797/// character after it. The length guard uses the *matched* scheme's own length,
3798/// so a single-character host on the shorter `http://` scheme (`http://x`, 8
3799/// bytes — e.g. an intranet/container hostname) is accepted; a bare scheme with
3800/// nothing after it (`http://`, `https://`) is rejected.
3801fn is_url(s: &str) -> bool {
3802    let s = s.trim();
3803    for scheme in ["http://", "https://"] {
3804        if let Some(rest) = s.strip_prefix(scheme) {
3805            return !rest.is_empty();
3806        }
3807    }
3808    false
3809}
3810
3811/// A short, deterministic suggestion for a `SCHEMA_SHAPE_MISMATCH`.
3812fn shape_suggestion(shape: Shape) -> String {
3813    match shape {
3814        Shape::String => "use a scalar string".into(),
3815        Shape::Int => "use an integer".into(),
3816        Shape::Bool => "use `true` or `false`".into(),
3817        Shape::Date => "use an ISO-8601 date, e.g. 2026-05-27".into(),
3818        Shape::Email => "use a `<local>@<domain>` address".into(),
3819        Shape::Currency => "use a numeric amount, e.g. 1234.56".into(),
3820        Shape::Url => "use an http(s) URL".into(),
3821    }
3822}
3823
3824/// Suggest a full-path rewrite for a short-form wiki-link. Without the layer we
3825/// can't know the folder, so the suggestion is generic but actionable.
3826fn short_form_suggestion(bare: &str) -> Option<String> {
3827    Some(format!(
3828        "use a full store-relative path, e.g. [[records/contacts/{}]]",
3829        slugish(bare)
3830    ))
3831}
3832
3833/// A filesystem-ish leaf for a plain string (lowercase, spaces → hyphens).
3834fn slugish(s: &str) -> String {
3835    s.trim()
3836        .to_lowercase()
3837        .chars()
3838        .map(|c| if c.is_whitespace() { '-' } else { c })
3839        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '/' || *c == '_')
3840        .collect()
3841}
3842
3843/// Cross-file asset-manifest integrity (the `--all` sweep). Text-only: it never
3844/// hashes a byte or reads an asset file's contents — byte presence and hash
3845/// correctness are `dbmd assets verify`, not `validate`, so a fresh clone with
3846/// no restored bytes still passes. Cross-checks `assets.jsonl` against every
3847/// content file's `asset`/`assets` declarations.
3848fn check_assets(store: &Store, parsed: &[(PathBuf, Parsed)], issues: &mut Vec<Issue>) {
3849    use crate::assets;
3850
3851    let manifest_rel = Path::new(assets::MANIFEST_FILE);
3852    let manifest_abs = store.root.join(assets::MANIFEST_FILE);
3853
3854    // Lenient manifest read: a malformed line is reported, not fatal.
3855    let mut manifest: BTreeMap<String, assets::AssetRecord> = BTreeMap::new();
3856    if let Ok(text) = std::fs::read_to_string(&manifest_abs) {
3857        for (i, line) in text.lines().enumerate() {
3858            if line.trim().is_empty() {
3859                continue;
3860            }
3861            match serde_json::from_str::<assets::AssetRecord>(line) {
3862                Ok(rec) => {
3863                    manifest.insert(rec.path.clone(), rec);
3864                }
3865                Err(e) => push(
3866                    issues,
3867                    Severity::Error,
3868                    codes::ASSET_MANIFEST_MALFORMED,
3869                    manifest_rel,
3870                    Some((i as u32) + 1),
3871                    None,
3872                    format!("invalid {} record: {e}", assets::MANIFEST_FILE),
3873                    Some("run `dbmd assets scan` to rebuild the manifest".to_string()),
3874                    vec![],
3875                ),
3876            }
3877        }
3878    }
3879
3880    // Per-wrapper declarations: every declared asset must be in the manifest and
3881    // must not point at a markdown content file.
3882    let mut declared: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3883    for (rel, p) in parsed {
3884        let Some(map) = &p.fm else {
3885            continue;
3886        };
3887        for decl in assets::declarations_from_yaml_map(map) {
3888            let norm = match assets::normalize_asset_path(&decl.path) {
3889                Ok(n) => n,
3890                Err(_) => continue, // a bad declared path is surfaced by `scan`, not here
3891            };
3892            declared.insert(norm.clone());
3893            let is_md = Path::new(&norm)
3894                .extension()
3895                .and_then(|e| e.to_str())
3896                .map(|e| e.eq_ignore_ascii_case("md"))
3897                .unwrap_or(false);
3898            if is_md {
3899                push(
3900                    issues,
3901                    Severity::Warning,
3902                    codes::ASSET_PATH_IS_CONTENT,
3903                    rel,
3904                    None,
3905                    Some("asset".to_string()),
3906                    format!("asset path `{norm}` points at a markdown content file"),
3907                    Some("assets are raw binaries; reference a non-markdown path".to_string()),
3908                    vec![PathBuf::from(&norm)],
3909                );
3910            }
3911            if !manifest.contains_key(&norm) {
3912                push(
3913                    issues,
3914                    Severity::Error,
3915                    codes::ASSET_UNDECLARED,
3916                    rel,
3917                    None,
3918                    Some("asset".to_string()),
3919                    format!(
3920                        "references asset `{norm}` with no record in {}",
3921                        assets::MANIFEST_FILE
3922                    ),
3923                    Some("run `dbmd assets scan` to catalog it".to_string()),
3924                    vec![PathBuf::from(&norm)],
3925                );
3926            }
3927        }
3928    }
3929
3930    // Per-record: wrapper existence + orphan detection.
3931    for (path, rec) in &manifest {
3932        for w in &rec.wrappers {
3933            if !store.root.join(w).is_file() {
3934                push(
3935                    issues,
3936                    Severity::Error,
3937                    codes::ASSET_WRAPPER_BROKEN,
3938                    Path::new(path),
3939                    None,
3940                    None,
3941                    format!("manifest record for `{path}` names a missing wrapper `{w}`"),
3942                    Some("run `dbmd assets scan` to reconcile the manifest".to_string()),
3943                    vec![PathBuf::from(w)],
3944                );
3945            }
3946        }
3947        if !declared.contains(path) {
3948            push(
3949                issues,
3950                Severity::Warning,
3951                codes::ASSET_MANIFEST_ORPHAN,
3952                Path::new(path),
3953                None,
3954                None,
3955                format!(
3956                    "`{path}` is in {} but no wrapper references it",
3957                    assets::MANIFEST_FILE
3958                ),
3959                Some("run `dbmd assets scan` to drop the orphan, or add a wrapper".to_string()),
3960                vec![],
3961            );
3962        }
3963    }
3964}
3965
3966/// Push a fully-formed [`Issue`].
3967#[allow(clippy::too_many_arguments)]
3968fn push(
3969    issues: &mut Vec<Issue>,
3970    severity: Severity,
3971    code: &'static str,
3972    file: &Path,
3973    line: Option<u32>,
3974    key: Option<String>,
3975    message: String,
3976    suggestion: Option<String>,
3977    related: Vec<PathBuf>,
3978) {
3979    issues.push(Issue {
3980        severity,
3981        code,
3982        file: file.to_path_buf(),
3983        line,
3984        key,
3985        message,
3986        suggestion,
3987        related,
3988    });
3989}
3990
3991/// 1-based line of a top-level frontmatter key inside the YAML block, offset to
3992/// the file (the YAML starts at file line 2). `None` if not found.
3993fn fm_key_line(fm_yaml: &str, key: &str) -> Option<u32> {
3994    for (i, line) in fm_yaml.lines().enumerate() {
3995        let trimmed = line.trim_start();
3996        // A top-level key line: `key:` with no leading list dash.
3997        if let Some(rest) = trimmed.strip_prefix(key) {
3998            if rest.starts_with(':') && line.starts_with(key) {
3999                // +2: file line 1 is the opening `---`, YAML line 0 → file line 2.
4000                return Some((i as u32) + 2);
4001            }
4002        }
4003    }
4004    None
4005}
4006
4007/// The line a *field-absence* issue (a required key that is missing entirely)
4008/// anchors to: the key's line when present, else line `1` — the frontmatter
4009/// block's opening `---`. A missing key has no line of its own; anchoring it to
4010/// the block top gives the agent (and the `EXPECTED` golden) a stable, non-null
4011/// line to point at instead of an unhelpful `null`.
4012fn fm_key_line_or_top(fm_yaml: &str, key: &str) -> Option<u32> {
4013    fm_key_line(fm_yaml, key).or(Some(1))
4014}
4015
4016/// A stable sort order for issues: by file, then line, then code. Keeps `--json`
4017/// output deterministic across runs.
4018fn issue_order(a: &Issue, b: &Issue) -> std::cmp::Ordering {
4019    a.file
4020        .cmp(&b.file)
4021        .then(a.line.cmp(&b.line))
4022        .then(a.code.cmp(b.code))
4023        .then(a.key.cmp(&b.key))
4024}
4025
4026// ═════════════════════════════════════════════════════════════════════════════
4027//  Tests
4028// ═════════════════════════════════════════════════════════════════════════════
4029
4030#[cfg(test)]
4031mod tests {
4032    use super::*;
4033    use crate::parser::{Config, FieldSpec};
4034    use std::fs;
4035    use tempfile::TempDir;
4036
4037    #[test]
4038    fn split_frontmatter_tolerates_leading_bom() {
4039        // Regression (finding #19 cross-module): a UTF-8 BOM before the opening
4040        // fence must not make validate treat the file as frontmatter-less while
4041        // the catalog indexes it. Pre-fix `first.trim_end() != "---"` was true
4042        // for `\u{feff}---` and the function returned None.
4043        let text = "\u{feff}---\ntype: contact\nsummary: hi\n---\nbody\n";
4044        let parsed = split_frontmatter(text);
4045        assert!(
4046            parsed.is_some(),
4047            "a leading BOM must not hide frontmatter from validate"
4048        );
4049        let (yaml, body, close_line) = parsed.unwrap();
4050        assert_eq!(yaml, "type: contact\nsummary: hi\n");
4051        assert_eq!(body, "body");
4052        assert_eq!(close_line, 4, "BOM is inline on line 1, not a new line");
4053    }
4054
4055    /// A test store builder over a real tempdir. Every helper writes real files
4056    /// so the assertions exercise real behavior, not mocks.
4057    struct Fixture {
4058        dir: TempDir,
4059        config: Config,
4060    }
4061
4062    impl Fixture {
4063        /// A fresh store with a **valid** `DB.md` (the identity contract:
4064        /// `type: db-md` + `scope` + `owner`) and the two layer dirs. A valid
4065        /// DB.md keeps `check_db_md` silent so a "clean store" fixture is truly
4066        /// clean; tests that want a broken DB.md write their own via `write`.
4067        fn new() -> Self {
4068            let dir = TempDir::new().unwrap();
4069            fs::write(
4070                dir.path().join("DB.md"),
4071                "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
4072            )
4073            .unwrap();
4074            for layer in ["sources", "records"] {
4075                fs::create_dir_all(dir.path().join(layer)).unwrap();
4076            }
4077            Fixture {
4078                dir,
4079                config: Config::default(),
4080            }
4081        }
4082
4083        /// A store with no `DB.md` marker.
4084        fn bare() -> Self {
4085            let dir = TempDir::new().unwrap();
4086            Fixture {
4087                dir,
4088                config: Config::default(),
4089            }
4090        }
4091
4092        /// Write a file at a store-relative path, creating parent dirs.
4093        fn write(&self, rel: &str, contents: &str) {
4094            let abs = self.dir.path().join(rel);
4095            fs::create_dir_all(abs.parent().unwrap()).unwrap();
4096            fs::write(abs, contents).unwrap();
4097        }
4098
4099        fn store(&self) -> Store {
4100            Store {
4101                root: self.dir.path().to_path_buf(),
4102                config: self.config.clone(),
4103            }
4104        }
4105
4106        fn store_all(&self) -> Vec<Issue> {
4107            validate_all(&self.store()).unwrap()
4108        }
4109
4110        /// Write the canonical `index.md` + `index.jsonl` at every level via the
4111        /// real builder ([`crate::index::Index::rebuild_all`]) — the same
4112        /// projection a `dbmd index rebuild` produces. Use this (rather than a
4113        /// hand-typed sidecar line) whenever a test asserts a *clean* store, so
4114        /// the sidecar carries the COMPLETE per-field projection and the fixture
4115        /// can't silently drift from what the index writer emits.
4116        fn rebuild_indexes(&self) {
4117            crate::index::Index::rebuild_all(&self.store()).unwrap();
4118        }
4119    }
4120
4121    /// True if any issue has this code.
4122    fn has(issues: &[Issue], code: &str) -> bool {
4123        issues.iter().any(|i| i.code == code)
4124    }
4125
4126    /// Count issues with a code.
4127    fn count(issues: &[Issue], code: &str) -> usize {
4128        issues.iter().filter(|i| i.code == code).count()
4129    }
4130
4131    /// The first issue with a code, or panic.
4132    fn find<'a>(issues: &'a [Issue], code: &str) -> &'a Issue {
4133        issues
4134            .iter()
4135            .find(|i| i.code == code)
4136            .unwrap_or_else(|| panic!("expected an issue with code {code}; got {issues:#?}"))
4137    }
4138
4139    /// A minimal valid `contact` body for reuse.
4140    fn valid_contact(summary: &str) -> String {
4141        format!(
4142            "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"{summary}\"\nname: A\n---\n\n# A\n"
4143        )
4144    }
4145
4146    // ── store marker ──────────────────────────────────────────────────────────
4147
4148    #[test]
4149    fn not_a_store_when_db_md_absent() {
4150        let fx = Fixture::bare();
4151        let issues = fx.store_all();
4152        assert_eq!(issues.len(), 1, "only NOT_A_STORE expected: {issues:#?}");
4153        assert_eq!(issues[0].code, codes::NOT_A_STORE);
4154        assert!(issues[0].is_error());
4155    }
4156
4157    #[test]
4158    fn working_set_also_reports_not_a_store() {
4159        let fx = Fixture::bare();
4160        let issues = validate_working_set(&fx.store(), None).unwrap();
4161        assert!(has(&issues, codes::NOT_A_STORE));
4162    }
4163
4164    #[test]
4165    fn clean_store_has_no_issues() {
4166        let fx = Fixture::new();
4167        fx.write("records/contacts/a.md", &valid_contact("A contact"));
4168        // Build the canonical indexes (complete per-field jsonl included) the
4169        // same way `dbmd index rebuild` does, so a freshly-rebuilt store is
4170        // proven clean across every projected field, not just summary/type.
4171        fx.rebuild_indexes();
4172        let issues = fx.store_all();
4173        assert!(
4174            issues.is_empty(),
4175            "expected a clean store, got: {issues:#?}"
4176        );
4177    }
4178
4179    // ── meta-type closed enum ─────────────────────────────────────────────────
4180
4181    /// Regression (adversarial review): a NON-SCALAR `meta-type` (a YAML list or
4182    /// mapping) must be rejected with `FM_BAD_META_TYPE`, not silently slip past
4183    /// the enum check (and then get reclassified as the default `fact`). Pre-fix
4184    /// the check was gated on `and_then(scalar_string)`, which returned `None`
4185    /// for a sequence/mapping and short-circuited the whole branch.
4186    #[test]
4187    fn meta_type_enum_is_closed_for_scalars_and_non_scalars() {
4188        let fx = Fixture::new();
4189        let body = |mt: &str| {
4190            format!(
4191                "---\ntype: profile\nmeta-type: {mt}\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n"
4192            )
4193        };
4194
4195        // Valid enum members + absent (default fact) → no FM_BAD_META_TYPE.
4196        for ok in ["fact", "operational", "conclusion"] {
4197            fx.write("records/profiles/ok.md", &body(ok));
4198            let issues = validate_working_set(&fx.store(), None).unwrap();
4199            assert!(
4200                !has(&issues, codes::FM_BAD_META_TYPE),
4201                "`meta-type: {ok}` must be accepted; got {issues:#?}"
4202            );
4203        }
4204        fx.write(
4205            "records/profiles/absent.md",
4206            "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n",
4207        );
4208        assert!(
4209            !has(
4210                &validate_working_set(&fx.store(), None).unwrap(),
4211                codes::FM_BAD_META_TYPE
4212            ),
4213            "an absent meta-type is the default `fact` and must be accepted"
4214        );
4215
4216        // Scalar-but-wrong, AND non-scalar (list / mapping) → FM_BAD_META_TYPE.
4217        for bad in ["xyz", "Fact", "[fact, conclusion]", "{kind: conclusion}"] {
4218            let fx2 = Fixture::new();
4219            fx2.write("records/profiles/bad.md", &body(bad));
4220            let issues = validate_working_set(&fx2.store(), None).unwrap();
4221            assert!(
4222                has(&issues, codes::FM_BAD_META_TYPE),
4223                "`meta-type: {bad}` must be rejected with FM_BAD_META_TYPE; got {issues:#?}"
4224            );
4225        }
4226    }
4227
4228    // ── id: recommended + opaque; FM_BAD_ID is structural only (v0.4) ────────
4229
4230    /// The additive-v0.4 guarantee, pinned: an ABSENT id, a hand-authored
4231    /// opaque slug id (legal since v0.3 and present in the shipped examples),
4232    /// a minted lowercase ULID, and a numeric scalar are ALL silent. The
4233    /// recommended ULID form is never a validation gate — a check that flags
4234    /// `id: sarah-chen` would retroactively dirty every v0.3 store and break
4235    /// the "v0.3 validates unchanged under v0.4" contract.
4236    #[test]
4237    fn id_absent_slug_ulid_and_numeric_are_all_silent() {
4238        let body = |id_line: &str| {
4239            format!(
4240                "---\ntype: contact\n{id_line}created: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n"
4241            )
4242        };
4243        for (case, id_line) in [
4244            ("absent", ""),
4245            ("slug", "id: sarah-chen\n"),
4246            ("ulid", "id: 01j5qc3v9k4ym8rwbn2tqe6f7d\n"),
4247            ("numeric-scalar", "id: 100\n"),
4248        ] {
4249            let fx = Fixture::new();
4250            fx.write("records/contacts/a.md", &body(id_line));
4251            let issues = validate_working_set(&fx.store(), None).unwrap();
4252            assert!(
4253                !has(&issues, codes::FM_BAD_ID),
4254                "id case `{case}` must be silent; got {issues:#?}"
4255            );
4256        }
4257    }
4258
4259    /// FM_BAD_ID (warning) fires exactly on ids that cannot work as an
4260    /// identifier: empty / whitespace-only, internal whitespace, and
4261    /// non-scalar (list / mapping) — the last also being the shape that
4262    /// silently escapes `DUP_ID`'s scalar read.
4263    #[test]
4264    fn id_unusable_as_identifier_warns_fm_bad_id() {
4265        let body = |id_line: &str| {
4266            format!(
4267                "---\ntype: contact\n{id_line}\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n"
4268            )
4269        };
4270        for bad in [
4271            "id: \"\"",
4272            "id: \"   \"",
4273            "id: two words",
4274            "id: [a, b]",
4275            "id: {k: v}",
4276        ] {
4277            let fx = Fixture::new();
4278            fx.write("records/contacts/a.md", &body(bad));
4279            let issues = validate_working_set(&fx.store(), None).unwrap();
4280            let issue = issues
4281                .iter()
4282                .find(|i| i.code == codes::FM_BAD_ID)
4283                .unwrap_or_else(|| panic!("`{bad}` must fire FM_BAD_ID; got {issues:#?}"));
4284            assert!(
4285                matches!(issue.severity, Severity::Warning),
4286                "FM_BAD_ID is a warning (additive v0.4 — it must never block a store): {issue:#?}"
4287            );
4288            assert_eq!(issue.key.as_deref(), Some("id"));
4289            assert!(
4290                !issue.is_error(),
4291                "FM_BAD_ID must not fail validation: {issue:#?}"
4292            );
4293        }
4294    }
4295
4296    /// Two records sharing a minted-form (ULID) id collide exactly like any
4297    /// other id — `DUP_ID`, hard error, store-scoped (the v0.4 uniqueness
4298    /// scope is the store).
4299    #[test]
4300    fn dup_id_fires_on_shared_ulid_ids() {
4301        let fx = Fixture::new();
4302        let rec = |name: &str| {
4303            format!(
4304                "---\ntype: contact\nid: 01j5qc3v9k4ym8rwbn2tqe6f7d\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: {name}\nname: {name}\n---\n\n# {name}\n"
4305            )
4306        };
4307        fx.write("records/contacts/a.md", &rec("A"));
4308        fx.write("records/contacts/b.md", &rec("B"));
4309        let issues = fx.store_all();
4310        assert_eq!(count(&issues, codes::DUP_ID), 1, "{issues:#?}");
4311        let issue = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
4312        assert!(issue.is_error());
4313        // The well-formed ULID itself stays silent — only the collision fires.
4314        assert!(!has(&issues, codes::FM_BAD_ID), "{issues:#?}");
4315    }
4316
4317    // ── DB.md structure ───────────────────────────────────────────────────────
4318
4319    /// The `Fixture::new` DB.md is valid → no `DB_MD_*` issue. This pins the
4320    /// "valid identity file is silent" half (a bug that flagged a valid DB.md
4321    /// would fail here).
4322    #[test]
4323    fn valid_db_md_emits_no_structure_issue() {
4324        let fx = Fixture::new();
4325        let issues = fx.store_all();
4326        assert!(
4327            !has(&issues, codes::DB_MD_BAD_TYPE)
4328                && !has(&issues, codes::DB_MD_MISSING_FIELD)
4329                && !has(&issues, codes::DB_MD_UNKNOWN_SECTION),
4330            "a valid DB.md (type: db-md + scope + owner, recognized sections) is silent: {issues:#?}"
4331        );
4332    }
4333
4334    /// A DB.md whose `type:` isn't `db-md` → `DB_MD_BAD_TYPE`, keyed on `type`,
4335    /// anchored to the `type:` line (file line 2). Failing to read the type, or
4336    /// accepting a non-`db-md` type, breaks this.
4337    #[test]
4338    fn db_md_wrong_type_is_error() {
4339        let fx = Fixture::new();
4340        fx.write("DB.md", "---\ntype: notes\nscope: company\nowner: T\n---\n");
4341        let issues = fx.store_all();
4342        let i = find(&issues, codes::DB_MD_BAD_TYPE);
4343        assert!(i.is_error());
4344        assert_eq!(i.file, PathBuf::from("DB.md"));
4345        assert_eq!(i.key.as_deref(), Some("type"));
4346        assert_eq!(i.line, Some(2), "anchors to the `type:` line");
4347    }
4348
4349    /// A DB.md missing `scope` and `owner` → one `DB_MD_MISSING_FIELD` per
4350    /// absent field, each keyed on its field name, anchored to the block top.
4351    #[test]
4352    fn db_md_missing_scope_and_owner_each_report() {
4353        let fx = Fixture::new();
4354        fx.write("DB.md", "---\ntype: db-md\n---\n");
4355        let issues = fx.store_all();
4356        assert_eq!(
4357            count(&issues, codes::DB_MD_MISSING_FIELD),
4358            2,
4359            "both scope and owner absent → two issues: {issues:#?}"
4360        );
4361        let keys: BTreeSet<Option<String>> = issues
4362            .iter()
4363            .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4364            .map(|i| i.key.clone())
4365            .collect();
4366        assert_eq!(
4367            keys,
4368            BTreeSet::from([Some("scope".to_string()), Some("owner".to_string())]),
4369            "one issue keyed on each missing field"
4370        );
4371        for i in issues
4372            .iter()
4373            .filter(|i| i.code == codes::DB_MD_MISSING_FIELD)
4374        {
4375            assert!(i.is_error());
4376            assert_eq!(i.line, Some(1), "absent field anchors to the block top");
4377        }
4378    }
4379
4380    /// A present-but-blank required field is still missing (`DB_MD_MISSING_FIELD`),
4381    /// anchored to its own line — guarding against an "is the key textually
4382    /// present?" shortcut that would miss `owner:` with an empty value.
4383    #[test]
4384    fn db_md_blank_required_field_is_missing() {
4385        let fx = Fixture::new();
4386        fx.write(
4387            "DB.md",
4388            "---\ntype: db-md\nscope: company\nowner: \"\"\n---\n",
4389        );
4390        let issues = fx.store_all();
4391        let i = find(&issues, codes::DB_MD_MISSING_FIELD);
4392        assert_eq!(i.key.as_deref(), Some("owner"));
4393        assert_eq!(
4394            i.line,
4395            Some(4),
4396            "a present-but-empty field anchors to its line"
4397        );
4398        assert!(
4399            count(&issues, codes::DB_MD_MISSING_FIELD) == 1,
4400            "scope is present and non-empty → only owner reported"
4401        );
4402    }
4403
4404    /// An unrecognized `##` section → `DB_MD_UNKNOWN_SECTION` (warning), anchored
4405    /// to the heading's file line; the three recognized sections stay silent.
4406    #[test]
4407    fn db_md_unknown_section_is_warning() {
4408        let fx = Fixture::new();
4409        fx.write(
4410            "DB.md",
4411            // line 1 `---`, 2 type, 3 scope, 4 owner, 5 `---`, 6 blank,
4412            // 7 `## Agent instructions`, 8 blank, 9 prose, 10 blank,
4413            // 11 `## Glossary`.
4414            "---\ntype: db-md\nscope: company\nowner: T\n---\n\n## Agent instructions\n\nbe good\n\n## Glossary\n\nterms\n",
4415        );
4416        let issues = fx.store_all();
4417        let i = find(&issues, codes::DB_MD_UNKNOWN_SECTION);
4418        assert!(!i.is_error(), "unknown section is a warning, not an error");
4419        assert_eq!(i.severity, Severity::Warning);
4420        assert_eq!(
4421            i.line,
4422            Some(11),
4423            "anchors to the `## Glossary` heading line"
4424        );
4425        assert!(
4426            i.message.contains("Glossary"),
4427            "the message names the offending section: {}",
4428            i.message
4429        );
4430        // The recognized `## Agent instructions` section did NOT fire.
4431        assert_eq!(
4432            count(&issues, codes::DB_MD_UNKNOWN_SECTION),
4433            1,
4434            "only the unrecognized section is flagged: {issues:#?}"
4435        );
4436    }
4437
4438    /// A DB.md with no frontmatter at all → `DB_MD_BAD_TYPE` plus both
4439    /// `DB_MD_MISSING_FIELD`s (no provable type, no provable fields).
4440    #[test]
4441    fn db_md_no_frontmatter_reports_type_and_both_fields() {
4442        let fx = Fixture::new();
4443        fx.write("DB.md", "# just a heading, no frontmatter\n");
4444        let issues = fx.store_all();
4445        assert!(has(&issues, codes::DB_MD_BAD_TYPE));
4446        assert_eq!(count(&issues, codes::DB_MD_MISSING_FIELD), 2);
4447    }
4448
4449    // ── frontmatter ─────────────────────────────────────────────────────────
4450
4451    #[test]
4452    fn missing_type_is_error() {
4453        let fx = Fixture::new();
4454        fx.write(
4455            "records/contacts/a.md",
4456            "---\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\n# A\n",
4457        );
4458        let issues = fx.store_all();
4459        assert!(has(&issues, codes::FM_MISSING_TYPE));
4460        assert!(find(&issues, codes::FM_MISSING_TYPE).is_error());
4461    }
4462
4463    #[test]
4464    fn missing_universal_timestamps_are_errors_on_content_files() {
4465        let fx = Fixture::new();
4466        fx.write(
4467            "records/contacts/a.md",
4468            "---\ntype: contact\nsummary: x\nname: A\n---\n\n# A\n",
4469        );
4470        let issues = fx.store_all();
4471
4472        let missing_created = find(&issues, codes::FM_MISSING_CREATED);
4473        assert_eq!(missing_created.key.as_deref(), Some("created"));
4474        assert!(missing_created.is_error());
4475
4476        let missing_updated = find(&issues, codes::FM_MISSING_UPDATED);
4477        assert_eq!(missing_updated.key.as_deref(), Some("updated"));
4478        assert!(missing_updated.is_error());
4479    }
4480
4481    #[test]
4482    fn meta_files_do_not_require_universal_timestamps() {
4483        let fx = Fixture::new();
4484        let issues = fx.store_all();
4485
4486        assert!(
4487            !has(&issues, codes::FM_MISSING_CREATED),
4488            "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4489        );
4490        assert!(
4491            !has(&issues, codes::FM_MISSING_UPDATED),
4492            "DB.md/log/index meta files must not require content timestamps: {issues:#?}"
4493        );
4494    }
4495
4496    #[test]
4497    fn content_file_with_no_frontmatter_block_reports_type_and_summary() {
4498        let fx = Fixture::new();
4499        fx.write(
4500            "records/profiles/a.md",
4501            "# Just a heading\n\nNo frontmatter here.\n",
4502        );
4503        let issues = fx.store_all();
4504        assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4505        assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4506    }
4507
4508    #[test]
4509    fn content_file_with_empty_frontmatter_reports_type_and_summary() {
4510        let fx = Fixture::new();
4511        fx.write("records/profiles/a.md", "---\n---\n\nbody\n");
4512        let issues = fx.store_all();
4513        assert!(has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
4514        assert!(has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4515    }
4516
4517    #[test]
4518    fn malformed_yaml_is_error_and_suppresses_field_checks() {
4519        let fx = Fixture::new();
4520        // A tab inside a mapping value is invalid YAML.
4521        fx.write(
4522            "records/contacts/a.md",
4523            "---\ntype: contact\n  bad: : : :\n: : nope\n---\n\nbody\n",
4524        );
4525        let issues = fx.store_all();
4526        let issue = find(&issues, codes::FM_MALFORMED_YAML);
4527        assert!(issue.is_error());
4528        assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4529        // When YAML doesn't parse we don't *also* claim the summary is missing;
4530        // the agent fixes the YAML first.
4531        assert!(
4532            !has(&issues, codes::SUMMARY_MISSING),
4533            "malformed YAML should suppress SUMMARY_MISSING: {issues:#?}"
4534        );
4535    }
4536
4537    #[test]
4538    fn bad_created_timestamp_is_error() {
4539        let fx = Fixture::new();
4540        fx.write(
4541            "records/contacts/a.md",
4542            "---\ntype: contact\ncreated: not-a-date\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
4543        );
4544        let issues = fx.store_all();
4545        let issue = find(&issues, codes::FM_BAD_TIMESTAMP);
4546        assert_eq!(issue.key.as_deref(), Some("created"));
4547        assert!(issue.is_error());
4548    }
4549
4550    #[test]
4551    fn date_only_created_is_rejected_but_type_date_field_accepted() {
4552        let fx = Fixture::new();
4553        // `created` must be a full RFC3339 datetime → a date-only value is bad.
4554        // `last_touch` is a type-specific date field → date-only is fine.
4555        fx.write(
4556            "records/contacts/a.md",
4557            "---\ntype: contact\ncreated: 2026-05-22\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\nlast_touch: 2026-05-22\n---\n\n# A\n",
4558        );
4559        let issues = fx.store_all();
4560        let created_issues: Vec<_> = issues
4561            .iter()
4562            .filter(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created"))
4563            .collect();
4564        assert_eq!(
4565            created_issues.len(),
4566            1,
4567            "date-only `created` must fail: {issues:#?}"
4568        );
4569        assert!(
4570            !issues.iter().any(
4571                |i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("last_touch")
4572            ),
4573            "date-only `last_touch` is valid: {issues:#?}"
4574        );
4575    }
4576
4577    // ── summary ─────────────────────────────────────────────────────────────
4578
4579    #[test]
4580    fn summary_missing_empty_multiline_toolong() {
4581        let fx = Fixture::new();
4582        fx.write(
4583            "records/profiles/missing.md",
4584            "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\n---\n\nbody\n",
4585        );
4586        fx.write(
4587            "records/profiles/empty.md",
4588            "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"   \"\n---\n\nbody\n",
4589        );
4590        let long = "x".repeat(201);
4591        fx.write(
4592            "records/profiles/long.md",
4593            &format!("---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"{long}\"\n---\n\nbody\n"),
4594        );
4595        let issues = fx.store_all();
4596        assert!(has(&issues, codes::SUMMARY_MISSING));
4597        assert_eq!(
4598            find(&issues, codes::SUMMARY_MISSING).file,
4599            PathBuf::from("records/profiles/missing.md")
4600        );
4601        assert!(has(&issues, codes::SUMMARY_EMPTY));
4602        assert!(has(&issues, codes::SUMMARY_TOO_LONG));
4603        assert_eq!(
4604            find(&issues, codes::SUMMARY_TOO_LONG).severity,
4605            Severity::Warning
4606        );
4607    }
4608
4609    #[test]
4610    fn summary_multiline_via_yaml_block_scalar() {
4611        let fx = Fixture::new();
4612        // A literal block scalar produces a value with a newline.
4613        fx.write(
4614            "records/profiles/a.md",
4615            "---\ntype: profile\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: |\n  line one\n  line two\n---\n\nbody\n",
4616        );
4617        let issues = fx.store_all();
4618        assert!(has(&issues, codes::SUMMARY_MULTILINE), "{issues:#?}");
4619    }
4620
4621    #[test]
4622    fn summary_exactly_200_chars_is_ok() {
4623        let fx = Fixture::new();
4624        let s = "y".repeat(200);
4625        fx.write(
4626            "records/profiles/a.md",
4627            &format!("---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"{s}\"\n---\n\nbody\n"),
4628        );
4629        let issues = fx.store_all();
4630        assert!(
4631            !has(&issues, codes::SUMMARY_TOO_LONG),
4632            "200 is the bound, inclusive: {issues:#?}"
4633        );
4634    }
4635
4636    #[test]
4637    fn meta_files_need_no_summary() {
4638        let fx = Fixture::new();
4639        // The root/layer/type indexes + log carry no summary and must not be
4640        // flagged. (A lone DB.md store with one contact and full indexes.)
4641        fx.write("records/contacts/a.md", &valid_contact("A contact"));
4642        fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n# I\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
4643        fx.write(
4644            "records/index.md",
4645            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
4646        );
4647        fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — A contact\n");
4648        fx.write(
4649            "records/contacts/index.jsonl",
4650            "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"A contact\"}\n",
4651        );
4652        fx.write("log.md", "---\ntype: log\n---\n\n# Log\n");
4653        let issues = fx.store_all();
4654        assert!(!has(&issues, codes::SUMMARY_MISSING), "{issues:#?}");
4655    }
4656
4657    // ── tags ────────────────────────────────────────────────────────────────
4658
4659    #[test]
4660    fn nested_tags_warns_flat_tags_ok() {
4661        let fx = Fixture::new();
4662        fx.write(
4663            "records/contacts/nested.md",
4664            "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\ntags:\n  - good\n  - [nested, list]\n---\n\n# A\n",
4665        );
4666        fx.write(
4667            "records/contacts/flat.md",
4668            "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\ntags: [customer, vip]\n---\n\n# A\n",
4669        );
4670        let issues = fx.store_all();
4671        let tag_issues: Vec<_> = issues
4672            .iter()
4673            .filter(|i| i.code == codes::TAGS_MALFORMED)
4674            .collect();
4675        assert_eq!(
4676            tag_issues.len(),
4677            1,
4678            "only the nested-tags file should warn: {issues:#?}"
4679        );
4680        assert_eq!(
4681            tag_issues[0].file,
4682            PathBuf::from("records/contacts/nested.md")
4683        );
4684        assert_eq!(tag_issues[0].severity, Severity::Warning);
4685    }
4686
4687    // ── wiki-links ────────────────────────────────────────────────────────────
4688
4689    #[test]
4690    fn short_form_wiki_link_is_error() {
4691        let fx = Fixture::new();
4692        let mut body = valid_contact("links to a short form");
4693        body.push_str("\nSee [[sarah-chen]] for details.\n");
4694        fx.write("records/contacts/a.md", &body);
4695        let issues = fx.store_all();
4696        let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4697        assert!(issue.is_error());
4698        assert!(issue.message.contains("sarah-chen"));
4699        // A short-form link must NOT also be reported broken — fix the form first.
4700        assert!(
4701            !issues
4702                .iter()
4703                .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("sarah-chen")),
4704            "short-form should suppress broken: {issues:#?}"
4705        );
4706    }
4707
4708    #[test]
4709    fn broken_full_path_wiki_link_is_error() {
4710        let fx = Fixture::new();
4711        let mut body = valid_contact("links to a missing file");
4712        body.push_str("\nSee [[records/contacts/ghost]].\n");
4713        fx.write("records/contacts/a.md", &body);
4714        let issues = fx.store_all();
4715        let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4716        assert!(issue.is_error());
4717        assert!(issue.message.contains("records/contacts/ghost"));
4718        assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4719    }
4720
4721    #[test]
4722    fn traversal_full_path_wiki_link_is_rejected_before_probe() {
4723        let fx = Fixture::new();
4724        let mut body = valid_contact("links with traversal");
4725        body.push_str("\nSee [[records/contacts/../../ghost]].\n");
4726        fx.write("records/contacts/a.md", &body);
4727        let issues = fx.store_all();
4728        let issue = find(&issues, codes::WIKI_LINK_BROKEN);
4729        assert!(issue.message.contains("not a safe store-relative path"));
4730        assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
4731    }
4732
4733    #[test]
4734    fn valid_full_path_wiki_link_passes() {
4735        let fx = Fixture::new();
4736        fx.write("records/contacts/target.md", &valid_contact("target"));
4737        let mut body = valid_contact("links to target");
4738        body.push_str("\nSee [[records/contacts/target]].\n");
4739        fx.write("records/contacts/a.md", &body);
4740        let issues = fx.store_all();
4741        assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4742        assert!(!has(&issues, codes::WIKI_LINK_SHORT_FORM), "{issues:#?}");
4743    }
4744
4745    #[test]
4746    fn md_extension_wiki_link_warns_and_resolves() {
4747        let fx = Fixture::new();
4748        fx.write("records/contacts/target.md", &valid_contact("target"));
4749        let mut body = valid_contact("links with extension");
4750        body.push_str("\nSee [[records/contacts/target.md]].\n");
4751        fx.write("records/contacts/a.md", &body);
4752        let issues = fx.store_all();
4753        let issue = find(&issues, codes::WIKI_LINK_HAS_EXTENSION);
4754        assert_eq!(issue.severity, Severity::Warning);
4755        assert_eq!(
4756            issue.suggestion.as_deref(),
4757            Some("drop the extension: [[records/contacts/target]]")
4758        );
4759        // The target exists once `.md` is stripped → not broken.
4760        assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4761    }
4762
4763    #[test]
4764    fn wiki_links_in_code_fences_are_ignored() {
4765        let fx = Fixture::new();
4766        let mut body = valid_contact("has a fenced example");
4767        body.push_str("\n```\n[[sarah-chen]]\n```\n");
4768        fx.write("records/contacts/a.md", &body);
4769        let issues = fx.store_all();
4770        assert!(
4771            !has(&issues, codes::WIKI_LINK_SHORT_FORM),
4772            "fenced wiki-links must be ignored: {issues:#?}"
4773        );
4774    }
4775
4776    #[test]
4777    fn flow_form_link_list_in_frontmatter_is_error() {
4778        let fx = Fixture::new();
4779        fx.write(
4780            "records/meetings/m.md",
4781            "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a meeting\ndate: 2026-05-22\nattendees: [[[records/contacts/a]], [[records/contacts/b]]]\n---\n\n# M\n",
4782        );
4783        let issues = fx.store_all();
4784        let issue = find(&issues, codes::WIKI_LINK_FLOW_FORM_LIST);
4785        assert!(issue.is_error());
4786        assert_eq!(issue.key.as_deref(), Some("attendees"));
4787    }
4788
4789    #[test]
4790    fn block_form_link_list_in_frontmatter_is_not_flow_form() {
4791        let fx = Fixture::new();
4792        fx.write("records/contacts/a.md", &valid_contact("a"));
4793        fx.write("records/contacts/b.md", &valid_contact("b"));
4794        fx.write(
4795            "records/meetings/m.md",
4796            "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a meeting\ndate: 2026-05-22\nattendees:\n  - [[records/contacts/a]]\n  - [[records/contacts/b]]\n---\n\n# M\n",
4797        );
4798        let issues = fx.store_all();
4799        assert!(
4800            !has(&issues, codes::WIKI_LINK_FLOW_FORM_LIST),
4801            "{issues:#?}"
4802        );
4803        // Block-form link targets are still integrity-checked (both exist here).
4804        assert!(!has(&issues, codes::WIKI_LINK_BROKEN), "{issues:#?}");
4805    }
4806
4807    #[test]
4808    fn frontmatter_short_form_link_field_is_error() {
4809        let fx = Fixture::new();
4810        // `related` is a *custom* (non-schema) wiki-link field, so it goes
4811        // through the generic doctrine path → a short form is WIKI_LINK_SHORT_FORM.
4812        fx.write(
4813            "records/synthesis/a.md",
4814            "---\ntype: synthesis\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nrelated: \"[[sarah-chen]]\"\n---\n\n# A\n",
4815        );
4816        let issues = fx.store_all();
4817        let issue = find(&issues, codes::WIKI_LINK_SHORT_FORM);
4818        assert!(issue.is_error());
4819        assert_eq!(issue.key.as_deref(), Some("related"));
4820    }
4821
4822    #[test]
4823    fn unquoted_frontmatter_link_is_recognized() {
4824        // An UNQUOTED `[[...]]` parses in YAML as a nested sequence, not a
4825        // string. The validator must still see it as a wiki-link (text-based
4826        // extraction). A short-form custom field must report SHORT_FORM, and a
4827        // full-path one with a missing target must report BROKEN.
4828        let fx = Fixture::new();
4829        fx.write(
4830            "records/synthesis/short.md",
4831            "---\ntype: synthesis\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nrelated: [[sarah-chen]]\n---\n\n# A\n",
4832        );
4833        fx.write(
4834            "records/synthesis/broken.md",
4835            "---\ntype: synthesis\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nrelated: [[records/contacts/ghost]]\n---\n\n# A\n",
4836        );
4837        let issues = fx.store_all();
4838        assert!(
4839            issues.iter().any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4840                && i.file == Path::new("records/synthesis/short.md")
4841                && i.key.as_deref() == Some("related")),
4842            "unquoted short-form frontmatter link must be caught: {issues:#?}"
4843        );
4844        assert!(
4845            issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
4846                && i.file == Path::new("records/synthesis/broken.md")),
4847            "unquoted full-path frontmatter link to a missing file must be caught: {issues:#?}"
4848        );
4849    }
4850
4851    #[test]
4852    fn short_form_in_declared_link_field_is_prefix_mismatch_not_double_reported() {
4853        // A short-form value in a *declared* link field (a `### contact` schema
4854        // with `company link to records/companies/`) is SCHEMA_LINK_PREFIX_MISMATCH
4855        // (the target isn't under the prefix), and must NOT also be reported as a
4856        // bare WIKI_LINK_SHORT_FORM — the schema path owns that field once.
4857        let mut fx = Fixture::new();
4858        fx.config.schemas.insert(
4859            "contact".into(),
4860            Schema {
4861                fields: vec![FieldSpec {
4862                    name: "company".into(),
4863                    link_prefix: Some(PathBuf::from("records/companies")),
4864                    ..Default::default()
4865                }],
4866                ..Default::default()
4867            },
4868        );
4869        fx.write(
4870            "records/contacts/a.md",
4871            "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\ncompany: \"[[northstar]]\"\n---\n\n# A\n",
4872        );
4873        let issues = fx.store_all();
4874        let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
4875        assert_eq!(issue.key.as_deref(), Some("company"));
4876        // The same link must NOT also be double-reported via the generic path.
4877        assert!(
4878            !issues
4879                .iter()
4880                .any(|i| i.code == codes::WIKI_LINK_SHORT_FORM
4881                    && i.key.as_deref() == Some("company")),
4882            "schema link fields are checked once, by the schema path: {issues:#?}"
4883        );
4884    }
4885
4886    #[test]
4887    fn schema_link_field_with_md_extension_still_warns() {
4888        let mut fx = Fixture::new();
4889        fx.config.schemas.insert(
4890            "contact".into(),
4891            Schema {
4892                fields: vec![FieldSpec {
4893                    name: "company".into(),
4894                    link_prefix: Some(PathBuf::from("records/companies")),
4895                    ..Default::default()
4896                }],
4897                ..Default::default()
4898            },
4899        );
4900        fx.write(
4901            "records/companies/acme.md",
4902            "---\ntype: company\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: Acme\nname: Acme\n---\n\n# Acme\n",
4903        );
4904        fx.write(
4905            "records/contacts/a.md",
4906            "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\ncompany: \"[[records/companies/acme.md]]\"\n---\n\n# A\n",
4907        );
4908        let issues = fx.store_all();
4909        let issue = issues
4910            .iter()
4911            .find(|i| {
4912                i.code == codes::WIKI_LINK_HAS_EXTENSION && i.key.as_deref() == Some("company")
4913            })
4914            .unwrap_or_else(|| panic!("schema link extension warning missing: {issues:#?}"));
4915        assert_eq!(issue.severity, Severity::Warning);
4916        assert!(
4917            !issues
4918                .iter()
4919                .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.key.as_deref() == Some("company")),
4920            "extensionless existence check should still find acme.md: {issues:#?}"
4921        );
4922    }
4923
4924    // ── schema: explicit DB.md schema (required / shape / enum) ───────────────
4925
4926    #[test]
4927    fn explicit_schema_required_shape_enum() {
4928        let fx = {
4929            let mut fx = Fixture::new();
4930            // contact schema: name required, email required+email shape,
4931            // status enum: active|inactive
4932            let schema = Schema {
4933                fields: vec![
4934                    FieldSpec {
4935                        name: "name".into(),
4936                        required: true,
4937                        ..Default::default()
4938                    },
4939                    FieldSpec {
4940                        name: "email".into(),
4941                        required: true,
4942                        shape: Some(Shape::Email),
4943                        ..Default::default()
4944                    },
4945                    FieldSpec {
4946                        name: "status".into(),
4947                        enum_values: Some(vec!["active".into(), "inactive".into()]),
4948                        ..Default::default()
4949                    },
4950                ],
4951                ..Default::default()
4952            };
4953            fx.config.schemas.insert("contact".into(), schema);
4954            fx
4955        };
4956        fx.write(
4957            "records/contacts/a.md",
4958            "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nemail: not-an-email\nstatus: archived\n---\n\n# A\n",
4959        );
4960        let issues = fx.store_all();
4961        // name absent → MISSING_REQUIRED
4962        assert!(
4963            issues
4964                .iter()
4965                .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
4966                    && i.key.as_deref() == Some("name")),
4967            "{issues:#?}"
4968        );
4969        // email malformed → SHAPE_MISMATCH
4970        assert!(
4971            issues.iter().any(
4972                |i| i.code == codes::SCHEMA_SHAPE_MISMATCH && i.key.as_deref() == Some("email")
4973            ),
4974            "{issues:#?}"
4975        );
4976        // status archived not in enum → ENUM_VIOLATION
4977        assert!(
4978            issues
4979                .iter()
4980                .any(|i| i.code == codes::SCHEMA_ENUM_VIOLATION
4981                    && i.key.as_deref() == Some("status")),
4982            "{issues:#?}"
4983        );
4984    }
4985
4986    #[test]
4987    fn schema_without_link_field_allows_plain_value() {
4988        // A `contact` schema with no `company` link field means a plain `company`
4989        // string is fine — schema enforcement is exactly what the store declares,
4990        // nothing implicit.
4991        let mut fx = Fixture::new();
4992        fx.config.schemas.insert(
4993            "contact".into(),
4994            Schema {
4995                fields: vec![FieldSpec {
4996                    name: "name".into(),
4997                    required: true,
4998                    ..Default::default()
4999                }],
5000                ..Default::default()
5001            },
5002        );
5003        fx.write(
5004            "records/contacts/a.md",
5005            "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: Sarah\ncompany: \"Acme Co\"\n---\n\n# Sarah\n",
5006        );
5007        let issues = fx.store_all();
5008        assert!(
5009            !has(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH),
5010            "no declared link field for `company` → a plain value is fine: {issues:#?}"
5011        );
5012    }
5013
5014    #[test]
5015    fn schema_link_field_plain_value_is_prefix_mismatch() {
5016        // The surviving link-enforcement path: a declared `link to <prefix>/`
5017        // field with a plain-string value is SCHEMA_LINK_PREFIX_MISMATCH.
5018        let mut fx = Fixture::new();
5019        fx.config.schemas.insert(
5020            "contact".into(),
5021            Schema {
5022                fields: vec![FieldSpec {
5023                    name: "company".into(),
5024                    link_prefix: Some(PathBuf::from("records/companies")),
5025                    ..Default::default()
5026                }],
5027                ..Default::default()
5028            },
5029        );
5030        fx.write(
5031            "records/contacts/a.md",
5032            "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: Sarah\ncompany: \"Acme Co\"\n---\n\n# Sarah\n",
5033        );
5034        let issues = fx.store_all();
5035        let issue = find(&issues, codes::SCHEMA_LINK_PREFIX_MISMATCH);
5036        assert_eq!(issue.key.as_deref(), Some("company"));
5037        assert!(issue
5038            .suggestion
5039            .as_deref()
5040            .unwrap()
5041            .contains("records/companies/"));
5042    }
5043
5044    #[test]
5045    fn schema_shape_int_and_url_and_currency() {
5046        let mut fx = Fixture::new();
5047        fx.config.schemas.insert(
5048            "widget".into(),
5049            Schema {
5050                fields: vec![
5051                    FieldSpec {
5052                        name: "qty".into(),
5053                        shape: Some(Shape::Int),
5054                        ..Default::default()
5055                    },
5056                    FieldSpec {
5057                        name: "site".into(),
5058                        shape: Some(Shape::Url),
5059                        ..Default::default()
5060                    },
5061                    FieldSpec {
5062                        name: "price".into(),
5063                        shape: Some(Shape::Currency),
5064                        ..Default::default()
5065                    },
5066                ],
5067                ..Default::default()
5068            },
5069        );
5070        // `USD 100` is the corpus-realistic shape (an `expense.currency`-style
5071        // ISO code + amount). It must pass — it used to spuriously fail.
5072        fx.write(
5073            "records/widgets/ok.md",
5074            "---\ntype: widget\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: ok\nqty: 5\nsite: https://example.com\nprice: \"USD 1,234.50\"\n---\n\n# ok\n",
5075        );
5076        // `free` is non-numeric; `inf`/`NaN`/3-decimal used to slip through
5077        // because the old impl leaned on `f64::parse`. `price: inf` here guards
5078        // the under-rejection half of the finding.
5079        fx.write(
5080            "records/widgets/bad.md",
5081            "---\ntype: widget\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: bad\nqty: five\nsite: ftp://nope\nprice: inf\n---\n\n# bad\n",
5082        );
5083        let issues = fx.store_all();
5084        let bad_shape: Vec<_> = issues
5085            .iter()
5086            .filter(|i| {
5087                i.code == codes::SCHEMA_SHAPE_MISMATCH
5088                    && i.file == Path::new("records/widgets/bad.md")
5089            })
5090            .map(|i| i.key.clone().unwrap_or_default())
5091            .collect();
5092        assert!(bad_shape.contains(&"qty".to_string()), "{issues:#?}");
5093        assert!(bad_shape.contains(&"site".to_string()), "{issues:#?}");
5094        assert!(
5095            bad_shape.contains(&"price".to_string()),
5096            "inf must be rejected as currency: {issues:#?}"
5097        );
5098        assert!(
5099            !issues.iter().any(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH
5100                && i.file == Path::new("records/widgets/ok.md")),
5101            "valid shapes (incl. `USD 1,234.50`) must not fire: {issues:#?}"
5102        );
5103    }
5104
5105    #[test]
5106    fn schema_shape_or_enum_field_with_non_scalar_value_is_shape_mismatch() {
5107        let mut fx = Fixture::new();
5108        fx.config.schemas.insert(
5109            "contact".into(),
5110            Schema {
5111                fields: vec![
5112                    FieldSpec {
5113                        name: "email".into(),
5114                        required: true,
5115                        shape: Some(Shape::Email),
5116                        ..Default::default()
5117                    },
5118                    FieldSpec {
5119                        name: "status".into(),
5120                        enum_values: Some(vec!["active".into(), "inactive".into()]),
5121                        ..Default::default()
5122                    },
5123                ],
5124                ..Default::default()
5125            },
5126        );
5127        // A required EMAIL field and an ENUM field, each holding a LIST. Both
5128        // used to slip through entirely (`scalar_string` → None → the shape and
5129        // enum bodies silently no-op); now they flag SCHEMA_SHAPE_MISMATCH.
5130        fx.write(
5131            "records/contacts/bad.md",
5132            "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: bad\nemail:\n  - a@b.com\n  - c@d.com\nstatus:\n  - active\n---\n\n# bad\n",
5133        );
5134        let issues = fx.store_all();
5135        let mismatched: Vec<_> = issues
5136            .iter()
5137            .filter(|i| i.code == codes::SCHEMA_SHAPE_MISMATCH)
5138            .map(|i| i.key.clone().unwrap_or_default())
5139            .collect();
5140        assert!(
5141            mismatched.contains(&"email".to_string()),
5142            "list-valued required email must flag: {issues:#?}"
5143        );
5144        assert!(
5145            mismatched.contains(&"status".to_string()),
5146            "list-valued enum must flag: {issues:#?}"
5147        );
5148    }
5149
5150    #[test]
5151    fn is_currency_accepts_codes_and_rejects_non_numeric() {
5152        // Symbols and 3-letter ISO codes both strip; plain numbers pass.
5153        for ok in [
5154            "100",
5155            "1234.56",
5156            "$1,234.50",
5157            "USD 100", // the finding's headline probe — used to be false
5158            "usd 100", // case-insensitive code
5159            "EUR 9.50",
5160            "£12",
5161            "¥1000",
5162            "-5.00", // signed amounts are real (refunds)
5163            "+5",
5164            "1,000,000",
5165        ] {
5166            assert!(is_currency(ok), "expected currency: {ok:?}");
5167        }
5168        // Non-numeric floats `f64::parse` would accept, and the > 2-decimal /
5169        // bare-code / exponent cases the docstring forbids.
5170        for bad in [
5171            "inf", "-inf", "infinity", "NaN", "nan",    // f64 accepts these; we must not
5172            "12.999", // 3 decimals
5173            "1.2345", // 4 decimals
5174            "USD",    // bare code, no amount
5175            "$",      // bare symbol
5176            "free", "", " ", "1e3",      // exponent form
5177            "1.",       // trailing dot, no fractional digits
5178            ".5",       // leading dot, no integer digits
5179            "1 000",    // space as separator is not a thousands separator
5180            "USDD 100", // 4-letter "code" must not strip
5181        ] {
5182            assert!(!is_currency(bad), "expected NOT currency: {bad:?}");
5183        }
5184    }
5185
5186    // ── policies ───────────────────────────────────────────────────────────
5187
5188    #[test]
5189    fn ignored_type_present_is_info() {
5190        let mut fx = Fixture::new();
5191        fx.config.ignored_types.push("temp".into());
5192        fx.write(
5193            "records/temps/x.md",
5194            "---\ntype: temp\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a temp\n---\n\n# x\n",
5195        );
5196        let issues = fx.store_all();
5197        let issue = find(&issues, codes::POLICY_IGNORED_TYPE_PRESENT);
5198        assert_eq!(issue.severity, Severity::Info);
5199        assert!(!issue.is_error());
5200        assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5201    }
5202
5203    #[test]
5204    fn conclusion_record_derived_from_ignored_type_warns() {
5205        let mut fx = Fixture::new();
5206        fx.config.ignored_types.push("temp".into());
5207        fx.write(
5208            "records/temps/x.md",
5209            "---\ntype: temp\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a temp\n---\n\n# x\n",
5210        );
5211        // The policy now gates on `meta-type: conclusion` (not the retired
5212        // `type: wiki-page`): a conclusion record that derives from an
5213        // ignored-type record warns.
5214        fx.write(
5215            "records/synthesis/t.md",
5216            "---\ntype: synthesis\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: derived\nderived_from: \"[[records/temps/x]]\"\n---\n\n# t\n",
5217        );
5218        let issues = fx.store_all();
5219        let issue = find(&issues, codes::POLICY_IGNORED_TYPE_DERIVED);
5220        assert_eq!(issue.severity, Severity::Warning);
5221        assert_eq!(issue.key.as_deref(), Some("derived_from"));
5222        assert!(issue.suggestion.as_deref().is_some_and(|s| !s.is_empty()));
5223    }
5224
5225    /// The shared `derived_from_ignored_type` entry point — the single
5226    /// policy-decision both `dbmd validate` (read) and `dbmd write` (write-time
5227    /// warning) now route through, so they cannot diverge. This pins its
5228    /// contract directly: the meta-type gate (now `meta-type: conclusion`, not
5229    /// the retired `type: wiki-page`), the empty-ignored-types gate, a positive
5230    /// match carrying the resolved target type, and a non-ignored target
5231    /// rejected.
5232    #[test]
5233    fn derived_from_ignored_type_is_the_shared_policy_decision() {
5234        let mut fx = Fixture::new();
5235        fx.config.ignored_types.push("secret".into());
5236        // An ignored-type record …
5237        fx.write(
5238            "records/secrets/s.md",
5239            "---\ntype: secret\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: hush\n---\n\n# s\n",
5240        );
5241        // … and a non-ignored record.
5242        fx.write(
5243            "records/contacts/c.md",
5244            "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: ok\nname: C\n---\n\n# c\n",
5245        );
5246        let store = fx.store();
5247
5248        // Positive: a conclusion record deriving from the ignored-type record
5249        // matches, and the hit carries both the target (as written) and its
5250        // resolved type.
5251        let hit =
5252            derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s"))
5253                .expect("conclusion → ignored-type record must match");
5254        assert_eq!(hit.target, "records/secrets/s");
5255        assert_eq!(hit.target_type, "secret");
5256
5257        // Meta-type gate: a non-`conclusion` meta-type never triggers, even with
5258        // the same ignored-type target.
5259        assert_eq!(
5260            derived_from_ignored_type(&store, "fact", std::iter::once("records/secrets/s")),
5261            None,
5262            "only conclusion derivation is policed"
5263        );
5264
5265        // Target gate: a conclusion deriving from a non-ignored record is fine.
5266        assert_eq!(
5267            derived_from_ignored_type(&store, "conclusion", std::iter::once("records/contacts/c")),
5268            None,
5269            "deriving from a non-ignored type is allowed"
5270        );
5271
5272        // First match wins across multiple targets (here the second is the hit).
5273        let hit = derived_from_ignored_type(
5274            &store,
5275            "conclusion",
5276            ["records/contacts/c", "records/secrets/s"],
5277        )
5278        .expect("a later ignored-type target must still be found");
5279        assert_eq!(hit.target, "records/secrets/s");
5280
5281        // Empty-policy gate: with no `### Ignored types`, nothing is policed.
5282        fx.config.ignored_types.clear();
5283        let store = fx.store();
5284        assert_eq!(
5285            derived_from_ignored_type(&store, "conclusion", std::iter::once("records/secrets/s")),
5286            None,
5287            "an empty ignored-types policy short-circuits"
5288        );
5289    }
5290
5291    // ── duplicates ───────────────────────────────────────────────────────────
5292
5293    #[test]
5294    fn dup_id_is_hard_error_with_related() {
5295        let fx = Fixture::new();
5296        fx.write(
5297            "records/contacts/a.md",
5298            "---\ntype: contact\nid: shared\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a\nname: A\n---\n\n# A\n",
5299        );
5300        fx.write(
5301            "records/contacts/b.md",
5302            "---\ntype: contact\nid: shared\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: b\nname: B\n---\n\n# B\n",
5303        );
5304        let issues = fx.store_all();
5305        // Reporting rule #1: ONE issue per collision group, keyed on the
5306        // lexicographically smallest path (`a.md`), partner in `related`.
5307        assert_eq!(
5308            count(&issues, codes::DUP_ID),
5309            1,
5310            "one issue per group: {issues:#?}"
5311        );
5312        let a = issues.iter().find(|i| i.code == codes::DUP_ID).unwrap();
5313        assert_eq!(a.file, PathBuf::from("records/contacts/a.md"));
5314        assert!(a.is_error());
5315        assert_eq!(a.key.as_deref(), Some("id"));
5316        assert_eq!(
5317            a.line,
5318            Some(3),
5319            "anchors to the `id` line on the reported file"
5320        );
5321        assert_eq!(a.related, vec![PathBuf::from("records/contacts/b.md")]);
5322    }
5323
5324    #[test]
5325    fn dup_id_not_fired_in_working_set() {
5326        // DUP_* is an --all-only cross-file check; the working set must not run it.
5327        let fx = Fixture::new();
5328        fx.write(
5329            "records/contacts/a.md",
5330            "---\ntype: contact\nid: shared\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a\nname: A\n---\n\n# A\n",
5331        );
5332        fx.write(
5333            "records/contacts/b.md",
5334            "---\ntype: contact\nid: shared\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: b\nname: B\n---\n\n# B\n",
5335        );
5336        // Log says both changed since epoch, so they're in the working set.
5337        fx.write(
5338            "log.md",
5339            "---\ntype: log\n---\n\n## [2026-05-22 10:00] create | records/contacts/a\nx\n\n## [2026-05-22 10:01] create | records/contacts/b\nx\n",
5340        );
5341        let issues = validate_working_set(&fx.store(), None).unwrap();
5342        assert!(
5343            !has(&issues, codes::DUP_ID),
5344            "DUP_ID is --all only: {issues:#?}"
5345        );
5346    }
5347
5348    #[test]
5349    fn dup_unique_key_single_field_is_warning() {
5350        let mut fx = Fixture::new();
5351        // contact declares `- unique: email`.
5352        fx.config.schemas.insert(
5353            "contact".into(),
5354            Schema {
5355                unique_keys: vec![vec!["email".into()]],
5356                ..Default::default()
5357            },
5358        );
5359        for (f, name) in [("a", "A"), ("b", "B")] {
5360            fx.write(
5361                &format!("records/contacts/{f}.md"),
5362                &format!("---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: s\nname: {name}\nemail: dup@x.com\n---\n\n# {name}\n"),
5363            );
5364        }
5365        let issues = fx.store_all();
5366        // One issue per group (rule #1), keyed on the smallest path, anchored to
5367        // the single `email` field.
5368        assert_eq!(count(&issues, codes::DUP_UNIQUE_KEY), 1);
5369        let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5370        assert_eq!(dup.severity, Severity::Warning);
5371        assert_eq!(dup.file, PathBuf::from("records/contacts/a.md"));
5372        assert_eq!(dup.key.as_deref(), Some("email"));
5373        assert_eq!(dup.related, vec![PathBuf::from("records/contacts/b.md")]);
5374    }
5375
5376    #[test]
5377    fn dup_unique_key_compound_and_clean_when_one_field_differs() {
5378        let mut fx = Fixture::new();
5379        // expense declares `- unique: date, amount, vendor` (a compound key).
5380        fx.config.schemas.insert(
5381            "expense".into(),
5382            Schema {
5383                unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
5384                ..Default::default()
5385            },
5386        );
5387        fx.write("records/companies/acme.md", "---\ntype: company\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: c\nname: Acme\n---\n# A\n");
5388        let exp = |f: &str, amount: &str| {
5389            format!(
5390            "---\ntype: expense\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: e\ndate: 2026-05-01\namount: {amount}\nvendor: \"[[records/companies/acme]]\"\n---\n\n# {f}\n"
5391        )
5392        };
5393        fx.write("records/expenses/e1.md", &exp("e1", "100"));
5394        fx.write("records/expenses/e2.md", &exp("e2", "100"));
5395        fx.write("records/expenses/e3.md", &exp("e3", "200")); // different amount
5396        let issues = fx.store_all();
5397        // One issue for the e1+e2 group (rule #1), keyed on the smallest path
5398        // (e1) with e2 in `related`; e3 differs on amount and never appears.
5399        assert_eq!(
5400            count(&issues, codes::DUP_UNIQUE_KEY),
5401            1,
5402            "only e1+e2 collide, one issue: {issues:#?}"
5403        );
5404        let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5405        assert_eq!(dup.file, PathBuf::from("records/expenses/e1.md"));
5406        assert_eq!(
5407            dup.line,
5408            Some(1),
5409            "compound-key collision anchors to line 1"
5410        );
5411        assert_eq!(dup.related, vec![PathBuf::from("records/expenses/e2.md")]);
5412        assert!(
5413            !issues.iter().any(|i| i.code == codes::DUP_UNIQUE_KEY
5414                && i.related.contains(&PathBuf::from("records/expenses/e3.md"))),
5415            "e3 differs on amount and must not collide: {issues:#?}"
5416        );
5417    }
5418
5419    #[test]
5420    fn dup_unique_key_list_field_is_order_independent() {
5421        let mut fx = Fixture::new();
5422        // meeting declares `- unique: date, attendees`; the list field is a set.
5423        fx.config.schemas.insert(
5424            "meeting".into(),
5425            Schema {
5426                unique_keys: vec![vec!["date".into(), "attendees".into()]],
5427                ..Default::default()
5428            },
5429        );
5430        fx.write("records/contacts/a.md", &valid_contact("a"));
5431        fx.write("records/contacts/b.md", &valid_contact("b"));
5432        let m = |f: &str, order: &str| {
5433            let attendees = if order == "ab" {
5434                "  - [[records/contacts/a]]\n  - [[records/contacts/b]]"
5435            } else {
5436                "  - [[records/contacts/b]]\n  - [[records/contacts/a]]"
5437            };
5438            format!(
5439                "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: m\ndate: 2026-05-01\nattendees:\n{attendees}\n---\n\n# {f}\n"
5440            )
5441        };
5442        fx.write("records/meetings/m1.md", &m("m1", "ab"));
5443        fx.write("records/meetings/m2.md", &m("m2", "ba"));
5444        let issues = fx.store_all();
5445        // The attendee SET is order-independent, so m1 (ab) and m2 (ba) collide
5446        // → a single issue on the smaller path.
5447        assert_eq!(
5448            count(&issues, codes::DUP_UNIQUE_KEY),
5449            1,
5450            "same date + same attendee set (any order) collide as one issue: {issues:#?}"
5451        );
5452        let dup = find(&issues, codes::DUP_UNIQUE_KEY);
5453        assert_eq!(dup.file, PathBuf::from("records/meetings/m1.md"));
5454        assert_eq!(dup.related, vec![PathBuf::from("records/meetings/m2.md")]);
5455    }
5456
5457    // ── indexes ───────────────────────────────────────────────────────────────
5458
5459    #[test]
5460    fn missing_indexes_at_all_three_levels() {
5461        let fx = Fixture::new();
5462        fx.write("records/contacts/a.md", &valid_contact("a"));
5463        let issues = fx.store_all();
5464        // root, layer (records), and type-folder (records/contacts) all missing.
5465        // The type-folder INDEX_MISSING is keyed on the FOLDER path (not its
5466        // would-be index.md), per the field convention `EXPECTED` pins.
5467        let missing_files: BTreeSet<PathBuf> = issues
5468            .iter()
5469            .filter(|i| i.code == codes::INDEX_MISSING)
5470            .map(|i| i.file.clone())
5471            .collect();
5472        assert!(
5473            missing_files.contains(&PathBuf::from("index.md")),
5474            "{issues:#?}"
5475        );
5476        assert!(
5477            missing_files.contains(&PathBuf::from("records/index.md")),
5478            "{issues:#?}"
5479        );
5480        assert!(
5481            missing_files.contains(&PathBuf::from("records/contacts")),
5482            "{issues:#?}"
5483        );
5484        // When the index.md is entirely absent we do NOT additionally fire
5485        // INDEX_JSONL_MISSING — one INDEX_MISSING covers the folder (rule #4).
5486        assert!(!has(&issues, codes::INDEX_JSONL_MISSING), "{issues:#?}");
5487    }
5488
5489    #[test]
5490    fn index_stale_entry_and_missing_entry() {
5491        let fx = Fixture::new();
5492        fx.write(
5493            "records/contacts/present.md",
5494            &valid_contact("present contact"),
5495        );
5496        // Indexes for the parents (root/layer) present so we isolate type-folder.
5497        fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5498        fx.write(
5499            "records/index.md",
5500            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5501        );
5502        // Type-folder index lists a GHOST (stale) and omits `present` (missing).
5503        fx.write(
5504            "records/contacts/index.md",
5505            "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/ghost]] — gone\n",
5506        );
5507        fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/present.md\",\"type\":\"contact\",\"summary\":\"present contact\"}\n");
5508        let issues = fx.store_all();
5509        let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5510        assert!(stale.message.contains("ghost"));
5511        assert!(stale.is_error());
5512        let missing = find(&issues, codes::INDEX_MISSING_ENTRY);
5513        assert!(
5514            missing.message.contains("present.md"),
5515            "{}",
5516            missing.message
5517        );
5518    }
5519
5520    #[test]
5521    fn index_md_entry_with_traversal_path_is_stale_not_probe() {
5522        let fx = Fixture::new();
5523        fx.write("records/contacts/a.md", &valid_contact("a"));
5524        fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5525        fx.write(
5526            "records/index.md",
5527            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5528        );
5529        fx.write(
5530            "records/contacts/index.md",
5531            "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/../../ghost]] — unsafe\n",
5532        );
5533        fx.write(
5534            "records/contacts/index.jsonl",
5535            "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5536        );
5537        let issues = fx.store_all();
5538        let stale = find(&issues, codes::INDEX_STALE_ENTRY);
5539        assert!(stale.message.contains("not a safe store-relative path"));
5540    }
5541
5542    #[test]
5543    fn index_summary_mismatch() {
5544        let fx = Fixture::new();
5545        fx.write("records/contacts/a.md", &valid_contact("the real summary"));
5546        fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5547        fx.write(
5548            "records/index.md",
5549            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5550        );
5551        fx.write(
5552            "records/contacts/index.md",
5553            "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a STALE summary\n",
5554        );
5555        fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"the real summary\"}\n");
5556        let issues = fx.store_all();
5557        let issue = find(&issues, codes::INDEX_SUMMARY_MISMATCH);
5558        assert!(issue.is_error());
5559        assert_eq!(issue.related, vec![PathBuf::from("records/contacts/a.md")]);
5560    }
5561
5562    #[test]
5563    fn index_summary_match_passes() {
5564        let fx = Fixture::new();
5565        fx.write("records/contacts/a.md", &valid_contact("matching summary"));
5566        fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5567        fx.write(
5568            "records/index.md",
5569            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5570        );
5571        fx.write(
5572            "records/contacts/index.md",
5573            "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — matching summary\n",
5574        );
5575        fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"matching summary\"}\n");
5576        let issues = fx.store_all();
5577        assert!(!has(&issues, codes::INDEX_SUMMARY_MISMATCH), "{issues:#?}");
5578    }
5579
5580    #[test]
5581    fn index_entry_with_tag_suffix_matches_summary() {
5582        let fx = Fixture::new();
5583        fx.write("records/contacts/a.md", &valid_contact("clean summary"));
5584        fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5585        fx.write(
5586            "records/index.md",
5587            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5588        );
5589        // Entry carries the renderer's `  ·  #tag` suffix (the EXACT double-spaced
5590        // delimiter `crate::index::format_md_entry` emits for a tagged file),
5591        // which must be stripped before comparing against the file's summary.
5592        fx.write(
5593            "records/contacts/index.md",
5594            "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — clean summary  ·  #customer\n",
5595        );
5596        fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"clean summary\"}\n");
5597        let issues = fx.store_all();
5598        assert!(
5599            !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5600            "tag suffix should be stripped: {issues:#?}"
5601        );
5602    }
5603
5604    #[test]
5605    fn index_entry_single_spaced_middot_tail_is_part_of_summary() {
5606        // Regression (the finding): a tagless file whose `summary` legitimately
5607        // ends in a single-spaced ` · #word` tail round-trips through `index
5608        // rebuild` verbatim (the renderer appends NO `  ·  #tag` block, since the
5609        // file has no tags). The validator must NOT mistake that single-spaced
5610        // tail for the renderer's tag suffix, or it reports a spurious — and
5611        // unfixable — INDEX_SUMMARY_MISMATCH on a freshly rebuilt store.
5612        let fx = Fixture::new();
5613        fx.write(
5614            "records/contacts/a.md",
5615            &valid_contact("Standup notes · #standup"),
5616        );
5617        fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5618        fx.write(
5619            "records/index.md",
5620            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5621        );
5622        fx.write(
5623            "records/contacts/index.md",
5624            "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — Standup notes · #standup\n",
5625        );
5626        fx.write("records/contacts/index.jsonl", "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"Standup notes · #standup\"}\n");
5627        let issues = fx.store_all();
5628        assert!(
5629            !has(&issues, codes::INDEX_SUMMARY_MISMATCH),
5630            "a single-spaced middot tail is part of the summary, not a tag block: {issues:#?}"
5631        );
5632    }
5633
5634    #[test]
5635    fn index_jsonl_desync_missing_file_in_jsonl() {
5636        let fx = Fixture::new();
5637        fx.write("records/contacts/a.md", &valid_contact("a"));
5638        fx.write("records/contacts/b.md", &valid_contact("b"));
5639        fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (2 files)\n");
5640        fx.write(
5641            "records/index.md",
5642            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5643        );
5644        fx.write(
5645            "records/contacts/index.md",
5646            "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n- [[records/contacts/b]] — b\n",
5647        );
5648        // jsonl only lists `a` → `b` is a desync (the twin must be complete).
5649        fx.write(
5650            "records/contacts/index.jsonl",
5651            "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5652        );
5653        let issues = fx.store_all();
5654        let desync = find(&issues, codes::INDEX_JSONL_DESYNC);
5655        assert!(desync.message.contains("b.md"), "{}", desync.message);
5656    }
5657
5658    #[test]
5659    fn index_jsonl_desync_record_points_at_missing_file() {
5660        let fx = Fixture::new();
5661        fx.write("records/contacts/a.md", &valid_contact("a"));
5662        fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5663        fx.write(
5664            "records/index.md",
5665            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5666        );
5667        fx.write(
5668            "records/contacts/index.md",
5669            "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5670        );
5671        fx.write(
5672            "records/contacts/index.jsonl",
5673            "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5674        );
5675        let issues = fx.store_all();
5676        assert!(
5677            issues
5678                .iter()
5679                .any(|i| i.code == codes::INDEX_JSONL_DESYNC && i.message.contains("ghost.md")),
5680            "{issues:#?}"
5681        );
5682    }
5683
5684    #[test]
5685    fn index_jsonl_record_with_traversal_path_is_desync_not_probe() {
5686        let fx = Fixture::new();
5687        fx.write("records/contacts/a.md", &valid_contact("a"));
5688        fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5689        fx.write(
5690            "records/index.md",
5691            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5692        );
5693        fx.write(
5694            "records/contacts/index.md",
5695            "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n",
5696        );
5697        fx.write(
5698            "records/contacts/index.jsonl",
5699            "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n{\"path\":\"records/contacts/../../ghost.md\",\"type\":\"contact\",\"summary\":\"x\"}\n",
5700        );
5701        let issues = fx.store_all();
5702        assert!(
5703            issues.iter().any(|i| i.code == codes::INDEX_JSONL_DESYNC
5704                && i.message.contains("not a safe store-relative path")),
5705            "{issues:#?}"
5706        );
5707    }
5708
5709    #[test]
5710    fn index_jsonl_stale_summary() {
5711        let fx = Fixture::new();
5712        fx.write("records/contacts/a.md", &valid_contact("real summary"));
5713        fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5714        fx.write(
5715            "records/index.md",
5716            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5717        );
5718        fx.write(
5719            "records/contacts/index.md",
5720            "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — real summary\n",
5721        );
5722        // jsonl summary disagrees with the file frontmatter.
5723        fx.write(
5724            "records/contacts/index.jsonl",
5725            "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"OUTDATED\"}\n",
5726        );
5727        let issues = fx.store_all();
5728        let stale = find(&issues, codes::INDEX_JSONL_STALE);
5729        assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5730        assert!(stale.key.as_deref().unwrap().contains("summary"));
5731    }
5732
5733    /// The whole point of `INDEX_JSONL_STALE`: a sidecar field the query/search
5734    /// path actually reads (`email`, `domain`, the `(date,amount,vendor)` dedup
5735    /// tuple, `tags`, `updated`, `links`, `company` …) that disagrees with the
5736    /// `.md` is STALE — even when `summary` and `type` are perfectly correct.
5737    /// Pre-fix the validator only diffed summary+type, so a sidecar with a wrong
5738    /// `email` validated clean and answered `--where email=…` with a phantom
5739    /// value present in no file. This is the direct regression guard.
5740    #[test]
5741    fn index_jsonl_stale_queryable_field_email() {
5742        let fx = Fixture::new();
5743        let contact = "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"a contact\"\nname: A\nemail: real@correct.com\n---\n\n# A\n";
5744        fx.write("records/contacts/a.md", contact);
5745        // Start from the canonical, fully-correct sidecar set …
5746        fx.rebuild_indexes();
5747        let jsonl_path = fx.dir.path().join("records/contacts/index.jsonl");
5748        let good = fs::read_to_string(&jsonl_path).unwrap();
5749        // sanity: the canonical store is clean (no STALE on a fresh rebuild).
5750        assert!(
5751            !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5752            "freshly-rebuilt sidecar must not be stale"
5753        );
5754        // … then desync ONLY the email so it's the single differing field.
5755        assert!(
5756            good.contains("real@correct.com"),
5757            "sidecar projects email: {good}"
5758        );
5759        fx.write(
5760            "records/contacts/index.jsonl",
5761            &good.replace("real@correct.com", "STALE-WRONG@evil.com"),
5762        );
5763
5764        let issues = fx.store_all();
5765        let stale = find(&issues, codes::INDEX_JSONL_STALE);
5766        assert_eq!(stale.related, vec![PathBuf::from("records/contacts/a.md")]);
5767        // The mismatch is reported precisely on `email`, and summary/type — which
5768        // still match — are NOT named.
5769        let key = stale.key.as_deref().unwrap();
5770        assert!(
5771            key.contains("email"),
5772            "expected `email` in stale key, got {key:?}"
5773        );
5774        assert!(!key.contains("summary"), "summary still matches: {key:?}");
5775        assert!(!key.contains("type"), "type still matches: {key:?}");
5776    }
5777
5778    /// Broaden the guard across the typed/list/timestamp projections at once:
5779    /// a wrong `tags`, `updated`, and a custom dedup field (`amount`) are each
5780    /// caught, with all three named in one issue.
5781    #[test]
5782    fn index_jsonl_stale_typed_and_list_fields() {
5783        let fx = Fixture::new();
5784        let expense = "---\ntype: expense\ncreated: 2026-05-20T08:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: \"office chairs\"\ntags: [furniture, q2]\namount: 1299\nvendor: Acme\ndate: 2026-05-20\n---\n\n# Expense\n";
5785        fx.write("records/expenses/e.md", expense);
5786        fx.rebuild_indexes();
5787        let jsonl_path = fx.dir.path().join("records/expenses/index.jsonl");
5788        let good = fs::read_to_string(&jsonl_path).unwrap();
5789        assert!(
5790            !has(&fx.store_all(), codes::INDEX_JSONL_STALE),
5791            "freshly-rebuilt sidecar must not be stale"
5792        );
5793        // Desync a list field (tags), a timestamp (updated), and a number (amount).
5794        let stale_line = good
5795            .replace("\"q2\"", "\"WRONG-TAG\"")
5796            .replace("2026-05-22T10:00:00-07:00", "2099-01-01T00:00:00-07:00")
5797            .replace("1299", "9999");
5798        fx.write("records/expenses/index.jsonl", &stale_line);
5799
5800        let issues = fx.store_all();
5801        let stale = find(&issues, codes::INDEX_JSONL_STALE);
5802        let key = stale.key.as_deref().unwrap();
5803        for expected in ["amount", "tags", "updated"] {
5804            assert!(
5805                key.contains(expected),
5806                "expected `{expected}` in stale key, got {key:?}"
5807            );
5808        }
5809    }
5810
5811    #[test]
5812    fn index_orphan_in_noncanonical_folder() {
5813        let fx = Fixture::new();
5814        fx.write("records/contacts/a.md", &valid_contact("a"));
5815        // Build the canonical indexes so they aren't reported as orphans.
5816        fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5817        fx.write(
5818            "records/index.md",
5819            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5820        );
5821        fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5822        fx.write(
5823            "records/contacts/index.jsonl",
5824            "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5825        );
5826        // An index.md inside a sub-sub-folder (operator territory) is an orphan.
5827        fx.write(
5828            "records/contacts/subfolder/index.md",
5829            "---\ntype: index\nscope: type-folder\n---\n\n# stray\n",
5830        );
5831        let issues = fx.store_all();
5832        let orphan = find(&issues, codes::INDEX_ORPHAN);
5833        assert_eq!(orphan.severity, Severity::Warning);
5834        assert_eq!(
5835            orphan.file,
5836            PathBuf::from("records/contacts/subfolder/index.md")
5837        );
5838    }
5839
5840    #[test]
5841    fn index_wrong_scope() {
5842        let fx = Fixture::new();
5843        fx.write("records/contacts/a.md", &valid_contact("a"));
5844        // Root index declares the wrong scope.
5845        fx.write("index.md", "---\ntype: index\nscope: layer\n---\n\n## Records\n- [[records/contacts/index|C]] (1 files)\n");
5846        fx.write(
5847            "records/index.md",
5848            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5849        );
5850        fx.write("records/contacts/index.md", "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/a]] — a\n");
5851        fx.write(
5852            "records/contacts/index.jsonl",
5853            "{\"path\":\"records/contacts/a.md\",\"type\":\"contact\",\"summary\":\"a\"}\n",
5854        );
5855        let issues = fx.store_all();
5856        let issue = find(&issues, codes::INDEX_WRONG_SCOPE);
5857        assert_eq!(issue.severity, Severity::Warning);
5858        assert_eq!(issue.file, PathBuf::from("index.md"));
5859    }
5860
5861    #[test]
5862    fn capped_type_folder_index_does_not_flag_missing_entries() {
5863        // Over the 500-entry cap, omitted entries are expected, not an error.
5864        let fx = Fixture::new();
5865        for i in 0..501 {
5866            fx.write(
5867                &format!("records/contacts/c{i:04}.md"),
5868                &valid_contact(&format!("contact {i}")),
5869            );
5870        }
5871        fx.write("index.md", "---\ntype: index\nscope: root\n---\n\n## Records\n- [[records/contacts/index|C]] (501 files)\n");
5872        fx.write(
5873            "records/index.md",
5874            "---\ntype: index\nscope: layer\nfolder: records\n---\n# r\n",
5875        );
5876        // Type-folder index lists only ONE entry + a More footer.
5877        fx.write(
5878            "records/contacts/index.md",
5879            "---\ntype: index\nscope: type-folder\nfolder: records/contacts\n---\n\n- [[records/contacts/c0000]] — contact 0\n\n## More\n\nThis folder has 501 files.\n",
5880        );
5881        // jsonl must still be complete — write all 501 lines.
5882        let mut jsonl = String::new();
5883        for i in 0..501 {
5884            jsonl.push_str(&format!(
5885                "{{\"path\":\"records/contacts/c{i:04}.md\",\"type\":\"contact\",\"summary\":\"contact {i}\"}}\n"
5886            ));
5887        }
5888        fx.write("records/contacts/index.jsonl", &jsonl);
5889        let issues = fx.store_all();
5890        assert!(
5891            !has(&issues, codes::INDEX_MISSING_ENTRY),
5892            "over the cap, missing browse entries are expected: {issues:#?}"
5893        );
5894        // But the jsonl is complete → no desync.
5895        assert!(
5896            !has(&issues, codes::INDEX_JSONL_DESYNC),
5897            "{:#?}",
5898            issues
5899                .iter()
5900                .filter(|i| i.code == codes::INDEX_JSONL_DESYNC)
5901                .collect::<Vec<_>>()
5902        );
5903    }
5904
5905    // ── log ────────────────────────────────────────────────────────────────
5906
5907    #[test]
5908    fn log_bad_timestamp_unknown_kind_out_of_order() {
5909        let fx = Fixture::new();
5910        fx.write(
5911            "log.md",
5912            concat!(
5913                "---\ntype: log\n---\n\n# Log\n\n",
5914                "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
5915                "## [2026-05-27 09:00] update | records/contacts/b\nx\n\n", // out of order
5916                "## [2026-05-27 11:00] frobnicate | records/contacts/c\nx\n\n", // unknown kind
5917                "## [not-a-date] create | records/contacts/d\nx\n",         // bad timestamp
5918            ),
5919        );
5920        let issues = fx.store_all();
5921        assert!(has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
5922        assert_eq!(
5923            find(&issues, codes::LOG_OUT_OF_ORDER).severity,
5924            Severity::Warning
5925        );
5926        let unknown = find(&issues, codes::LOG_UNKNOWN_KIND);
5927        assert_eq!(unknown.severity, Severity::Warning);
5928        assert!(unknown.message.contains("frobnicate"));
5929        assert!(unknown
5930            .suggestion
5931            .as_deref()
5932            .is_some_and(|s| s.contains("create")));
5933        let bad = find(&issues, codes::LOG_BAD_TIMESTAMP);
5934        assert!(bad.is_error());
5935    }
5936
5937    #[test]
5938    fn log_validate_entry_without_object_is_well_formed() {
5939        let fx = Fixture::new();
5940        fx.write(
5941            "log.md",
5942            "---\ntype: log\n---\n\n## [2026-05-27 10:00] validate\nPASS\n",
5943        );
5944        let issues = fx.store_all();
5945        assert!(!has(&issues, codes::LOG_BAD_TIMESTAMP), "{issues:#?}");
5946        assert!(!has(&issues, codes::LOG_UNKNOWN_KIND), "{issues:#?}");
5947    }
5948
5949    #[test]
5950    fn log_in_order_is_clean() {
5951        let fx = Fixture::new();
5952        fx.write(
5953            "log.md",
5954            concat!(
5955                "---\ntype: log\n---\n\n",
5956                "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
5957                "## [2026-05-27 10:05] update | records/contacts/a\nx\n",
5958            ),
5959        );
5960        let issues = fx.store_all();
5961        assert!(!has(&issues, codes::LOG_OUT_OF_ORDER), "{issues:#?}");
5962    }
5963
5964    #[test]
5965    fn log_not_checked_in_working_set() {
5966        // log.md ordering is an --all-only check.
5967        let fx = Fixture::new();
5968        fx.write(
5969            "log.md",
5970            concat!(
5971                "---\ntype: log\n---\n\n",
5972                "## [2026-05-27 10:00] create | records/contacts/a\nx\n\n",
5973                "## [2026-05-27 09:00] update | records/contacts/a\nx\n",
5974            ),
5975        );
5976        let issues = validate_working_set(&fx.store(), None).unwrap();
5977        assert!(
5978            !has(&issues, codes::LOG_OUT_OF_ORDER),
5979            "log ordering is --all only: {issues:#?}"
5980        );
5981    }
5982
5983    // ── working-set scoping ───────────────────────────────────────────────────
5984
5985    #[test]
5986    fn working_set_validates_only_changed_files() {
5987        let fx = Fixture::new();
5988        // `dirty` has a bad timestamp; `clean_but_unlogged` also does but is NOT
5989        // in the log → working set must skip it.
5990        fx.write(
5991            "records/contacts/dirty.md",
5992            "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
5993        );
5994        fx.write(
5995            "records/contacts/unlogged.md",
5996            "---\ntype: contact\ncreated: ALSO-BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
5997        );
5998        fx.write(
5999            "log.md",
6000            "---\ntype: log\n---\n\n## [2026-05-22 10:00] update | records/contacts/dirty\nedited\n",
6001        );
6002        let issues = validate_working_set(&fx.store(), None).unwrap();
6003        assert!(
6004            issues.iter().any(|i| i.code == codes::FM_BAD_TIMESTAMP
6005                && i.file == Path::new("records/contacts/dirty.md")),
6006            "{issues:#?}"
6007        );
6008        assert!(
6009            !issues
6010                .iter()
6011                .any(|i| i.file == Path::new("records/contacts/unlogged.md")),
6012            "unlogged file must not be in the working set: {issues:#?}"
6013        );
6014    }
6015
6016    #[test]
6017    fn working_set_includes_incoming_linkers_to_changed_path() {
6018        let fx = Fixture::new();
6019        // `changed` was renamed/removed (logged). `linker` points at it with a
6020        // now-broken link and was NOT itself logged — but must be pulled in.
6021        fx.write(
6022            "records/profiles/linker.md",
6023            "---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: links to a removed page\n---\n\nSee [[records/contacts/changed]].\n",
6024        );
6025        // `changed.md` does NOT exist on disk (removed).
6026        fx.write(
6027            "log.md",
6028            "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/changed\nremoved\n",
6029        );
6030        let issues = validate_working_set(&fx.store(), None).unwrap();
6031        assert!(
6032            issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN
6033                && i.file == Path::new("records/profiles/linker.md")),
6034            "incoming linker to a removed path must be validated: {issues:#?}"
6035        );
6036    }
6037
6038    #[test]
6039    fn working_set_respects_explicit_since_cutoff() {
6040        let fx = Fixture::new();
6041        fx.write(
6042            "records/contacts/old.md",
6043            "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6044        );
6045        fx.write(
6046            "records/contacts/new.md",
6047            "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6048        );
6049        fx.write(
6050            "log.md",
6051            concat!(
6052                "---\ntype: log\n---\n\n",
6053                "## [2026-05-20 10:00] update | records/contacts/old\nx\n\n",
6054                "## [2026-05-25 10:00] update | records/contacts/new\nx\n",
6055            ),
6056        );
6057        // Cutoff after `old` but before `new`.
6058        let since = DateTime::parse_from_rfc3339("2026-05-22T00:00:00+00:00").unwrap();
6059        let issues = validate_working_set(&fx.store(), Some(since)).unwrap();
6060        assert!(
6061            issues
6062                .iter()
6063                .any(|i| i.file == Path::new("records/contacts/new.md")),
6064            "{issues:#?}"
6065        );
6066        assert!(
6067            !issues
6068                .iter()
6069                .any(|i| i.file == Path::new("records/contacts/old.md")),
6070            "old change is before the cutoff: {issues:#?}"
6071        );
6072    }
6073
6074    #[test]
6075    fn working_set_default_since_is_last_validate_entry() {
6076        let fx = Fixture::new();
6077        // `before` changed before the last validate; `after` changed after.
6078        fx.write(
6079            "records/contacts/before.md",
6080            "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6081        );
6082        fx.write(
6083            "records/contacts/after.md",
6084            "---\ntype: contact\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: B\n---\n\n# B\n",
6085        );
6086        fx.write(
6087            "log.md",
6088            concat!(
6089                "---\ntype: log\n---\n\n",
6090                "## [2026-05-20 10:00] update | records/contacts/before\nx\n\n",
6091                "## [2026-05-21 10:00] validate\nPASS\n\n",
6092                "## [2026-05-22 10:00] update | records/contacts/after\nx\n",
6093            ),
6094        );
6095        let issues = validate_working_set(&fx.store(), None).unwrap();
6096        assert!(
6097            issues
6098                .iter()
6099                .any(|i| i.file == Path::new("records/contacts/after.md")),
6100            "{issues:#?}"
6101        );
6102        assert!(
6103            !issues
6104                .iter()
6105                .any(|i| i.file == Path::new("records/contacts/before.md")),
6106            "change before the last validate entry is outside the default window: {issues:#?}"
6107        );
6108    }
6109
6110    // ── ordering / determinism ────────────────────────────────────────────────
6111
6112    #[test]
6113    fn issues_are_sorted_by_file_then_line() {
6114        let fx = Fixture::new();
6115        fx.write("records/profiles/z.md", "---\ntype: profile\nmeta-type: conclusion\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n");
6116        fx.write("records/profiles/a.md", "---\ntype: profile\nmeta-type: conclusion\ncreated: BAD\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nbody\n");
6117        let issues = fx.store_all();
6118        let files: Vec<&PathBuf> = issues.iter().map(|i| &i.file).collect();
6119        let mut sorted = files.clone();
6120        sorted.sort();
6121        assert_eq!(
6122            files, sorted,
6123            "issues must be emitted in a stable file order"
6124        );
6125    }
6126
6127    // ── boundaries: codes validate must NOT emit ──────────────────────────────
6128
6129    #[test]
6130    fn frozen_page_is_not_a_validate_error() {
6131        // POLICY_FROZEN_PAGE is a *write-time* refusal, never a validate finding.
6132        // A clean file listed in `### Frozen pages` must validate clean.
6133        let mut fx = Fixture::new();
6134        fx.config
6135            .frozen_pages
6136            .push(PathBuf::from("records/decisions/d.md"));
6137        fx.write(
6138            "records/decisions/d.md",
6139            "---\ntype: decision\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a finalized decision\n---\n\n# D\n",
6140        );
6141        let issues = fx.store_all();
6142        assert!(
6143            !has(&issues, codes::POLICY_FROZEN_PAGE),
6144            "frozen pages are enforced at write-time, not by validate: {issues:#?}"
6145        );
6146    }
6147
6148    #[test]
6149    fn wiki_link_ambiguous_is_never_emitted_under_full_path_doctrine() {
6150        // The full-path doctrine makes ambiguity impossible; the defensive code
6151        // must never fire on a normal store.
6152        let fx = Fixture::new();
6153        fx.write("records/contacts/sarah-chen.md", &valid_contact("sarah"));
6154        let mut body = valid_contact("links to sarah");
6155        body.push_str("\nSee [[records/contacts/sarah-chen]].\n");
6156        fx.write("records/contacts/p.md", &body);
6157        let issues = fx.store_all();
6158        assert!(!has(&issues, codes::WIKI_LINK_AMBIGUOUS), "{issues:#?}");
6159    }
6160
6161    // ── unknown-type / unknown-field passthrough ──────────────────────────────
6162
6163    #[test]
6164    fn unknown_type_passes_through() {
6165        // A custom type is ambient context: it has a `type`, so no
6166        // FM_MISSING_TYPE, and with no matching schema there are no schema
6167        // errors. Only the universal contract (summary, timestamps) applies.
6168        let fx = Fixture::new();
6169        fx.write(
6170            "records/proposals/x.md",
6171            "---\ntype: proposal\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: a proposal\ncustom_field: anything\nbudget: 5000\n---\n\n# Proposal\n",
6172        );
6173        let issues = fx.store_all();
6174        assert!(!has(&issues, codes::FM_MISSING_TYPE), "{issues:#?}");
6175        assert!(!has(&issues, codes::SCHEMA_MISSING_REQUIRED), "{issues:#?}");
6176        assert!(!has(&issues, codes::SCHEMA_SHAPE_MISMATCH), "{issues:#?}");
6177        // The unknown fields don't trip anything.
6178        assert!(
6179            !issues
6180                .iter()
6181                .any(|i| i.key.as_deref() == Some("custom_field")
6182                    || i.key.as_deref() == Some("budget")),
6183            "unknown fields are ambient context: {issues:#?}"
6184        );
6185    }
6186
6187    // ── find_links_to prefix-collision safety (working set) ───────────────────
6188
6189    #[test]
6190    fn incoming_linker_scan_does_not_prefix_match() {
6191        // A changed `records/contacts/sarah` must NOT pull in a file that only
6192        // links to `records/contacts/sarah-chen` (a longer path sharing a prefix).
6193        let fx = Fixture::new();
6194        fx.write(
6195            "records/profiles/only-sarah-chen.md",
6196            "---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nSee [[records/contacts/sarah-chen]].\n",
6197        );
6198        // The log says `records/contacts/sarah` (the shorter path) changed.
6199        fx.write(
6200            "log.md",
6201            "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah\nremoved\n",
6202        );
6203        let issues = validate_working_set(&fx.store(), None).unwrap();
6204        assert!(
6205            !issues
6206                .iter()
6207                .any(|i| i.file == Path::new("records/profiles/only-sarah-chen.md")),
6208            "a prefix-sharing link must not pull a file into the working set: {issues:#?}"
6209        );
6210    }
6211
6212    #[test]
6213    fn working_set_does_not_flag_stale_catalog_index_as_wiki_link_broken() {
6214        // The working-set incoming-linker scan rides embedded-ripgrep
6215        // `Store::find_links_to`, which scans EVERY `.md` — so a type-folder
6216        // `index.md` listing a now-deleted target IS pulled into the working set.
6217        // But its entries are GENERATED catalog entries, not authored body links:
6218        // a dangling one is an `INDEX_STALE_ENTRY` ("run `dbmd index rebuild`"),
6219        // the job of `check_indexes` under `--all` — NOT a `WIKI_LINK_BROKEN`
6220        // ("create the target"), whose remedy would steer an agent to recreate
6221        // the very data it just deleted. The loop default must therefore NOT
6222        // body-link-check the derived catalog (index integrity is an O(store)
6223        // sweep concern, not an O(changed) loop concern). Adversarial review #11:
6224        // the prior behavior gave WIKI_LINK_BROKEN here while `--all` gave
6225        // INDEX_STALE_ENTRY for the identical condition — two codes, opposite
6226        // remedies, across the loop default vs the sweep.
6227        let fx = Fixture::new();
6228        // A catalog that still lists the deleted contact (a real, common stale
6229        // state after an out-of-band `delete`).
6230        fx.write(
6231            "records/contacts/index.md",
6232            "---\ntype: index\n---\n\n- [[records/contacts/sarah-chen]] — Sarah Chen\n",
6233        );
6234        // The log says `records/contacts/sarah-chen` was deleted.
6235        fx.write(
6236            "log.md",
6237            "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah-chen\nremoved\n",
6238        );
6239        let issues = validate_working_set(&fx.store(), None).unwrap();
6240        assert!(
6241            !issues
6242                .iter()
6243                .any(|i| i.file == Path::new("records/contacts/index.md")
6244                    && i.code == codes::WIKI_LINK_BROKEN),
6245            "a stale catalog `index.md` entry must NOT be WIKI_LINK_BROKEN in the \
6246             working set (it is an INDEX_STALE_ENTRY under `--all`): {issues:#?}"
6247        );
6248    }
6249
6250    #[test]
6251    fn incoming_linker_scan_covers_the_whole_changed_set_in_one_pass() {
6252        // CONTRACT (the O(changed × store) fix): the working-set scan finds
6253        // incoming linkers for EVERY changed object, and does so via the single
6254        // batch pass `Store::find_links_to_any` — not one full store read per
6255        // changed object. This test pins the behavior that makes the single-pass
6256        // correct: with two DISTINCT deleted targets, the linker to EACH is pulled
6257        // into the working set and flagged. A regression that scanned for only the
6258        // first/last changed object, or that dropped the batch union, would leave
6259        // one of the two broken links unreported and fail here.
6260        let fx = Fixture::new();
6261        // Linker A → deleted target #1 (in the body).
6262        fx.write(
6263            "records/profiles/refers-sarah.md",
6264            "---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\n---\n\nSee [[records/contacts/sarah-chen]].\n",
6265        );
6266        // Linker B → deleted target #2 (in a typed frontmatter field — an edge the
6267        // sidecar `links` projection would miss, which is why this must be a
6268        // content scan, not a sidecar read).
6269        fx.write(
6270            "records/meetings/2026/05/kickoff.md",
6271            "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: m\ndate: 2026-05-01\ncompany: \"[[records/companies/acme]]\"\n---\n\n# Kickoff\n",
6272        );
6273        // The log says BOTH targets were deleted in this window.
6274        fx.write(
6275            "log.md",
6276            "---\ntype: log\n---\n\n## [2026-05-22 10:00] delete | records/contacts/sarah-chen\nremoved\n\n## [2026-05-22 10:05] delete | records/companies/acme\nremoved\n",
6277        );
6278
6279        let issues = validate_working_set(&fx.store(), None).unwrap();
6280        assert!(
6281            issues
6282                .iter()
6283                .any(|i| i.file == Path::new("records/profiles/refers-sarah.md")
6284                    && i.code == codes::WIKI_LINK_BROKEN),
6285            "linker to the FIRST deleted target must be pulled in and flagged: {issues:#?}"
6286        );
6287        assert!(
6288            issues.iter().any(
6289                |i| i.file == Path::new("records/meetings/2026/05/kickoff.md")
6290                    && i.code == codes::WIKI_LINK_BROKEN
6291            ),
6292            "linker to the SECOND deleted target (typed-field edge) must also be \
6293             pulled in and flagged — proves the scan covers the whole changed set, \
6294             not just one object: {issues:#?}"
6295        );
6296    }
6297
6298    #[test]
6299    fn frontmatter_block_sequence_links_each_get_their_own_line() {
6300        // Each block-sequence wiki-link reports on its own source line.
6301        let fx = Fixture::new();
6302        // Neither target exists → two WIKI_LINK_BROKEN, on different lines.
6303        fx.write(
6304            "records/meetings/m.md",
6305            "---\ntype: meeting\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: m\ndate: 2026-05-01\nparticipants:\n  - [[records/contacts/ghost1]]\n  - [[records/contacts/ghost2]]\n---\n\n# M\n",
6306        );
6307        let issues = fx.store_all();
6308        let broken_lines: BTreeSet<Option<u32>> = issues
6309            .iter()
6310            .filter(|i| i.code == codes::WIKI_LINK_BROKEN)
6311            .map(|i| i.line)
6312            .collect();
6313        assert_eq!(
6314            broken_lines.len(),
6315            2,
6316            "two distinct broken-link lines: {issues:#?}"
6317        );
6318    }
6319
6320    // ── Regression: null / non-scalar created/updated ────────────────────────
6321
6322    #[test]
6323    fn null_created_is_missing_not_silently_passed() {
6324        // Regression: a present-but-`null` `created:` previously slipped past
6325        // both FM_MISSING_CREATED (only `!contains_key` was checked) and
6326        // FM_BAD_TIMESTAMP (`scalar_string(null)` is None → branch no-oped).
6327        let fx = Fixture::new();
6328        fx.write(
6329            "records/contacts/a.md",
6330            "---\ntype: contact\ncreated:\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6331        );
6332        let issues = fx.store_all();
6333        assert!(
6334            has(&issues, codes::FM_MISSING_CREATED),
6335            "null `created:` must read as missing: {issues:#?}"
6336        );
6337    }
6338
6339    #[test]
6340    fn sequence_created_is_bad_timestamp() {
6341        // A non-scalar `created: [2026]` is not a timestamp string → FM_BAD_TIMESTAMP.
6342        let fx = Fixture::new();
6343        fx.write(
6344            "records/contacts/a.md",
6345            "---\ntype: contact\ncreated: [2026]\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\n# A\n",
6346        );
6347        let issues = fx.store_all();
6348        assert!(
6349            issues
6350                .iter()
6351                .any(|i| i.code == codes::FM_BAD_TIMESTAMP && i.key.as_deref() == Some("created")),
6352            "a sequence `created:` must be FM_BAD_TIMESTAMP: {issues:#?}"
6353        );
6354    }
6355
6356    // ── Regression: schema required null / empty-collection ──────────────────
6357
6358    #[test]
6359    fn required_field_null_or_empty_collection_is_missing() {
6360        // Regression: a plain required field (no shape/enum) holding YAML null
6361        // (`name:`), an empty list (`name: []`), or an empty mapping (`name: {}`)
6362        // previously validated with 0 issues — `scalar_string` returned None and
6363        // `.unwrap_or(false)` treated the value as non-empty.
6364        for value in ["", " []", " {}"] {
6365            let mut fx = Fixture::new();
6366            fx.config.schemas.insert(
6367                "contact".into(),
6368                Schema {
6369                    fields: vec![FieldSpec {
6370                        name: "name".into(),
6371                        required: true,
6372                        ..Default::default()
6373                    }],
6374                    ..Default::default()
6375                },
6376            );
6377            fx.write(
6378                "records/contacts/a.md",
6379                &format!(
6380                    "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname:{value}\n---\n\n# A\n"
6381                ),
6382            );
6383            let issues = fx.store_all();
6384            assert!(
6385                issues
6386                    .iter()
6387                    .any(|i| i.code == codes::SCHEMA_MISSING_REQUIRED
6388                        && i.key.as_deref() == Some("name")),
6389                "required `name:{value}` must be SCHEMA_MISSING_REQUIRED: {issues:#?}"
6390            );
6391        }
6392    }
6393
6394    // ── Regression: WIKI_LINK_BROKEN on raw source files ─────────────────────
6395
6396    #[test]
6397    fn wiki_link_to_raw_source_file_resolves() {
6398        // Regression: a body link to a raw `.eml`/`.pdf` source kept verbatim
6399        // under `sources/` was flagged WIKI_LINK_BROKEN because the existence
6400        // probe only ever stat'd `{bare}.md`. It must resolve the literal path.
6401        let fx = Fixture::new();
6402        fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6403        fx.write(
6404            "records/contacts/a.md",
6405            "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\nSee [[sources/emails/2026-05-22-elena.eml]] for context.\n",
6406        );
6407        let issues = fx.store_all();
6408        assert!(
6409            !issues.iter().any(|i| i.code == codes::WIKI_LINK_BROKEN),
6410            "a link to an existing raw source file must not be broken: {issues:#?}"
6411        );
6412    }
6413
6414    // ── Regression: wrong-case wiki-link must be platform-independent ─────────
6415
6416    #[test]
6417    fn wrong_case_wiki_link_is_broken_exact_case() {
6418        // Regression (cross-platform false-negative): on case-insensitive
6419        // APFS/macOS, `Path::is_file()` resolves `[[records/contacts/BOB]]` to the
6420        // on-disk `bob.md`, so validate passed — but on case-sensitive Linux that
6421        // file does not exist (WIKI_LINK_BROKEN). Existence resolution is now
6422        // exact-case, so a wrong-case target is flagged on every platform.
6423        let fx = Fixture::new();
6424        fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6425        let mut body = valid_contact("links with the wrong case");
6426        body.push_str("\nKnows [[records/contacts/BOB]].\n");
6427        fx.write("records/contacts/alice.md", &body);
6428        let issues = fx.store_all();
6429        let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6430        assert!(issue.is_error());
6431        assert!(
6432            issue.message.contains("records/contacts/BOB"),
6433            "the wrong-case target must be named in the issue: {issues:#?}"
6434        );
6435    }
6436
6437    #[test]
6438    fn correct_case_wiki_link_still_resolves() {
6439        // The companion to the exact-case fix: a *correct*-case lowercase link to
6440        // the same on-disk file must STILL resolve clean. Only a genuine case
6441        // mismatch is newly flagged; correct case is never a false positive.
6442        let fx = Fixture::new();
6443        fx.write("records/contacts/bob.md", &valid_contact("Bob"));
6444        let mut body = valid_contact("links with the right case");
6445        body.push_str("\nKnows [[records/contacts/bob]].\n");
6446        fx.write("records/contacts/alice.md", &body);
6447        let issues = fx.store_all();
6448        assert!(
6449            !issues
6450                .iter()
6451                .any(|i| i.code == codes::WIKI_LINK_BROKEN && i.message.contains("contacts/bob")),
6452            "a correct-case link must resolve clean: {issues:#?}"
6453        );
6454    }
6455
6456    #[test]
6457    fn wrong_case_raw_source_wiki_link_is_broken() {
6458        // The literal-path candidate (raw `.eml`/`.pdf` sources kept verbatim)
6459        // gets the same exact-case treatment as the `.md`-appended candidate: a
6460        // wrong-case link to a raw source is broken on a case-sensitive host, so
6461        // it must flag on macOS too.
6462        let fx = Fixture::new();
6463        fx.write("sources/emails/2026-05-22-elena.eml", "raw email bytes\n");
6464        fx.write(
6465            "records/contacts/a.md",
6466            "---\ntype: contact\ncreated: 2026-05-22T10:00:00-07:00\nupdated: 2026-05-22T10:00:00-07:00\nsummary: x\nname: A\n---\n\nSee [[sources/emails/2026-05-22-ELENA.eml]] for context.\n",
6467        );
6468        let issues = fx.store_all();
6469        let issue = find(&issues, codes::WIKI_LINK_BROKEN);
6470        assert!(issue.is_error());
6471        assert!(
6472            issue.message.contains("2026-05-22-ELENA.eml"),
6473            "the wrong-case raw-source target must be flagged: {issues:#?}"
6474        );
6475    }
6476
6477    // ── Regression: unreadable (non-UTF-8) content file ──────────────────────
6478
6479    #[test]
6480    fn non_utf8_content_file_is_reported() {
6481        // Regression: a content file with invalid UTF-8 bytes made
6482        // check_content_file return None silently, so the store passed with exit
6483        // 0. It must surface FM_UNREADABLE instead of passing vacuously.
6484        let fx = Fixture::new();
6485        let abs = fx.dir.path().join("records/notes/corrupt.md");
6486        fs::create_dir_all(abs.parent().unwrap()).unwrap();
6487        fs::write(&abs, [0xFF, 0xFE, 0x00, 0x01]).unwrap();
6488        let issues = validate_working_set(&fx.store(), None).unwrap();
6489        assert!(
6490            has(&issues, codes::FM_UNREADABLE),
6491            "an unreadable content file must be reported, not silently skipped: {issues:#?}"
6492        );
6493    }
6494
6495    // ── Regression: code-fence char/run tracking ─────────────────────────────
6496
6497    #[test]
6498    fn tilde_fence_containing_backtick_fence_does_not_invert() {
6499        // Regression: a `~~~` block legally contains ``` lines (documenting a
6500        // backtick fence); a naive toggle inverted `in_fence` and checked the
6501        // demo `[[fake]]` inside the code block as a live link. The link inside
6502        // BOTH fences must be skipped.
6503        let body = "~~~markdown\n```\n[[fake-link]]\n```\n~~~\n";
6504        let links = extract_wiki_links(body);
6505        assert!(
6506            links.is_empty(),
6507            "wiki-link inside a nested code fence must be skipped: {links:?}"
6508        );
6509    }
6510
6511    // ── Regression: --all skips in-layer `log/` folder ───────────────────────
6512
6513    #[test]
6514    fn all_sweep_visits_in_layer_log_folder() {
6515        // Regression: `validate --all` pruned every dir named `log`, so a real
6516        // content folder like `records/log/` was invisible to the full sweep —
6517        // reporting FEWER errors than the default scope. A frontmatter-less file
6518        // there must still surface FM_MISSING_TYPE under --all.
6519        let fx = Fixture::new();
6520        fx.write("records/log/2026-06-01-pricing.md", "no frontmatter here\n");
6521        let issues = fx.store_all();
6522        assert!(
6523            has(&issues, codes::FM_MISSING_TYPE),
6524            "--all must validate files under an in-layer `log/` folder: {issues:#?}"
6525        );
6526    }
6527
6528    // ── Regression: flow-form list with whitespace ───────────────────────────
6529
6530    #[test]
6531    fn flow_form_link_list_with_spaces_is_flagged() {
6532        // Regression: `attendees: [ [[a]] ]` parses to the same nested-sequence
6533        // mis-encoding as `[[[a]]]` but evaded the literal `starts_with("[[[")`
6534        // text test. The value-based detector must catch the whitespace variant.
6535        let keys = detect_flow_form_link_lists("attendees: [ [[records/contacts/elena]] ]\n");
6536        assert!(
6537            keys.iter().any(|k| k == "attendees"),
6538            "spaced flow-form list must be detected: {keys:?}"
6539        );
6540    }
6541
6542    // ── Regression: INDEX_SUMMARY_MISMATCH middot tail ───────────────────────
6543
6544    #[test]
6545    fn middot_hashtag_summary_tail_round_trips() {
6546        // Regression: a tagless summary that legitimately ends in a single-spaced
6547        // ` · #word` tail round-trips through the renderer verbatim, but the loose
6548        // ` · ` strip mistook it for the tag block and reported a spurious,
6549        // unfixable INDEX_SUMMARY_MISMATCH. The strip must use the renderer's
6550        // exact double-spaced `  ·  ` delimiter.
6551        assert_eq!(
6552            extract_index_entry_summary("— Standup notes · #standup").as_deref(),
6553            Some("Standup notes · #standup"),
6554            "a single-spaced middot tail is part of the summary, not a tag block"
6555        );
6556        // The renderer's real double-spaced tag suffix IS still stripped.
6557        assert_eq!(
6558            extract_index_entry_summary("— Renewal champion  ·  #renewal #acme").as_deref(),
6559            Some("Renewal champion"),
6560            "the renderer's double-spaced `  ·  #tag` suffix is stripped"
6561        );
6562    }
6563
6564    // ── Regression: shape Url / Email edge cases ─────────────────────────────
6565
6566    #[test]
6567    fn url_shape_accepts_short_http_and_rejects_bare_scheme() {
6568        assert!(is_url("http://x"), "an 8-char http URL is valid");
6569        assert!(is_url("https://x"), "a 9-char https URL is valid");
6570        assert!(!is_url("http://"), "a bare scheme with no host is rejected");
6571        assert!(!is_url("https://"), "a bare https scheme is rejected");
6572    }
6573
6574    #[test]
6575    fn email_shape_rejects_double_at() {
6576        assert!(!is_email("sarah@@acme.com"), "double-@ domain is rejected");
6577        assert!(!is_email("a@b@c.com"), "two @ signs are rejected");
6578        assert!(is_email("sarah@acme.com"), "a normal address still passes");
6579    }
6580
6581    // ── Regression: working-set vs --all agree on log.md links ───────────────
6582
6583    #[test]
6584    fn working_set_does_not_flag_log_md_body_links() {
6585        // Regression: the working-set incoming-linker scan runs root `log.md`
6586        // through the body wiki-link check, flagging a historical `[[deleted]]`
6587        // mention as WIKI_LINK_BROKEN — an error `--all` never reports and that
6588        // the append-only log can't have "fixed". The root meta files must be
6589        // excluded from the body link check, matching --all.
6590        let fx = Fixture::new();
6591        fx.write("records/contacts/a.md", &valid_contact("A"));
6592        fx.write(
6593            "log.md",
6594            "---\ntype: log\n---\n\n## [2026-06-01 10:00] delete | records/contacts/ghost\n\nRemoved [[records/contacts/ghost]] per cleanup.\n",
6595        );
6596        let issues = validate_working_set(&fx.store(), None).unwrap();
6597        assert!(
6598            !issues
6599                .iter()
6600                .any(|i| i.code == codes::WIKI_LINK_BROKEN
6601                    && i.file == std::path::Path::new("log.md")),
6602            "a broken wiki-link inside append-only log.md must not be flagged: {issues:#?}"
6603        );
6604    }
6605
6606    // ── Regression: DB.md schema field lint ──────────────────────────────────
6607
6608    #[test]
6609    fn schema_duplicate_field_name_is_flagged() {
6610        let mut fx = Fixture::new();
6611        fx.config.schemas.insert(
6612            "contact".into(),
6613            Schema {
6614                fields: vec![
6615                    FieldSpec {
6616                        name: "name".into(),
6617                        required: true,
6618                        ..Default::default()
6619                    },
6620                    FieldSpec {
6621                        name: "name".into(),
6622                        ..Default::default()
6623                    },
6624                ],
6625                ..Default::default()
6626            },
6627        );
6628        let issues = fx.store_all();
6629        assert!(
6630            issues
6631                .iter()
6632                .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.key.as_deref() == Some("name")),
6633            "a duplicate schema field name must be flagged: {issues:#?}"
6634        );
6635    }
6636
6637    #[test]
6638    fn schema_unknown_modifier_is_info() {
6639        let mut fx = Fixture::new();
6640        fx.config.schemas.insert(
6641            "contact".into(),
6642            Schema {
6643                fields: vec![FieldSpec {
6644                    name: "name".into(),
6645                    unknown_modifiers: vec!["requierd".into()],
6646                    ..Default::default()
6647                }],
6648                ..Default::default()
6649            },
6650        );
6651        let issues = fx.store_all();
6652        assert!(
6653            issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6654                && i.severity == Severity::Info
6655                && i.key.as_deref() == Some("name")),
6656            "an unrecognized schema modifier must surface as Info: {issues:#?}"
6657        );
6658    }
6659
6660    /// A `unique:` key naming a declared-but-optional field silently skips
6661    /// every record missing that field (an incomplete key never collides), so
6662    /// the declaration itself must warn. The dogfood case: `unique: date,
6663    /// amount, vendor` with `vendor` optional — a vendorless re-entered
6664    /// expense sails past the check.
6665    #[test]
6666    fn schema_unique_key_optional_field_is_warning() {
6667        let mut fx = Fixture::new();
6668        fx.config.schemas.insert(
6669            "expense".into(),
6670            Schema {
6671                fields: vec![
6672                    FieldSpec {
6673                        name: "date".into(),
6674                        required: true,
6675                        ..Default::default()
6676                    },
6677                    FieldSpec {
6678                        name: "amount".into(),
6679                        required: true,
6680                        ..Default::default()
6681                    },
6682                    FieldSpec {
6683                        name: "vendor".into(),
6684                        ..Default::default()
6685                    },
6686                ],
6687                unique_keys: vec![vec!["date".into(), "amount".into(), "vendor".into()]],
6688                ..Default::default()
6689            },
6690        );
6691        let issues = fx.store_all();
6692        assert!(
6693            issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6694                && i.severity == Severity::Warning
6695                && i.key.as_deref() == Some("vendor")
6696                && i.message.contains("unique")),
6697            "a `unique:` key field not marked required must warn: {issues:#?}"
6698        );
6699        // The required key fields are fine — no warning for them.
6700        assert!(
6701            !issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6702                && matches!(i.key.as_deref(), Some("date") | Some("amount"))),
6703            "required key fields must not warn: {issues:#?}"
6704        );
6705    }
6706
6707    /// A `unique:` key naming a field the schema never declares can also never
6708    /// be `required` — same silent skip, same warning.
6709    #[test]
6710    fn schema_unique_key_undeclared_field_is_warning() {
6711        let mut fx = Fixture::new();
6712        fx.config.schemas.insert(
6713            "expense".into(),
6714            Schema {
6715                fields: vec![FieldSpec {
6716                    name: "date".into(),
6717                    required: true,
6718                    ..Default::default()
6719                }],
6720                unique_keys: vec![vec!["date".into(), "vendor".into()]],
6721                ..Default::default()
6722            },
6723        );
6724        let issues = fx.store_all();
6725        assert!(
6726            issues.iter().any(|i| i.code == codes::DB_MD_SCHEMA_FIELD
6727                && i.severity == Severity::Warning
6728                && i.key.as_deref() == Some("vendor")
6729                && i.message.contains("not declared")),
6730            "a `unique:` key field absent from the schema must warn: {issues:#?}"
6731        );
6732    }
6733
6734    /// The clean shape — every key field `required` — stays silent.
6735    #[test]
6736    fn schema_unique_key_all_required_is_clean() {
6737        let mut fx = Fixture::new();
6738        fx.config.schemas.insert(
6739            "expense".into(),
6740            Schema {
6741                fields: vec![
6742                    FieldSpec {
6743                        name: "date".into(),
6744                        required: true,
6745                        ..Default::default()
6746                    },
6747                    FieldSpec {
6748                        name: "amount".into(),
6749                        required: true,
6750                        ..Default::default()
6751                    },
6752                ],
6753                unique_keys: vec![vec!["date".into(), "amount".into()]],
6754                ..Default::default()
6755            },
6756        );
6757        let issues = fx.store_all();
6758        assert!(
6759            !issues
6760                .iter()
6761                .any(|i| i.code == codes::DB_MD_SCHEMA_FIELD && i.message.contains("unique")),
6762            "an all-required unique key must not warn: {issues:#?}"
6763        );
6764    }
6765
6766    /// Every code in `mod codes` must appear as a row in SPEC.md § Validation —
6767    /// the SPEC table is the declared "complete vocabulary" an agent branches on,
6768    /// and the module doc-comment promises this code implements "exactly those
6769    /// codes — no more, no fewer." This guards against the code/SPEC drift where a
6770    /// new validation code is added to the engine but never documented.
6771    #[test]
6772    fn every_code_constant_is_documented_in_spec() {
6773        // Parse the canonical constant *values* straight out of this module's
6774        // source, so a future `pub const X: &str = "X";` is covered with no test
6775        // edit. Format is uniform: `    pub const NAME: &str = "VALUE";`.
6776        let this_src = include_str!("validate.rs");
6777        let mut codes_in_module: Vec<String> = Vec::new();
6778        let mut in_codes_mod = false;
6779        for line in this_src.lines() {
6780            let t = line.trim();
6781            if t.starts_with("pub mod codes") {
6782                in_codes_mod = true;
6783                continue;
6784            }
6785            // The `mod codes` block ends at its closing brace at column 0.
6786            if in_codes_mod && line == "}" {
6787                break;
6788            }
6789            if in_codes_mod {
6790                if let Some(rest) = t.strip_prefix("pub const ") {
6791                    // rest = `NAME: &str = "VALUE";`
6792                    let value = rest
6793                        .split_once('=')
6794                        .map(|(_, v)| v.trim())
6795                        .and_then(|v| v.strip_prefix('"'))
6796                        .and_then(|v| v.strip_suffix("\";"))
6797                        .unwrap_or_else(|| panic!("unparseable code constant line: {line:?}"));
6798                    codes_in_module.push(value.to_string());
6799                }
6800            }
6801        }
6802        assert!(
6803            codes_in_module.len() >= 36,
6804            "parsed only {} code constants from `mod codes`; the parser likely \
6805             broke against a source-format change",
6806            codes_in_module.len()
6807        );
6808
6809        // SPEC.md lives at the repo root, two levels up from this crate's manifest.
6810        let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../SPEC.md");
6811        let spec = fs::read_to_string(&spec_path)
6812            .unwrap_or_else(|e| panic!("cannot read {}: {e}", spec_path.display()));
6813
6814        // Each code must appear as a SPEC § Validation table cell: `` | `CODE` | ``.
6815        let missing: Vec<&String> = codes_in_module
6816            .iter()
6817            .filter(|code| !spec.contains(&format!("| `{code}` |")))
6818            .collect();
6819        assert!(
6820            missing.is_empty(),
6821            "validation codes emitted by the engine but absent from SPEC.md \
6822             § Validation (the declared complete vocabulary): {missing:?}"
6823        );
6824    }
6825
6826    // ── loose files (directly at a layer root, no type-folder) ───────────────
6827
6828    const LOOSE_ALICE: &str = "---\ntype: contact\nid: alice\ncreated: 2026-06-01T08:00:00-07:00\nupdated: 2026-06-01T08:00:00-07:00\nsummary: Alice\n---\nbody\n";
6829    const LOOSE_BOB: &str = "---\ntype: contact\nid: bob\ncreated: 2026-06-01T08:00:00-07:00\nupdated: 2026-06-01T08:00:00-07:00\nsummary: Bob loose\n---\nbody\n";
6830
6831    #[test]
6832    fn loose_file_catalogued_in_layer_jsonl_validates_clean() {
6833        let fx = Fixture::new();
6834        fx.write("records/contacts/alice.md", LOOSE_ALICE);
6835        fx.write("records/bob.md", LOOSE_BOB); // loose, directly under records/
6836        fx.rebuild_indexes();
6837        let issues = fx.store_all();
6838        assert!(
6839            issues.is_empty(),
6840            "a rebuilt store with a catalogued loose file must validate clean, got: {issues:?}"
6841        );
6842    }
6843
6844    #[test]
6845    fn loose_file_with_missing_layer_jsonl_is_index_jsonl_missing() {
6846        let fx = Fixture::new();
6847        fx.write("records/contacts/alice.md", LOOSE_ALICE);
6848        fx.write("records/bob.md", LOOSE_BOB);
6849        fx.rebuild_indexes();
6850        // Simulate the layer sidecar going missing (a hand-deletion / bad sync).
6851        fs::remove_file(fx.dir.path().join("records/index.jsonl")).unwrap();
6852        let issues = fx.store_all();
6853        assert!(
6854            has(&issues, codes::INDEX_JSONL_MISSING),
6855            "a loose file with no layer index.jsonl must raise INDEX_JSONL_MISSING, got: {issues:?}"
6856        );
6857    }
6858}