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