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