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