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