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