Skip to main content

dbmd_core/
validate.rs

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