unknown-knowledge 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +5 -0
  3. package/README.md +167 -0
  4. package/cli/.gitkeep +0 -0
  5. package/cli/commands/init-copy.js +90 -0
  6. package/cli/commands/init.js +386 -0
  7. package/cli/init-copy.js +24 -0
  8. package/cli/init.js +24 -0
  9. package/cli/kit.manifest.yaml +256 -0
  10. package/cli/lib/copy-payload.js +334 -0
  11. package/cli/lib/generate-wrappers.js +162 -0
  12. package/package.json +36 -0
  13. package/payload/adapter-fixtures/README.md +53 -0
  14. package/payload/adapter-fixtures/html/EXPECTED.yaml +50 -0
  15. package/payload/adapter-fixtures/html/sample.html +38 -0
  16. package/payload/adapter-fixtures/md/EXPECTED.yaml +65 -0
  17. package/payload/adapter-fixtures/md/sample.md +32 -0
  18. package/payload/adapter-fixtures/pdf/EXPECTED.yaml +45 -0
  19. package/payload/adapter-fixtures/pdf/sample.pdf +0 -0
  20. package/payload/adapter-fixtures/txt/EXPECTED.yaml +31 -0
  21. package/payload/adapter-fixtures/txt/sample.txt +18 -0
  22. package/payload/docs/README.md +102 -0
  23. package/payload/docs/boundaries.md +60 -0
  24. package/payload/docs/ci-wiring.md +109 -0
  25. package/payload/docs/steward-guide.md +238 -0
  26. package/payload/engine/audit.js +34 -0
  27. package/payload/engine/commands/audit.js +289 -0
  28. package/payload/engine/commands/derive.js +334 -0
  29. package/payload/engine/commands/ingest.js +124 -0
  30. package/payload/engine/commands/log-entry.js +85 -0
  31. package/payload/engine/commands/phoenix.js +206 -0
  32. package/payload/engine/commands/preflight.js +530 -0
  33. package/payload/engine/commands/resolve.js +1678 -0
  34. package/payload/engine/commands/survey-map.js +351 -0
  35. package/payload/engine/commands/validate-values.js +315 -0
  36. package/payload/engine/commands/validate.js +1426 -0
  37. package/payload/engine/derive.js +34 -0
  38. package/payload/engine/ingest.js +34 -0
  39. package/payload/engine/lib/anchor-signatures.js +126 -0
  40. package/payload/engine/lib/boot.js +39 -0
  41. package/payload/engine/lib/call-numbers.js +133 -0
  42. package/payload/engine/lib/cli.js +147 -0
  43. package/payload/engine/lib/coverage.js +849 -0
  44. package/payload/engine/lib/decomposition.js +225 -0
  45. package/payload/engine/lib/derived.js +494 -0
  46. package/payload/engine/lib/engine-refusal.js +40 -0
  47. package/payload/engine/lib/exit-codes.js +14 -0
  48. package/payload/engine/lib/extractor-kinds.js +955 -0
  49. package/payload/engine/lib/format-adapters.js +802 -0
  50. package/payload/engine/lib/id-grammars.js +178 -0
  51. package/payload/engine/lib/iso-date.js +55 -0
  52. package/payload/engine/lib/kit-root.js +101 -0
  53. package/payload/engine/lib/load-stores.js +1624 -0
  54. package/payload/engine/lib/log-entry.js +196 -0
  55. package/payload/engine/lib/phoenix.js +628 -0
  56. package/payload/engine/lib/scoring.js +150 -0
  57. package/payload/engine/lib/suppressions.js +172 -0
  58. package/payload/engine/lib/time-verdicts.js +282 -0
  59. package/payload/engine/lib/usage-error.js +14 -0
  60. package/payload/engine/lib/validate-record.js +504 -0
  61. package/payload/engine/log-entry.js +34 -0
  62. package/payload/engine/phoenix.js +39 -0
  63. package/payload/engine/preflight.js +34 -0
  64. package/payload/engine/resolve.js +34 -0
  65. package/payload/engine/survey-map.js +34 -0
  66. package/payload/engine/validate-values.js +34 -0
  67. package/payload/engine/validate.js +34 -0
  68. package/payload/extractor-fixtures/.gitkeep +0 -0
  69. package/payload/extractor-fixtures/README.md +29 -0
  70. package/payload/extractor-fixtures/swift/strings-keys/EXPECTED.yaml +8 -0
  71. package/payload/extractor-fixtures/swift/strings-keys/sample.strings +15 -0
  72. package/payload/extractor-fixtures/swift/swift-const-array/EXPECTED.yaml +7 -0
  73. package/payload/extractor-fixtures/swift/swift-const-array/sample.swift +21 -0
  74. package/payload/extractor-fixtures/swift/swift-enum/EXPECTED.yaml +8 -0
  75. package/payload/extractor-fixtures/swift/swift-enum/sample.swift +30 -0
  76. package/payload/extractor-fixtures/swift/yaml-keys/EXPECTED.yaml +6 -0
  77. package/payload/extractor-fixtures/swift/yaml-keys/sample.yaml +23 -0
  78. package/payload/extractor-fixtures/swift/yaml-map-keys/EXPECTED.yaml +7 -0
  79. package/payload/extractor-fixtures/swift/yaml-map-keys/sample.yaml +15 -0
  80. package/payload/extractor-fixtures/ts/dir-modules/EXPECTED.yaml +12 -0
  81. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/alpha.widget.ts +1 -0
  82. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/beta.widget.ts +1 -0
  83. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/gamma.widget.ts +1 -0
  84. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/helpers/format.ts +3 -0
  85. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/widgets.test.ts +2 -0
  86. package/payload/extractor-fixtures/ts/json-keys/EXPECTED.yaml +7 -0
  87. package/payload/extractor-fixtures/ts/json-keys/sample.json +5 -0
  88. package/payload/extractor-fixtures/ts/json-map-keys/EXPECTED.yaml +7 -0
  89. package/payload/extractor-fixtures/ts/json-map-keys/sample.json +13 -0
  90. package/payload/extractor-fixtures/ts/ts-const-array/EXPECTED.yaml +6 -0
  91. package/payload/extractor-fixtures/ts/ts-const-array/sample.ts +13 -0
  92. package/payload/extractor-fixtures/ts/ts-enum/EXPECTED.yaml +7 -0
  93. package/payload/extractor-fixtures/ts/ts-enum/sample.ts +11 -0
  94. package/payload/extractor-fixtures/ts/ts-object-keys/EXPECTED.yaml +6 -0
  95. package/payload/extractor-fixtures/ts/ts-object-keys/sample.tsx +23 -0
  96. package/payload/extractor-fixtures/ts/ts-union/EXPECTED.yaml +5 -0
  97. package/payload/extractor-fixtures/ts/ts-union/sample.ts +9 -0
  98. package/payload/hooks/pre-commit +37 -0
  99. package/payload/hooks/reverse-lookup +66 -0
  100. package/payload/package.json +3 -0
  101. package/payload/protocol/.gitkeep +0 -0
  102. package/payload/protocol/AGENTS.md +239 -0
  103. package/payload/protocol/derived-layer.md +174 -0
  104. package/payload/protocol/new-kind-pipeline.md +179 -0
  105. package/payload/protocol/registry-warrant.md +162 -0
  106. package/payload/protocol/skills/kb-build.md +303 -0
  107. package/payload/protocol/skills/knowledge-audit.md +183 -0
  108. package/payload/protocol/skills/knowledge-bootstrap.md +229 -0
  109. package/payload/protocol/skills/knowledge-reflect.md +397 -0
  110. package/payload/schemas/catalog.schema.json +32 -0
  111. package/payload/schemas/decision-entry.schema.json +122 -0
  112. package/payload/schemas/finding.schema.json +77 -0
  113. package/payload/schemas/gap.schema.json +52 -0
  114. package/payload/schemas/graduation-categories.schema.json +64 -0
  115. package/payload/schemas/knowledge-leaf.schema.json +194 -0
  116. package/payload/schemas/miss.schema.json +45 -0
  117. package/payload/schemas/ontology-concept.schema.json +115 -0
  118. package/payload/schemas/phoenix-event.schema.json +76 -0
  119. package/payload/schemas/registry.schema.json +57 -0
  120. package/payload/schemas/rules.schema.json +14 -0
  121. package/payload/schemas/survey-scope.schema.json +23 -0
  122. package/payload/templates/decisions/_catalog.yaml +7 -0
  123. package/payload/templates/decisions/_registries/graduation-categories.yaml +42 -0
  124. package/payload/templates/decisions/phoenix-event.yaml +74 -0
  125. package/payload/templates/decisions/reflect-mint-proposal.yaml +100 -0
  126. package/payload/templates/decisions/registry-minting.yaml +58 -0
  127. package/payload/templates/decisions/trust-graduation.yaml +120 -0
  128. package/payload/templates/decisions/trust-revocation.yaml +106 -0
  129. package/payload/templates/knowledge/_catalog.yaml +9 -0
  130. package/payload/templates/knowledge/_registries/anchor.yaml +42 -0
  131. package/payload/templates/knowledge/_registries/authority-tiers.yaml +32 -0
  132. package/payload/templates/knowledge/_registries/domains.yaml +43 -0
  133. package/payload/templates/knowledge/_registries/form.yaml +38 -0
  134. package/payload/templates/knowledge/_registries/jurisdictions.yaml +20 -0
  135. package/payload/templates/knowledge/_registries/operations.yaml +18 -0
  136. package/payload/templates/knowledge/_registries/stage.yaml +53 -0
  137. package/payload/templates/knowledge/_rules.yaml +6 -0
  138. package/payload/templates/new-kind/README.md +107 -0
  139. package/payload/templates/new-kind/descriptor.example.yaml +18 -0
  140. package/payload/templates/new-kind/fixture/EXPECTED.yaml +6 -0
  141. package/payload/templates/new-kind/fixture/demo-anchor.list +2 -0
  142. package/payload/templates/new-kind/fixture/sample.list +7 -0
  143. package/payload/templates/new-kind/parser.example.js +98 -0
  144. package/payload/templates/ontology/_catalog.yaml +6 -0
  145. package/payload/templates/ontology/_rules.yaml +6 -0
  146. package/payload/wrappers/cursor.mdc +15 -0
  147. package/payload/wrappers/pointer.md +10 -0
@@ -0,0 +1,1426 @@
1
+ /**
2
+ * Structural validator (KK-05) — blocking-grade (PRD §4, D-011).
3
+ *
4
+ * node payload/engine/validate.js [--json] [--root <dir>] [--concepts <ids>]
5
+ *
6
+ * Runs the full §4 structural check list over the KK-04 loader model — the
7
+ * single health model; this module NEVER re-parses stores itself. The loader
8
+ * already carries schema validation (KK-02), duplicate-id detection, and
9
+ * typed-ref resolution on its one error/warning scale; this validator adds
10
+ * the checks that need the whole model and the working tree:
11
+ *
12
+ * id-shape catalog entry id violates the owning store's id grammar
13
+ * (K-NNN / dotted notation / D-NNN or provisional draft id)
14
+ * — record-level ids are schema-pattern-checked upstream,
15
+ * catalog ids are plain strings there by design
16
+ * id-range concept id outside the class file's declared range: the
17
+ * numeric filename prefix N declares [N, N+99] (§3.5 ids
18
+ * are minted within class ranges, leaving gaps)
19
+ * missing-path a declared pointer that does not name a real thing inside
20
+ * this repo — a concept source-of-truth path, or a
21
+ * knowledge leaf `paths` entry (UCS-1151). Three shapes,
22
+ * one code, because from the store's side they are one
23
+ * defect class: the pointer is absent, it ESCAPES the repo
24
+ * root (`../elsewhere`, an absolute path, or a symlink
25
+ * inside the repo whose target is outside it — containment
26
+ * is judged on CANONICAL paths, never lexically), or it
27
+ * names the repo ROOT itself — a pointer at everything
28
+ * attributes nothing. A dangling symlink reports as absent
29
+ * rather than escaping, which is what it is.
30
+ * Deprecated concepts demote an ABSENT pointer to
31
+ * warning (§3.5 — the source-deletion escape hatch) but
32
+ * never an escaping or root one: that hatch is for a path
33
+ * that used to exist, not for a claim the store was never
34
+ * entitled to make. Leaves have no demotion at all
35
+ * index-drift catalog/tree index inconsistency: a row naming a file
36
+ * that was not loaded, or naming a file that does not
37
+ * contain the row's id. The documented pending marker
38
+ * (file: pending-import, mid-import rows) is a warning —
39
+ * declared, not drifted
40
+ * orphan a loaded record its store's catalog never declares —
41
+ * unreachable through the navigational entry point (§3)
42
+ * missing-citation a knowledge-leaf citation whose source is empty — an
43
+ * unsourced claim is not promotable (§3.2); presence and
44
+ * minItems are schema checks upstream
45
+ * ref-cycle a decision supersedes chain that loops (§3.3 chains
46
+ * must be acyclic; supersedes/superseded-by mirror pairs
47
+ * are legitimate, so only supersedes edges are walked)
48
+ * unregistered-value a governed facet value absent from its registry
49
+ * (UCS-1148). Registry membership cannot be a schema enum:
50
+ * the vocabulary is minted by literary warrant under
51
+ * steward review, so it lives in a governed file that
52
+ * grows without an engine release
53
+ * unminted-segment a hierarchical domain path with an unminted segment —
54
+ * the finding names the SEGMENT, which is the edit the
55
+ * author can actually make
56
+ * suppressed-value a value the registry lists as REJECTED. Suppression is
57
+ * durable and visible: a refused term must not read as a
58
+ * typo, and must not be quietly re-minted
59
+ * missing-registry a record cites a governed facet whose registry the store
60
+ * does not carry. Registry absence surfaces HERE, at the
61
+ * point a value actually needs judging — a store that
62
+ * governs nothing is complete, but a value checked against
63
+ * a registry that never loaded is a check that never ran
64
+ * missing-graduation-table
65
+ * a graduation/revocation entry in a store carrying no
66
+ * category table (UCS-1155). Absence surfaces where it can
67
+ * mean something — at the entry that needed the table
68
+ * undeclared-category
69
+ * a graduation/revocation naming a category the table does
70
+ * not declare. Autonomy is per CATEGORY, so one nobody
71
+ * scoped has no written extent
72
+ * gated-category-graduation
73
+ * a graduation for a PERMANENTLY GATED category — the
74
+ * refusal the mechanism exists for. Revocation of a gated
75
+ * category is fine: it only ever narrows autonomy
76
+ * registry-shape-mismatch
77
+ * a registry's hierarchical flag disagrees with the shape
78
+ * the facet it governs requires. Reported once, against the
79
+ * registry file: a domains registry missing its
80
+ * `hierarchical: true` would judge whole paths as opaque
81
+ * strings, silently disabling the segment rule at exit 0
82
+ *
83
+ * Exit codes (PRD §5, lib/exit-codes.js): 0 clean, 1 findings (any
84
+ * error-severity finding), 2 engine failure. Loader error-severity
85
+ * diagnostics gate to exit 2 — structural checks over a store that failed to
86
+ * load never ran, and a check that never ran is a blocking defect, never a
87
+ * silent pass. Warnings alone (from findings or the loader) exit 0.
88
+ *
89
+ * --concepts <ids> (comma-separated) filters findings to the named concepts
90
+ * for mid-session ACT checks; an unknown id is a hard error (exit 2) —
91
+ * filtering on a typo must never read as a clean pass.
92
+ *
93
+ * JSON findings output is deterministic and stable-sorted by file/path/code/id
94
+ * (shared comparator), no timestamps — baseline-diffable (D-012).
95
+ */
96
+ import { realpathSync, statSync } from 'node:fs';
97
+ import { isAbsolute, relative, resolve } from 'node:path';
98
+ import { fileURLToPath } from 'node:url';
99
+ import process from 'node:process';
100
+ import {
101
+ LEAF_ACCESSION_FIELD, LEAF_PATHS_FIELD, healthSummary, loadStores,
102
+ normalizeConceptIds, recordId, storeHealth,
103
+ } from '../lib/load-stores.js';
104
+ import { locateKitRoot } from '../lib/kit-root.js';
105
+ import { EXIT_CODES } from '../lib/exit-codes.js';
106
+ import { UsageError, parseArgs as parseFlags, rethrowIfBug } from '../lib/cli.js';
107
+ import { compare } from '../lib/validate-record.js';
108
+ // Catalog id grammars come from the one module that owns them (UCS-1142), the
109
+ // same source the record-level schema patterns bind to — so a catalog row and
110
+ // the record it points at can never be judged by two different grammars.
111
+ import { ID_GRAMMARS, idPattern } from '../lib/id-grammars.js';
112
+ // The Time facet's field spellings and pinned thresholds (UCS-1150), read from
113
+ // the one module that owns them — the validator's presence check and every
114
+ // surface's staleness verdict must agree about which fields those are.
115
+ import { VERIFIED_FIELD, VOLATILITY_LIMITS, leafVolatility } from '../lib/time-verdicts.js';
116
+ import { isCalendarDate } from '../lib/iso-date.js';
117
+ // Phoenix events own the `edition` field: they are the only thing that bumps it
118
+ // (UCS-1154), so the check that every bump was sanctioned reads its rule from
119
+ // the same module that applies them.
120
+ import { EDITION_FIELD, FIRST_EDITION, unaccountedEditions } from '../lib/phoenix.js';
121
+
122
+ export const USAGE = 'usage: node payload/engine/validate.js [--json] [--root <dir>] [--concepts <ids>]';
123
+
124
+ /** Every check class this validator runs, sorted — reported on every run. */
125
+ export const CHECKS = Object.freeze([
126
+ 'disconnected-revocation', 'gated-category-graduation',
127
+ 'graduation-field-shape', 'graduation-not-trust-category',
128
+ 'id-range', 'id-shape', 'index-drift',
129
+ 'malformed-verified', 'missing-authority', 'missing-citation',
130
+ 'missing-graduation-table', 'missing-path', 'missing-registry',
131
+ 'missing-verified', 'orphan', 'ref-cycle', 'registry-shape-mismatch',
132
+ 'suppressed-value', 'unaccounted-edition', 'undeclared-category',
133
+ 'unminted-segment', 'unregistered-value',
134
+ ]);
135
+
136
+ /** The §3 documented mid-import marker a catalog row carries instead of a file. */
137
+ const PENDING_MARKER = 'pending-import';
138
+
139
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
140
+ const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
141
+
142
+ /**
143
+ * Judge one declared pointer into the working tree — the single reader of
144
+ * "does this path exist, and is it even ours to ask about".
145
+ *
146
+ * Both pointer families ride this: concept `source-of-truth` and leaf `paths`.
147
+ * They had the same defect independently, which is the argument for one
148
+ * function rather than two call sites that happen to agree today.
149
+ *
150
+ * CONTAINMENT is checked before existence, and that order is the whole point.
151
+ * `join(repoRoot, p)` happily resolves `../sibling` to a directory OUTSIDE the
152
+ * repo, and `/etc/passwd` to `<repoRoot>/etc/passwd`. A pointer that escapes
153
+ * would then be judged against a file the store has no claim on: an escaping
154
+ * path that happens to exist on the author's machine passes silently, and the
155
+ * same store fails on a machine where it does not. A check whose verdict
156
+ * depends on what sits OUTSIDE the repo is not a check — the store is the unit
157
+ * that gets committed, reviewed and shipped, so a pointer that leaves it is
158
+ * refused on its shape rather than tested against the filesystem.
159
+ *
160
+ * The repo ROOT itself is refused too, for a reason the resolver already
161
+ * settled from the other side: `--paths .` is a usage error there ("name the
162
+ * files or directories the change touched"), because a pointer at everything
163
+ * attributes nothing. A leaf declaring `paths: ["."]` makes that same empty
164
+ * claim, and it is worse than useless — it would match every path ever queried,
165
+ * putting one leaf in front of every developer regardless of what they touched.
166
+ * Refusing it is the same judgement, applied where the claim is authored.
167
+ *
168
+ * @param {string} repoRoot the repo root pointers resolve against
169
+ * @param {string} p the pointer as the record spells it
170
+ * @returns {'escapes'|'root'|'missing'|null} the defect, or null if it is fine
171
+ */
172
+ /**
173
+ * What each pointer defect says, per family — one message per defect, taking
174
+ * the family's own noun so a concept and a leaf each read naturally.
175
+ *
176
+ * All three ride the `missing-path` code, because they are one defect class
177
+ * from the store's side: a declared pointer that does not name a real thing
178
+ * inside this repo. The MESSAGE is what distinguishes them, and it has to,
179
+ * since the three send an author to three different edits — restore the file,
180
+ * bring the path inside the repo, or name something narrower than everything.
181
+ */
182
+ const POINTER_MESSAGES = Object.freeze({
183
+ missing: (p, noun) => `${noun} "${p}" does not exist in the working tree — the truth anchor is the artifact (§3.1)`,
184
+ escapes: (p, noun) => `${noun} "${p}" resolves outside the repo root — a store may only point at its own repo, and a pointer that escapes would be judged against a file this store has no claim on (passing or failing by what happens to sit outside it)`,
185
+ root: (p, noun) => `${noun} "${p}" names the repo root — a pointer at everything attributes nothing, and it would match every path ever queried; name the files or directories this actually governs`,
186
+ });
187
+
188
+ function pointerDefect(repoRoot, p) {
189
+ const root = resolve(repoRoot);
190
+ const target = resolve(root, p);
191
+ // The repo root itself is its own defect — a pointer at everything — so it is
192
+ // separated from the escapes it otherwise shares a test with.
193
+ if (relative(root, target) === '') return 'root';
194
+ if (outside(root, target)) return 'escapes';
195
+ // Existence before canonicalization, because the two questions are ordered:
196
+ // a path that is not there has no canonical form to compare, and reporting
197
+ // it as an escape would send an author looking for a link that does not
198
+ // exist. A dangling symlink lands here too — statSync follows links, so a
199
+ // link whose target is gone is `missing`, which is what it is.
200
+ if (!statSync(target, { throwIfNoEntry: false })) return 'missing';
201
+ // Then AGAIN on the canonical paths. The lexical test above is necessary but
202
+ // not sufficient: `src/link.ts` is lexically inside the repo while resolving
203
+ // to anywhere at all, so a symlink would carry the whole escape back in
204
+ // through a path that looks contained. Both sides are canonicalized, because
205
+ // the ROOT may itself be reached through a link (a /tmp that is really
206
+ // /private/tmp, which is exactly what macOS hands a test) — comparing a
207
+ // canonical target against a lexical root would then read every ordinary
208
+ // path as an escape.
209
+ const realRoot = realpathOrNull(root);
210
+ const realTarget = realpathOrNull(target);
211
+ // If either cannot be canonicalized the filesystem has declined to answer.
212
+ // The lexical test already passed and the path exists, so the honest reading
213
+ // is to accept it rather than invent a defect from a failed syscall.
214
+ if (realRoot === null || realTarget === null) return null;
215
+ return outside(realRoot, realTarget) ? 'escapes' : null;
216
+ }
217
+
218
+ /** Is `target` outside `root`, or root itself? Both must already be absolute. */
219
+ function outside(root, target) {
220
+ const rel = relative(root, target);
221
+ // `''` is the root itself; a leading `..` climbed out; an absolute result
222
+ // means a different volume entirely.
223
+ return rel === '' || rel.startsWith('..') || isAbsolute(rel);
224
+ }
225
+
226
+ /**
227
+ * The canonical path, or null when the filesystem will not say.
228
+ *
229
+ * A pointer this engine cannot canonicalize (a permissions wall on a parent, a
230
+ * race with a concurrent delete) must not crash a validation run that can still
231
+ * answer for every other pointer — the same conduct the resolver's folder test
232
+ * already applies to an unreadable pointer.
233
+ */
234
+ function realpathOrNull(path) {
235
+ try {
236
+ return realpathSync(path);
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ // -------------------------------------------------------------- the checks
243
+
244
+ /**
245
+ * The id grammar each store's CATALOG rows are judged by.
246
+ *
247
+ * Usually the store's own space — a catalog row names an id that store mints.
248
+ * Knowledge stays a distinct entry after UCS-1147 narrowed citations, because a
249
+ * knowledge row is still a POINTER rather than a mint: it is the store's own
250
+ * citation of its leaves, so it is judged by the citation grammar, and its
251
+ * finding carries the citation grammar's hint — the one that names the
252
+ * accession migration. The two grammars accept the same strings today; the
253
+ * table is what keeps the distinction available when they diverge.
254
+ */
255
+ const CATALOG_ID_SPACE = Object.freeze({
256
+ decisions: 'decisions',
257
+ knowledge: 'leaf-ref',
258
+ ontology: 'ontology',
259
+ });
260
+
261
+ /** Catalog rows: id grammar (id-shape) + row↔file agreement (index-drift). */
262
+ function checkCatalogs(model, push) {
263
+ // What each loaded file actually contains — the fact the index must match.
264
+ // One id per record now that a leaf answers to one spelling (UCS-1147); the
265
+ // alternate-spelling pass the expand phase needed here left with the alias
266
+ // index, so a row naming a leaf by its retired notation reads as the
267
+ // id-shape finding it is rather than resolving to the file.
268
+ const idsByFile = new Map();
269
+ const addTo = (file, id) => {
270
+ if (typeof file !== 'string' || typeof id !== 'string') return;
271
+ if (!idsByFile.has(file)) idsByFile.set(file, new Set());
272
+ idsByFile.get(file).add(id);
273
+ };
274
+ for (const records of [model.concepts, model.decisions, model.leaves]) {
275
+ for (const entry of records.values()) addTo(entry.file, recordId(entry));
276
+ }
277
+
278
+ for (const store of ['decisions', 'knowledge', 'ontology']) {
279
+ const catalog = model.stores[store].catalog;
280
+ if (!isObject(catalog) || !Array.isArray(catalog.entries)) continue; // absent/invalid: loader diagnosed
281
+ // A catalog row is a POINTER at a leaf, not a leaf, so it is judged by the
282
+ // citation grammar rather than the minting one: a row names its leaf the
283
+ // way a cross-reference does, which since UCS-1147 means by accession.
284
+ // Agreement with the pointed-at file is checked below, and that check is
285
+ // what actually ties the row to a leaf — the grammar only decides whether
286
+ // the row names something of a shape a leaf could have.
287
+ const space = CATALOG_ID_SPACE[store];
288
+ const grammar = ID_GRAMMARS[space];
289
+ const pattern = idPattern(space);
290
+ catalog.entries.forEach((row, i) => {
291
+ if (!isObject(row) || typeof row.id !== 'string' || typeof row.file !== 'string') return;
292
+ if (!pattern.test(row.id)) {
293
+ push({
294
+ severity: 'error', code: 'id-shape', id: row.id,
295
+ file: `${store}/_catalog.yaml`, path: `entries[${i}].id`,
296
+ message: `id "${row.id}" violates the ${store} id grammar (${grammar.hint}, §3.5)`,
297
+ });
298
+ return; // a malformed id cannot be meaningfully matched against files
299
+ }
300
+ if (row.file === PENDING_MARKER) {
301
+ push({
302
+ severity: 'warning', code: 'index-drift', id: row.id,
303
+ file: `${store}/_catalog.yaml`, path: `entries[${i}].file`,
304
+ message: `"${row.id}" is declared pending import — resolve the marker to a real file once the record lands`,
305
+ });
306
+ return;
307
+ }
308
+ const target = `${store}/${row.file}`;
309
+ if (!model.stores[store].files.includes(target)) {
310
+ push({
311
+ severity: 'error', code: 'index-drift', id: row.id,
312
+ file: `${store}/_catalog.yaml`, path: `entries[${i}].file`,
313
+ message: `catalog names ${target}, which does not exist in the store`,
314
+ });
315
+ } else if (!idsByFile.get(target)?.has(row.id)) {
316
+ push({
317
+ severity: 'error', code: 'index-drift', id: row.id,
318
+ file: `${store}/_catalog.yaml`, path: `entries[${i}].file`,
319
+ message: `catalog id "${row.id}" not found in ${target} — the map must point at the fact it names`,
320
+ });
321
+ }
322
+ });
323
+ }
324
+ }
325
+
326
+ /** Concepts: class-range membership (id-range) + SSOT existence (missing-path). */
327
+ function checkConcepts(model, push, repoRoot) {
328
+ for (const { id, file, record } of model.concepts.values()) {
329
+ // id-range: the class file's numeric prefix N declares [N, N+99] (§3.5).
330
+ const prefix = /^(\d+)-/.exec(file.split('/').pop());
331
+ const n = Number(id.slice(2));
332
+ if (prefix && Number.isInteger(n)) {
333
+ const lower = Number(prefix[1]);
334
+ if (n < lower || n > lower + 99) {
335
+ push({
336
+ severity: 'error', code: 'id-range', id, file, path: 'id',
337
+ message: `id "${id}" is outside the class range ${lower}..${lower + 99} declared by ${file} (§3.5: ids are minted within class ranges)`,
338
+ });
339
+ }
340
+ }
341
+ // missing-path: §3.5 deprecated demotes pointer checks to warnings — the
342
+ // escape hatch that lets a source-deletion PR land without dead-ending.
343
+ const severity = record.status === 'deprecated' ? 'warning' : 'error';
344
+ strings(record['source-of-truth']).forEach((p, i) => {
345
+ const defect = pointerDefect(repoRoot, p);
346
+ if (!defect) return;
347
+ push({
348
+ // A pointer that ESCAPES the repo, or names the root, stays
349
+ // error-severity even for a deprecated concept. The §3.5 demotion is
350
+ // an escape hatch for a path that USED to exist and was deleted — it
351
+ // says nothing about a pointer that was never the store's to make, and
352
+ // demoting a malformed claim would let it ship under a warning.
353
+ severity: defect === 'missing' ? severity : 'error',
354
+ code: 'missing-path', id, file, path: `source-of-truth[${i}]`,
355
+ message: POINTER_MESSAGES[defect](p, 'source-of-truth path'),
356
+ });
357
+ });
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Leaf `paths` must exist in the working tree (UCS-1151) — the same check
363
+ * concept source-of-truth pointers already ride, deliberately reusing the same
364
+ * CODE.
365
+ *
366
+ * Three of frontmatter v2's typed edge families are declared in the ref-field
367
+ * table and get `unresolved-ref` for free. `paths` cannot join them, and the
368
+ * reason is worth stating plainly rather than papering over: the ref graph
369
+ * resolves IDS. Its whole question is whether a string is minted in some store's
370
+ * id space, and a repo path is not an id — it names the working tree, which is a
371
+ * different truth anchor (§3.1: the artifact) checked by a different means (the
372
+ * filesystem). Declaring `paths` as a ref row would have asked the loader
373
+ * whether `src/api/handler.ts` resolves to a knowledge entry, which it never
374
+ * could, and every leaf carrying a path would have failed for the wrong reason.
375
+ *
376
+ * So the family gets the HONEST mechanism for what it points at, and it is not a
377
+ * new one: `missing-path` already means exactly this — a declared pointer into
378
+ * the working tree that is not there. Reusing the code keeps one finding class
379
+ * for one defect class, so a steward who has fixed a concept's dead pointer
380
+ * needs nothing new to fix a leaf's.
381
+ *
382
+ * Unlike concepts there is no deprecation demotion. That escape hatch exists so
383
+ * a source-deletion PR can land while a DEPRECATED concept still points at what
384
+ * it deleted (§3.5); a leaf has no `status`, and its `facets.stage` is a
385
+ * promotion lifecycle rather than a retirement one — there is no stage that
386
+ * means "this leaf's pointers are allowed to dangle". Inventing one here would
387
+ * be a governance decision this ticket has no warrant to make.
388
+ */
389
+ function checkLeafPaths(model, push, repoRoot) {
390
+ for (const entry of model.leaves.values()) {
391
+ const { file, record } = entry;
392
+ strings(record[LEAF_PATHS_FIELD]).forEach((p, i) => {
393
+ const defect = pointerDefect(repoRoot, p);
394
+ if (!defect) return;
395
+ push({
396
+ severity: 'error', code: 'missing-path', id: recordId(entry), file,
397
+ path: `${LEAF_PATHS_FIELD}[${i}]`,
398
+ message: POINTER_MESSAGES[defect](p, 'path'),
399
+ });
400
+ });
401
+ }
402
+ }
403
+
404
+ /** Records the store catalog never declares are unreachable — orphans. */
405
+ function checkOrphans(model, push) {
406
+ const declared = { ontology: new Set(), knowledge: new Set(), decisions: new Set() };
407
+ for (const store of Object.keys(declared)) {
408
+ const catalog = model.stores[store].catalog;
409
+ if (!isObject(catalog) || !Array.isArray(catalog.entries)) continue;
410
+ for (const row of catalog.entries) {
411
+ if (isObject(row) && typeof row.id === 'string') declared[store].add(row.id);
412
+ }
413
+ }
414
+ // The third element derives the FINDING path — which field of the record the
415
+ // reported id was read from — not how this check GETS at the id: identity
416
+ // comes through recordId(), so a leaf id space change never reaches this
417
+ // loop (UCS-1142). A leaf's identity is its accession, so that is the field
418
+ // an orphan finding points the author at (UCS-1147).
419
+ const spaces = [
420
+ ['ontology', model.concepts, () => 'id'],
421
+ ['knowledge', model.leaves, () => LEAF_ACCESSION_FIELD],
422
+ ['decisions', model.decisions, () => 'id'],
423
+ ];
424
+ for (const [store, records, idPath] of spaces) {
425
+ if (!model.stores[store].catalog) continue; // no catalog loaded: loader diagnosed
426
+ for (const [id, entry] of records) {
427
+ if (!declared[store].has(id)) {
428
+ push({
429
+ severity: 'error', code: 'orphan', id, file: entry.file, path: idPath(entry),
430
+ message: `"${id}" is not declared in ${store}/_catalog.yaml — unreachable through the store's navigational entry point (§3)`,
431
+ });
432
+ }
433
+ }
434
+ }
435
+ }
436
+
437
+ // ------------------------------------------- registry membership (UCS-1148)
438
+
439
+ /**
440
+ * Which governed vocabulary each facet field draws from (UCS-1148).
441
+ *
442
+ * A DECLARATION, in the same spirit as the loader's REF_FIELDS: "this field's
443
+ * values must be minted in that registry" is a fact about the store shape, and
444
+ * facts about the store shape belong in a table every surface reads. Adding a
445
+ * governed facet is adding a row — there is no per-facet branch in the checker
446
+ * to extend, which is what lets frontmatter v2 (UCS-1149) declare the rest of
447
+ * its facets here rather than growing a second membership code path.
448
+ *
449
+ * Each row:
450
+ * field a path of object keys into the record, dotted. The path may end
451
+ * at a single string or at an ARRAY of strings; `each` says which
452
+ * each true when the field holds an array, so every member is checked
453
+ * and the finding path carries the index the author can point at
454
+ * registry "<store>/<name>" — the registry key, matching the loader's index
455
+ * within an optional path prefix, so a field nested inside a repeated
456
+ * sub-record (a citation) is declared once rather than per index
457
+ * hierarchical
458
+ * the registry SHAPE this facet requires. Declared, and checked
459
+ * against the registry's own flag: see registryShapeMismatch
460
+ * blankOwnedBy
461
+ * the check that owns this field's ABSENCE, when one exists. A
462
+ * blank value is then that check's to report and membership defers,
463
+ * so one omission earns one finding. Absent means membership judges
464
+ * blanks itself — the safe default, since a field whose emptiness
465
+ * nobody checks would otherwise pass governed-but-unchecked
466
+ *
467
+ * Frozen all the way down: a mutated row would silently redirect a facet at a
468
+ * different vocabulary, which is a governed store quietly ungoverned.
469
+ *
470
+ * @type {Readonly<Record<string, ReadonlyArray<Readonly<object>>>>}
471
+ */
472
+ export const FACET_REGISTRIES = Object.freeze({
473
+ 'knowledge-leaf': Object.freeze([
474
+ Object.freeze({ field: 'facets.domain', registry: 'knowledge/domains', hierarchical: true }),
475
+ // Frontmatter v2's remaining classification facets (UCS-1149). Each is a
476
+ // ROW, which is the whole point of the table: three more governed fields
477
+ // cost three declarations and no second membership code path.
478
+ Object.freeze({ field: 'facets.form', registry: 'knowledge/form', hierarchical: false }),
479
+ Object.freeze({ field: 'facets.anchor', registry: 'knowledge/anchor', hierarchical: false }),
480
+ Object.freeze({ field: 'facets.stage', registry: 'knowledge/stage', hierarchical: false }),
481
+ Object.freeze({ field: 'operations', each: true, registry: 'knowledge/operations', hierarchical: false }),
482
+ Object.freeze({ field: 'applies.jurisdictions', each: true, registry: 'knowledge/jurisdictions', hierarchical: false }),
483
+ // `blankOwnedBy` names the check that OWNS this field's absence. Only
484
+ // `authority` has one (`missing-authority`), so only `authority` may leave
485
+ // a blank value to it; every other governed field judges a blank itself as
486
+ // an unregistered value, because otherwise `stage: ""` would pass silently.
487
+ Object.freeze({
488
+ within: 'citations', field: 'authority', registry: 'knowledge/authority-tiers',
489
+ hierarchical: false, blankOwnedBy: 'missing-authority',
490
+ }),
491
+ ]),
492
+ });
493
+
494
+ /**
495
+ * Which model collection each governed record kind is indexed in.
496
+ *
497
+ * The one place the checker learns that `knowledge-leaf` records live in
498
+ * `model.leaves`. Without it the kind and the collection were spelled at two
499
+ * call sites apiece, so a second governed kind — which UCS-1149 brings — would
500
+ * have meant editing the walker rather than the tables it reads.
501
+ *
502
+ * Keyed by the same kind strings FACET_REGISTRIES uses, so the two tables are
503
+ * read together and a kind declared in one but missing from the other is
504
+ * refused at load rather than silently unchecked (assertGovernedKinds).
505
+ *
506
+ * @type {Readonly<Record<string, string>>}
507
+ */
508
+ export const GOVERNED_COLLECTIONS = Object.freeze({
509
+ 'knowledge-leaf': 'leaves',
510
+ });
511
+
512
+ /**
513
+ * Refuse a facet table naming a kind whose records the checker cannot reach —
514
+ * an engine failure at load, never a silent pass.
515
+ *
516
+ * A row declared for a kind with no collection would govern nothing: the
517
+ * facet would look governed in the table and be unchecked in every store,
518
+ * which is the failure class this engine exists to prevent (PRD §5).
519
+ *
520
+ * @param {Record<string, unknown>} table the facet declaration table
521
+ * @throws {Error} if a declared kind has no model collection
522
+ */
523
+ export function assertGovernedKinds(table) {
524
+ for (const kind of Object.keys(table)) {
525
+ if (!GOVERNED_COLLECTIONS[kind]) {
526
+ throw new Error(
527
+ `facet table declares governed kind "${kind}", which maps to no model collection — `
528
+ + 'its rows would look governed and be checked in no store; add it to GOVERNED_COLLECTIONS',
529
+ );
530
+ }
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Refuse a facet table whose rows disagree about one registry's SHAPE — an
536
+ * engine failure at load, never a silent pass.
537
+ *
538
+ * The shape check runs once per registry, because a mismatch is a fact about
539
+ * the registry rather than about any record that drew from it. That
540
+ * de-duplication is only sound while every row naming a registry agrees about
541
+ * its shape: if two rows disagreed, the first one seen would settle the
542
+ * question and the second would be checked against a shape it never asked for
543
+ * — a facet silently governed by the wrong rule, which is the exact failure
544
+ * `registry-shape-mismatch` exists to catch one level down.
545
+ *
546
+ * The contradiction cannot be resolved here either. One registry is either
547
+ * hierarchical or flat; two facets needing it both ways need two registries,
548
+ * and picking a winner by declaration order would be an arbitrary answer to a
549
+ * question the table asked wrongly.
550
+ *
551
+ * @param {Record<string, ReadonlyArray<{ registry: string, hierarchical?: boolean }>>} table
552
+ * @throws {Error} if two rows name one registry with different shapes
553
+ */
554
+ export function assertConsistentRegistryShapes(table) {
555
+ const expected = new Map(); // registry key -> { hierarchical, kind, field }
556
+ for (const [kind, rows] of Object.entries(table)) {
557
+ for (const row of rows) {
558
+ const previous = expected.get(row.registry);
559
+ if (previous === undefined) {
560
+ expected.set(row.registry, { hierarchical: row.hierarchical, kind, field: row.field });
561
+ } else if (previous.hierarchical !== row.hierarchical) {
562
+ throw new Error(
563
+ `facet table declares registry "${row.registry}" as both hierarchical=${previous.hierarchical} `
564
+ + `(${previous.kind}.${previous.field}) and hierarchical=${row.hierarchical} (${kind}.${row.field}) — `
565
+ + 'one registry has one shape, so one of these facets would be governed by a rule it never asked for; '
566
+ + 'a facet needing the other shape needs its own registry',
567
+ );
568
+ }
569
+ }
570
+ }
571
+ }
572
+
573
+ // Both checked as this module loads: a facet governed by nothing, or governed
574
+ // against a shape another row settled, is a defect in the kit itself. Each must
575
+ // surface the moment it is introduced rather than as a store that quietly
576
+ // passes checks it never ran.
577
+ assertGovernedKinds(FACET_REGISTRIES);
578
+ assertConsistentRegistryShapes(FACET_REGISTRIES);
579
+
580
+ /** Follow a dotted path into a record; undefined if any segment is missing. */
581
+ function valueAtPath(record, field) {
582
+ let node = record;
583
+ for (const segment of field.split('.')) {
584
+ if (!isObject(node)) return undefined;
585
+ node = node[segment];
586
+ }
587
+ return node;
588
+ }
589
+
590
+ /**
591
+ * The registry SHAPE a facet requires, checked against the shape the registry
592
+ * declares — a silent disagreement here disables the rule it governs.
593
+ *
594
+ * `facets.domain` is hierarchical: its whole membership rule is that every
595
+ * segment of a path is minted. That rule lives behind `registry.hierarchical`,
596
+ * so a domains registry that lost its `hierarchical: true` line would judge a
597
+ * two-segment path as one opaque string and pass it the moment that exact
598
+ * string appeared in the file — the segment rule switched off by an omission,
599
+ * at exit 0, with nothing said. The converse is as bad: a flat registry
600
+ * declaring itself hierarchical would split values on '/' and demand parents
601
+ * nobody meant to mint.
602
+ *
603
+ * Neither direction is a defect the value-level checks can see, because both
604
+ * produce a coherent-looking verdict about the wrong question. So the shape is
605
+ * declared on both sides and the disagreement is the finding: reported ONCE per
606
+ * registry, against the registry file, because the registry is what must change
607
+ * — reporting it per value would bury one edit under a finding for every leaf.
608
+ *
609
+ * @param {object} registry the loaded registry
610
+ * @param {boolean|undefined} expected the shape the declaration requires
611
+ * @returns {{ code: string, message: string }|null}
612
+ */
613
+ function registryShapeMismatch(registry, expected, registryKey) {
614
+ if (expected === undefined || registry.hierarchical === expected) return null;
615
+ return {
616
+ code: 'registry-shape-mismatch',
617
+ message: expected
618
+ ? `the "${registryKey}" registry must be hierarchical — the facet it governs is a '/'-joined path whose every segment must be minted, and a flat registry would judge the whole path as one opaque string, silently disabling that rule. Add "hierarchical: true" to ${registry.file}`
619
+ : `the "${registryKey}" registry declares itself hierarchical, but the facet it governs is a flat value — path splitting would demand parent values nobody minted. Remove "hierarchical: true" from ${registry.file}`,
620
+ };
621
+ }
622
+
623
+ /**
624
+ * Judge one facet value against its registry — the whole membership rule.
625
+ *
626
+ * Returns the finding this value earns, or null when it is legitimately
627
+ * minted. Four outcomes, and each one names both the value and the registry,
628
+ * because a finding that says only "unknown value" leaves the author guessing
629
+ * which of four vocabularies to go read:
630
+ *
631
+ * - the registry did not load at all → `missing-registry`. This is where
632
+ * registry ABSENCE surfaces, and it surfaces HERE rather than at load
633
+ * because absence is only meaningful once a record actually claims a
634
+ * governed value. A store that governs nothing is complete; a store whose
635
+ * leaf cites a domain with no domains registry is a check that never ran.
636
+ * - the value is minted → clean.
637
+ * - the value is SUPPRESSED → `suppressed-value`, quoting the refusal. A
638
+ * term that was considered and rejected must not read as a typo: the
639
+ * author needs to know the vocabulary decision already went against them.
640
+ * - a hierarchical path with an unminted segment → `unminted-segment`,
641
+ * naming the SEGMENT rather than the whole path. "trading/derivatives/swaps
642
+ * is not in the registry" sends an author looking for the wrong edit; the
643
+ * finding they can act on is which parent is missing.
644
+ * - otherwise → `unregistered-value`.
645
+ *
646
+ * @param {object} registry the loaded registry, or undefined if absent
647
+ * @param {string} registryKey the "<store>/<name>" the row declared
648
+ * @param {string} value the facet value as the record spells it
649
+ * @returns {{ code: string, message: string }|null}
650
+ */
651
+ function judgeValue(registry, registryKey, value) {
652
+ if (!registry) {
653
+ return {
654
+ code: 'missing-registry',
655
+ message: `value "${value}" is governed by the "${registryKey}" registry, which the store does not carry — the vocabulary this value must be minted in never loaded, so its membership was never checked (a check that never ran is a blocking defect, PRD §5)`,
656
+ };
657
+ }
658
+ const where = `the "${registryKey}" registry (${registry.file})`;
659
+ if (registry.suppressed.has(value)) {
660
+ return {
661
+ code: 'suppressed-value',
662
+ message: `value "${value}" is SUPPRESSED in ${where} — this term was proposed and refused, and a suppression is durable: re-minting it is a registry edit with its own Decisions entry, never a quiet reuse`,
663
+ };
664
+ }
665
+ if (registry.hierarchical) {
666
+ // Every segment above a child must itself be minted: a hierarchy where a
667
+ // child may hang off an unminted parent is not a hierarchy, it is a set of
668
+ // strings that happen to contain slashes.
669
+ const segments = value.split('/');
670
+ for (let i = 0; i < segments.length; i += 1) {
671
+ const path = segments.slice(0, i + 1).join('/');
672
+ if (registry.suppressed.has(path)) {
673
+ return {
674
+ code: 'suppressed-value',
675
+ message: `value "${value}" descends from "${path}", which is SUPPRESSED in ${where} — a refused class mints no children`,
676
+ };
677
+ }
678
+ if (!registry.minted.has(path)) {
679
+ return {
680
+ code: 'unminted-segment',
681
+ message: `value "${value}" is invalid: the segment "${path}" is not minted in ${where} — every segment of a hierarchical path must be minted before a child may hang off it, and each minting is a registry edit plus a Decisions entry (literary warrant: material must exist to fill it)`,
682
+ };
683
+ }
684
+ }
685
+ return null;
686
+ }
687
+ if (registry.minted.has(value)) return null;
688
+ return {
689
+ code: 'unregistered-value',
690
+ message: `value "${value}" is not minted in ${where} — governed facets draw only from their registry; minting a new value is a registry edit plus a Decisions entry, never an ad-hoc string`,
691
+ };
692
+ }
693
+
694
+ /**
695
+ * Every governed facet value is minted in its registry (UCS-1148).
696
+ *
697
+ * Generic over FACET_REGISTRIES and GOVERNED_COLLECTIONS: this function knows
698
+ * how to walk a declared path and how to judge a value, and nothing about which
699
+ * kinds or facets exist. That is the seam UCS-1149 extends by declaration —
700
+ * a second governed record kind is two table entries, not an edit here.
701
+ */
702
+ function checkRegistryMembership(model, push) {
703
+ // Shape first, once per registry rather than once per record: a registry
704
+ // whose shape disagrees with the facet it governs is judging the wrong
705
+ // question, so the disagreement is reported against the registry file rather
706
+ // than against every record that drew from it. De-duplicated across kinds,
707
+ // since two kinds may legitimately draw on one registry — sound because
708
+ // assertConsistentRegistryShapes refused at load any table whose rows
709
+ // disagree about a registry's shape, so whichever row is seen first here
710
+ // speaks for all of them.
711
+ const shapeChecked = new Set();
712
+ for (const rows of Object.values(FACET_REGISTRIES)) {
713
+ for (const { registry: registryKey, hierarchical } of rows) {
714
+ if (shapeChecked.has(registryKey)) continue;
715
+ shapeChecked.add(registryKey);
716
+ const registry = model.registries.get(registryKey);
717
+ if (!registry) continue; // absence is the per-value missing-registry finding
718
+ const mismatch = registryShapeMismatch(registry, hierarchical, registryKey);
719
+ if (mismatch) {
720
+ push({
721
+ severity: 'error', code: mismatch.code, id: registryKey,
722
+ file: registry.file, path: 'hierarchical', message: mismatch.message,
723
+ });
724
+ }
725
+ }
726
+ }
727
+ for (const [kind, rows] of Object.entries(FACET_REGISTRIES)) {
728
+ // Non-null by construction: assertGovernedKinds refused the table at load
729
+ // if any declared kind lacked a collection.
730
+ for (const entry of model[GOVERNED_COLLECTIONS[kind]].values()) {
731
+ checkOneRecord(model, push, entry, rows);
732
+ }
733
+ }
734
+ }
735
+
736
+ /** Judge one record's governed facets against the rows its kind declares. */
737
+ function checkOneRecord(model, push, entry, rows) {
738
+ const { file, record } = entry;
739
+ const id = recordId(entry);
740
+ for (const { within, field, each, registry: registryKey, blankOwnedBy } of rows) {
741
+ const registry = model.registries.get(registryKey);
742
+ // A row may sit inside a repeated sub-record (citations[]); declaring the
743
+ // container once keeps the table free of per-index rows.
744
+ const container = within ? valueAtPath(record, within) : null;
745
+ const hosts = within
746
+ ? (Array.isArray(container)
747
+ ? container.map((host, i) => [host, `${within}[${i}]`])
748
+ : [])
749
+ : [[record, '']];
750
+ for (const [host, hostPath] of hosts) {
751
+ if (!isObject(host)) continue;
752
+ const raw = valueAtPath(host, field);
753
+ // Non-strings are already diagnosed by KK-02's schema check; a second
754
+ // complaint here would double-report one defect.
755
+ const values = each
756
+ ? (Array.isArray(raw) ? raw.map((v, i) => [v, `${field}[${i}]`]) : [])
757
+ : [[raw, field]];
758
+ for (const [value, valuePath] of values) {
759
+ if (typeof value !== 'string') continue;
760
+ // A BLANK value is deferred ONLY when another check owns this field's
761
+ // absence — declared as `blankOwnedBy`, which today is just
762
+ // `citations[].authority` and its `missing-authority` check. There,
763
+ // reporting `unregistered-value ""` too would put a second finding on a
764
+ // path that already carries the one an author can act on.
765
+ //
766
+ // Everywhere else a blank is judged HERE, and that asymmetry is the
767
+ // point: no check owns `facets.stage: ""`, so skipping it would make an
768
+ // empty governed facet pass silently at exit 0 — a governed field
769
+ // ungoverned by an empty string, which is worse than double-reporting.
770
+ // The registry does not mint "", so it falls out as unregistered.
771
+ if (value.trim() === '' && blankOwnedBy) continue;
772
+ const verdict = judgeValue(registry, registryKey, value);
773
+ if (!verdict) continue;
774
+ push({
775
+ severity: 'error', code: verdict.code, id, file,
776
+ path: hostPath ? `${hostPath}.${valuePath}` : valuePath,
777
+ message: verdict.message,
778
+ });
779
+ }
780
+ }
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Leaf citations must carry a non-empty source (§3.2) and an authority tier
786
+ * (UCS-1149).
787
+ *
788
+ * The two failures are separate codes because they are separate defects. An
789
+ * empty `source` is an unsourced claim. A missing `authority` is a sourced
790
+ * claim that records nothing about HOW FAR the source can be trusted — a
791
+ * regulator's text and a hallway conversation read identically once the tier
792
+ * is gone, and a reader comparing two leaves that disagree has nothing to go
793
+ * on but the source strings themselves.
794
+ *
795
+ * Note what this check does and does not claim. It governs the tier as
796
+ * VOCABULARY: present, and minted in the registry. Nothing in this engine
797
+ * compares two tiers or resolves a conflict between citations today —
798
+ * automatic conflict ranking arrives with the resolution pipeline (UCS-1152).
799
+ * Requiring the tier now is what makes that possible later: a tier nobody
800
+ * recorded cannot be ranked retroactively.
801
+ *
802
+ * Absence is checked here; a tier naming a value the registry does not carry is
803
+ * the ordinary `unregistered-value` finding the facet table already declares
804
+ * (`{ within: 'citations', field: 'authority' }`). Two codes, because "you left
805
+ * it out" and "that tier does not exist" send an author to two different edits
806
+ * — and neither is checked by the other: the membership walk skips a value that
807
+ * is not a string, which is exactly what an absent field is.
808
+ *
809
+ * The tier requirement is gated on the store CARRYING an authority-tiers
810
+ * registry, which is UCS-1148's opt-in conduct applied one level up rather than
811
+ * a softening of it. A store with no such registry has no vocabulary to draw a
812
+ * tier from, so demanding one would be demanding a value that could only be
813
+ * unregistered — every leaf failing twice for one thing the store never opted
814
+ * into. Once the registry exists the project HAS said tiers govern its
815
+ * citations, and an untiered citation is then a real omission. The escalation
816
+ * is the steward's, made by adding the file, and it is exactly the escalation
817
+ * `missing-registry` refuses to let happen silently in the other direction.
818
+ */
819
+ function checkCitations(model, push) {
820
+ const tiersGoverned = model.registries.has('knowledge/authority-tiers');
821
+ for (const leaf of model.leaves.values()) {
822
+ const { file, record } = leaf;
823
+ if (!Array.isArray(record.citations)) continue; // presence is a schema check
824
+ record.citations.forEach((c, i) => {
825
+ if (!isObject(c)) return;
826
+ if (typeof c.source === 'string' && c.source.trim() === '') {
827
+ push({
828
+ severity: 'error', code: 'missing-citation', id: recordId(leaf), file,
829
+ path: `citations[${i}].source`,
830
+ message: 'citation source is empty — an unsourced claim is not promotable (§3.2)',
831
+ });
832
+ }
833
+ // A non-string authority is a schema defect KK-02 already reported;
834
+ // complaining again here would double-report one mistake.
835
+ if (tiersGoverned
836
+ && (c.authority === undefined || (typeof c.authority === 'string' && c.authority.trim() === ''))) {
837
+ push({
838
+ severity: 'error', code: 'missing-authority', id: recordId(leaf), file,
839
+ path: `citations[${i}].authority`,
840
+ message: 'citation carries no authority tier — nothing records how far this source can be trusted, so a regulator\'s text and a hallway conversation read identically; name a tier minted in the "knowledge/authority-tiers" registry (UCS-1149)',
841
+ });
842
+ }
843
+ });
844
+ }
845
+ }
846
+
847
+ /**
848
+ * The Time facet's presence check (UCS-1150) — a leaf under time governance
849
+ * must carry a usable `verified` date.
850
+ *
851
+ * This is the validator's whole share of the time facet, and it is deliberately
852
+ * DATE-FREE: it asks whether the leaf can be judged, never whether it is stale.
853
+ * Staleness needs an injected `today` and belongs to the surfaces that project
854
+ * verdicts; asking it here would put a wall-clock-dependent finding into the
855
+ * baseline finding set, and diffing that set against a baseline is exactly what
856
+ * D-012 protects. So this check's output is identical on every run forever,
857
+ * whatever day it is.
858
+ *
859
+ * Two findings, because two different things are wrong:
860
+ *
861
+ * missing-verified a leaf declaring a NON-STATIC volatility with no
862
+ * `verified` date. It has asked to be governed by time
863
+ * and given nothing to measure from, so its verdict can
864
+ * only ever be `undated` — a leaf that can never be
865
+ * trusted and never be stale, sitting in the store
866
+ * looking governed.
867
+ * malformed-verified a date that matches the schema's pattern but names no
868
+ * real day. `2026-02-30` is shaped like a date and rolls
869
+ * forward to March 2nd under `Date.parse`, so an age
870
+ * measured from it is off by two days with nothing said —
871
+ * the defect `isCalendarDate` was written for (UCS-957).
872
+ * The schema checks the shape; the calendar is checked
873
+ * here, where the finding can name the file and field.
874
+ *
875
+ * A STATIC leaf with no date is CLEAN, and that asymmetry is the point. Static
876
+ * knowledge never stales, so a date on it would measure an age nothing consumes
877
+ * — demanding one would be demanding a field with no reader, which is how a
878
+ * store fills up with ritual metadata nobody maintains. A static leaf that DOES
879
+ * carry a date still has it checked for the calendar: an author who wrote one
880
+ * meant it, and a malformed one is a mistake whether or not anything measures
881
+ * from it.
882
+ *
883
+ * A leaf declaring NO volatility is clean too — absent means exempt from time
884
+ * governance (see the schema), so there is nothing to be missing. That is what
885
+ * keeps a store mid-migration from failing validation on every un-migrated leaf
886
+ * the day this ticket lands.
887
+ */
888
+ function checkVerifiedDates(model, push) {
889
+ for (const leaf of model.leaves.values()) {
890
+ const { file, record } = leaf;
891
+ // Read through the module's own reader rather than testing membership here.
892
+ // A second spelling of "is this a known class" is a second chance to get it
893
+ // wrong — `volatility in VOLATILITY_LIMITS` would answer TRUE for inherited
894
+ // names like `toString`, and this check would then demand a date from a
895
+ // class that has no threshold. One reader, one answer.
896
+ //
897
+ // Null covers both a non-string volatility and an unknown class. Either is
898
+ // a schema defect already reported, and judging the date on top of it would
899
+ // be a second finding for one mistake.
900
+ const volatility = leafVolatility(record);
901
+ if (volatility === null) continue;
902
+ const verified = record[VERIFIED_FIELD];
903
+
904
+ if (verified === undefined) {
905
+ // Static never stales, so it needs no date to measure from.
906
+ if (volatility === 'static') continue;
907
+ push({
908
+ severity: 'error', code: 'missing-verified', id: recordId(leaf), file,
909
+ path: VERIFIED_FIELD,
910
+ message: `${volatility} knowledge carries no "${VERIFIED_FIELD}" date — it stales after ${VOLATILITY_LIMITS[volatility]} days and nothing records when it was last checked, so its freshness can never be computed (UCS-1150)`,
911
+ });
912
+ continue;
913
+ }
914
+ // A non-string date is a schema defect (KK-02) — do not double-report.
915
+ if (typeof verified !== 'string') continue;
916
+ if (!isCalendarDate(verified)) {
917
+ push({
918
+ severity: 'error', code: 'malformed-verified', id: recordId(leaf), file,
919
+ path: VERIFIED_FIELD,
920
+ message: `"${VERIFIED_FIELD}: ${verified}" is not a real calendar date — an age measured from a day that does not exist is a number no calendar agrees with (YYYY-MM-DD, UCS-957)`,
921
+ });
922
+ }
923
+ }
924
+ }
925
+
926
+ /** Decision supersedes chains must be acyclic (§3.3). One finding per cycle. */
927
+ /**
928
+ * Every edition bump must be sanctioned by a phoenix event (UCS-1154).
929
+ *
930
+ * `edition` is not an ordinary field an author may increment. A phoenix event
931
+ * is THE ONLY thing that bumps it in v2, so a leaf at edition > 1 is making a
932
+ * claim — "a governed bulk re-taxonomy moved me" — and this check holds it to
933
+ * that claim. Without it the field would be a number anyone could type, and
934
+ * "the edition tells you a phoenix event happened" would be a convention rather
935
+ * than a fact.
936
+ *
937
+ * Checkable from the WORKING TREE ALONE, which is why the event mappings are
938
+ * retained in `knowledge/_phoenix/` rather than living only in the PR that
939
+ * applied them. The validator sees a fresh clone with no git history, and a
940
+ * governance rule it could only verify by reading commits is a rule it cannot
941
+ * verify at all.
942
+ *
943
+ * A carried-forward row does not count: it records that a leaf was considered
944
+ * and deliberately left alone, so it cannot also be the warrant for an edition
945
+ * that says the leaf changed.
946
+ */
947
+ function checkEditions(model, push) {
948
+ for (const { id, file, edition, events, expected } of unaccountedEditions(model)) {
949
+ const sanctioned = events.length
950
+ ? `${events.length} retained phoenix event(s) move it (${events.join(', ')}), so it should be at ${expected}`
951
+ : `no retained phoenix event moves it, so it should be at ${FIRST_EDITION}`;
952
+ push({
953
+ severity: 'error', code: 'unaccounted-edition', id, file, path: EDITION_FIELD,
954
+ message: `leaf "${id}" is at ${EDITION_FIELD} ${edition}, but ${sanctioned} — an edition COUNTS the governed re-taxonomies that moved a leaf, and a phoenix event is the only thing that bumps it, so a number that matches no mapping is one nobody sanctioned (UCS-1154)`,
955
+ });
956
+ }
957
+ }
958
+
959
+ /**
960
+ * Every graduation or revocation entry is held against the category table
961
+ * (UCS-1155).
962
+ *
963
+ * This is the machine-checkable half of trust graduation, and it is worth being
964
+ * precise about which half that is. The engine does NOT compute approved-
965
+ * unmodified counts, does not decide whether a threshold was met, and does not
966
+ * grant or withdraw autonomy: v1's analytics are MANUAL, and the moderator
967
+ * judges the recorded counts themselves (steward-guide.md). What is checked is
968
+ * that the recorded artifacts are well-formed and consistent with the governed
969
+ * table — which is exactly the part a human reviewer cannot reliably do by eye
970
+ * across a growing decisions store.
971
+ *
972
+ * Three findings, because three different things are wrong:
973
+ *
974
+ * missing-graduation-table
975
+ * an entry moves the trust boundary in a store carrying no
976
+ * category table at all. Absence surfaces HERE, at the point a
977
+ * record actually needs the table, for the same reason
978
+ * `missing-registry` does: a store that governs nothing is
979
+ * complete, but a graduation judged against a table that never
980
+ * loaded is a check that never ran (PRD §5).
981
+ * undeclared-category
982
+ * the entry names a category the table does not declare. Autonomy
983
+ * is per category, so a graduation for a category nobody scoped is
984
+ * autonomy with no declared extent — nothing says what class of
985
+ * change it covers, and nothing could later revoke it by name.
986
+ * gated-category-graduation
987
+ * a graduation for a PERMANENTLY GATED category. This is the one
988
+ * the whole mechanism exists to refuse: the gated list is where
989
+ * judgment lives (new domain classes, contradicts/supersedes
990
+ * edges, authority assignments, anything citation-bearing), and a
991
+ * judgment call does not become mechanical by having been made
992
+ * correctly N times. Revoking a gated category is NOT a finding —
993
+ * a revocation only ever narrows autonomy, and refusing to record
994
+ * one would be refusing the safe direction.
995
+ * graduation-not-trust-category
996
+ * an entry carrying a graduation block but filed under some other
997
+ * decision category. `trust` is the category that says the third
998
+ * store governs the trust boundary (PRD §3); an entry filed
999
+ * elsewhere moves that boundary invisibly to anyone auditing it
1000
+ * by category, which is how a steward reads the store
1001
+ * disconnected-revocation
1002
+ * a revocation whose link to the graduation it withdraws is
1003
+ * missing or wrong: it names no `revokes` WHILE a graduation for
1004
+ * the same category stands, or its `revokes` names an entry that
1005
+ * is not a graduation, or one that graduates a DIFFERENT category.
1006
+ * Omission is clean when no graduation exists — the
1007
+ * standing-position case has nothing to point at — and an
1008
+ * unresolved id is the ref graph's to report
1009
+ * graduation-field-shape
1010
+ * the block's fields disagree with its own action: a graduation
1011
+ * with no `observed-cycles` (the only record of what was counted,
1012
+ * since v1 computes nothing) or carrying withdrawal fields, or a
1013
+ * revocation naming no `defect` (the automatic trigger)
1014
+ */
1015
+ /**
1016
+ * Which `graduation:` fields each action requires and which it refuses.
1017
+ *
1018
+ * The schema states these in prose because it cannot state them in keywords —
1019
+ * there is no conditional in the subset this engine interprets. Enforcing them
1020
+ * here is what keeps that prose from being decoration.
1021
+ *
1022
+ * Each rule earns its place:
1023
+ *
1024
+ * graduate REQUIRES `observed-cycles` — the recorded count is the entire
1025
+ * reviewable basis of the judgment. v1 computes nothing, so a graduation
1026
+ * with no written count is exactly the unreviewable decision the manual-
1027
+ * analytics stance depends on NOT existing: a reader cannot ask whether the
1028
+ * bar was met, because nobody wrote down what was counted.
1029
+ * graduate REFUSES `revokes` and `defect` — both describe a withdrawal. On a
1030
+ * grant they are either copy-paste from the revocation template or a
1031
+ * confusion about which direction the entry moves, and each would leave a
1032
+ * grant carrying the vocabulary of its own reversal.
1033
+ * revoke REQUIRES `defect` — revocation is automatic ON A DEFECT, so the
1034
+ * defect is the trigger. A revocation that names none records that trust
1035
+ * was withdrawn for no stated reason, and the next graduation of that
1036
+ * category has nothing to have fixed.
1037
+ *
1038
+ * `observed-cycles` is deliberately NOT refused on a revoke: it is meaningless
1039
+ * there rather than contradictory, and refusing a harmless field would cost an
1040
+ * author an edit for nothing.
1041
+ *
1042
+ * @param {object} graduation the typed block
1043
+ * @param {'graduate'|'revoke'} action the block's own action
1044
+ * @returns {Array<{field: string, message: string}>}
1045
+ */
1046
+ function graduationShapeDefects(graduation, action) {
1047
+ const defects = [];
1048
+ if (action === 'graduate') {
1049
+ if (!Number.isInteger(graduation['observed-cycles'])) {
1050
+ defects.push({
1051
+ field: 'observed-cycles',
1052
+ message: 'graduation records no "observed-cycles" — v1 computes no counts, so this number is the ONLY record of what the moderator judged against the category\'s threshold; without it the graduation cannot be reviewed by anyone who was not in the room (UCS-1155: the analytics are manual by design)',
1053
+ });
1054
+ }
1055
+ for (const field of ['revokes', 'defect']) {
1056
+ if (graduation[field] !== undefined) {
1057
+ defects.push({
1058
+ field,
1059
+ message: `graduation carries "${field}", which describes a WITHDRAWAL of autonomy — this entry grants it. Remove the field, or change "action" to revoke if the entry was meant to withdraw a graduation`,
1060
+ });
1061
+ }
1062
+ }
1063
+ return defects;
1064
+ }
1065
+ if (typeof graduation.defect !== 'string' || graduation.defect.trim() === '') {
1066
+ defects.push({
1067
+ field: 'defect',
1068
+ message: 'revocation names no "defect" — revocation is automatic ON A DEFECT, so the defect is the trigger and the substance both; without it the record says trust was withdrawn for no stated reason, and the next graduation of this category has nothing to have fixed',
1069
+ });
1070
+ }
1071
+ return defects;
1072
+ }
1073
+
1074
+ function checkGraduations(model, push) {
1075
+ // One table per store, keyed "<store>/<name>"; decisions is where graduation
1076
+ // is governed (the change process is the team's truth anchor, D-003).
1077
+ const tables = [...model.graduations.values()];
1078
+ // Which categories the store carries a GRADUATION for, and the entry that
1079
+ // granted it. Built once: a revocation's coherence is a question about the
1080
+ // store as a whole, not about the entry in isolation, so it cannot be
1081
+ // answered while looking at one record.
1082
+ const graduatedBy = new Map();
1083
+ for (const entry of model.decisions.values()) {
1084
+ const block = entry.record.graduation;
1085
+ if (!isObject(block) || block.action !== 'graduate') continue;
1086
+ if (typeof block.category !== 'string') continue;
1087
+ if (!graduatedBy.has(block.category)) graduatedBy.set(block.category, recordId(entry));
1088
+ }
1089
+ for (const entry of model.decisions.values()) {
1090
+ const { file, record } = entry;
1091
+ const graduation = record.graduation;
1092
+ if (!isObject(graduation)) continue; // an ordinary decision carries no block
1093
+ const { action, category } = graduation;
1094
+ // A non-string category or unknown action is a schema defect KK-02 already
1095
+ // reported; judging it again here would double-report one mistake.
1096
+ if (typeof category !== 'string' || (action !== 'graduate' && action !== 'revoke')) continue;
1097
+ // The entry's own `category` field must be `trust`. The third store governs
1098
+ // the trust boundary (PRD §3), and `trust` is the category that says so —
1099
+ // an entry filed under `process` or `governance` that quietly moves the
1100
+ // boundary is invisible to anyone auditing the boundary by category, which
1101
+ // is how a steward reads the decisions store. A malformed value is a schema
1102
+ // defect KK-02 already reported, so only a well-formed MISMATCH is judged
1103
+ // here; one omission earns one finding.
1104
+ if (typeof record.category === 'string' && record.category !== 'trust') {
1105
+ push({
1106
+ severity: 'error', code: 'graduation-not-trust-category', id: recordId(entry), file,
1107
+ path: 'category',
1108
+ message: `entry carries a graduation block but is filed under category "${record.category}" — an entry that moves the trust boundary is a "trust" decision (PRD §3: the third store governs changes to the system's own trust boundary), and one filed elsewhere is invisible to anyone auditing that boundary by category`,
1109
+ });
1110
+ }
1111
+ // Which fields belong to which action. The schema describes these rules in
1112
+ // prose and cannot enforce them — its keyword subset has no conditional —
1113
+ // and a rule stated where nothing enforces it is contract drift wearing the
1114
+ // appearance of a check. One code for the family, like the loader's
1115
+ // `graduation-threshold-shape`, with a message per field: they are one
1116
+ // defect class (this block's fields disagree with its own action) and an
1117
+ // author fixes them the same way.
1118
+ for (const defect of graduationShapeDefects(graduation, action)) {
1119
+ push({
1120
+ severity: 'error', code: 'graduation-field-shape', id: recordId(entry), file,
1121
+ path: `graduation.${defect.field}`, message: defect.message,
1122
+ });
1123
+ }
1124
+ if (!tables.length) {
1125
+ push({
1126
+ severity: 'error', code: 'missing-graduation-table', id: recordId(entry), file,
1127
+ path: 'graduation.category',
1128
+ message: `entry ${action}s the category "${category}", but the store carries no graduation category table — the table that declares which categories may graduate never loaded, so this entry's category was never checked (a check that never ran is a blocking defect, PRD §5)`,
1129
+ });
1130
+ continue;
1131
+ }
1132
+ const row = tables.map((t) => t.categories.get(category)).find(Boolean);
1133
+ if (!row) {
1134
+ const table = tables[0];
1135
+ push({
1136
+ severity: 'error', code: 'undeclared-category', id: recordId(entry), file,
1137
+ path: 'graduation.category',
1138
+ message: `category "${category}" is not declared in the graduation category table (${table.file}) — autonomy is granted per CATEGORY, so a ${action} naming an undeclared one has no scope anyone wrote down; declare the category with its eligibility and warrant, or correct the name`,
1139
+ });
1140
+ continue;
1141
+ }
1142
+ // A revocation only ever narrows autonomy, so it is legitimate against any
1143
+ // declared category — including a gated one, where it is a no-op that
1144
+ // records a defect was found. Only GRADUATION is refused.
1145
+ if (action === 'graduate' && row.eligibility === 'gated') {
1146
+ push({
1147
+ severity: 'error', code: 'gated-category-graduation', id: recordId(entry), file,
1148
+ path: 'graduation.category',
1149
+ message: `category "${category}" is PERMANENTLY GATED in ${row.file} and cannot graduate — the table's warrant for gating it is the standing answer, and a judgment call does not become mechanical by having been made correctly ${typeof graduation['observed-cycles'] === 'number' ? graduation['observed-cycles'] : 'N'} times; changing that is a table edit with its own Decisions entry, never a graduation against the table as it stands`,
1150
+ });
1151
+ }
1152
+ // A revocation must name the graduation it withdraws WHEN THERE IS ONE.
1153
+ // The `revokes` field's whole promise is that the withdrawal and the thing
1154
+ // withdrawn stay connected in the record; a revocation that omits it while
1155
+ // a graduation for that category stands leaves the store asserting both,
1156
+ // with nothing linking them — a reader cannot tell whether the graduation
1157
+ // is live, and the revocation names no target to check against.
1158
+ //
1159
+ // Omission stays CLEAN when no graduation for the category exists. That is
1160
+ // the standing-position case: recording that a never-graduated category is
1161
+ // (and remains) at full inspection is a legitimate entry, and it has no
1162
+ // graduation to point at. Demanding a ref there would demand a citation of
1163
+ // something that does not exist.
1164
+ if (action === 'revoke' && typeof graduation.revokes !== 'string') {
1165
+ const granted = graduatedBy.get(category);
1166
+ if (granted !== undefined) {
1167
+ push({
1168
+ severity: 'error', code: 'disconnected-revocation', id: recordId(entry), file,
1169
+ path: 'graduation.revokes',
1170
+ message: `revocation of "${category}" names no graduation, but ${granted} graduates that category — the withdrawal and the thing withdrawn must stay connected in the record, or a reader cannot tell whether ${granted} still stands; name it in "revokes"`,
1171
+ });
1172
+ }
1173
+ }
1174
+ // A PRESENT `revokes` must name an actual graduation OF THIS CATEGORY.
1175
+ // Existence alone is what the ref graph checks, and existence is not the
1176
+ // claim: `revokes` asserts "this entry withdraws that grant", so a
1177
+ // revocation pointing at an ordinary ADR, or at a graduation of some other
1178
+ // category, records a withdrawal of something that was never granted —
1179
+ // clean-looking, and false. The ref graph cannot see this because every id
1180
+ // it resolves is equally a real decision.
1181
+ //
1182
+ // An UNRESOLVED id is skipped: that is the ref graph's `unresolved-ref` to
1183
+ // report, and adding a second finding would double-report one typo.
1184
+ if (action === 'revoke' && typeof graduation.revokes === 'string') {
1185
+ const target = model.decisions.get(graduation.revokes);
1186
+ if (target !== undefined) {
1187
+ const targetBlock = target.record.graduation;
1188
+ const targetIsGraduation = isObject(targetBlock) && targetBlock.action === 'graduate';
1189
+ if (!targetIsGraduation) {
1190
+ push({
1191
+ severity: 'error', code: 'disconnected-revocation', id: recordId(entry), file,
1192
+ path: 'graduation.revokes',
1193
+ message: `"revokes" names ${graduation.revokes}, which is not a graduation — a revocation withdraws a grant of autonomy, so it must name the entry that granted it; ${graduation.revokes} ${isObject(targetBlock) ? 'is itself a revocation' : 'carries no graduation block at all'}`,
1194
+ });
1195
+ } else if (targetBlock.category !== category) {
1196
+ push({
1197
+ severity: 'error', code: 'disconnected-revocation', id: recordId(entry), file,
1198
+ path: 'graduation.revokes',
1199
+ message: `this entry revokes "${category}" but "revokes" names ${graduation.revokes}, which graduates "${targetBlock.category}" — a revocation must withdraw a grant made for its OWN category, or it records the withdrawal of something that was never granted while leaving the real graduation standing`,
1200
+ });
1201
+ }
1202
+ }
1203
+ }
1204
+ }
1205
+ }
1206
+
1207
+ function checkDecisionCycles(model, push) {
1208
+ const seen = new Set(); // canonical cycle keys — each loop reported once
1209
+ const color = new Map(); // 0/undefined = white, 1 = on stack, 2 = done
1210
+ const stack = [];
1211
+
1212
+ const visit = (id) => {
1213
+ color.set(id, 1);
1214
+ stack.push(id);
1215
+ const record = model.decisions.get(id)?.record;
1216
+ for (const to of strings(record?.supersedes).sort(compare)) {
1217
+ if (!model.decisions.has(to)) continue; // unresolved-ref is the loader's
1218
+ if (color.get(to) === 1) {
1219
+ const cycle = stack.slice(stack.indexOf(to));
1220
+ // Canonical rotation: start at the smallest id, attribute to it.
1221
+ const start = cycle.indexOf([...cycle].sort(compare)[0]);
1222
+ const rotated = [...cycle.slice(start), ...cycle.slice(0, start)];
1223
+ const key = rotated.join('>');
1224
+ if (!seen.has(key)) {
1225
+ seen.add(key);
1226
+ const head = rotated[0];
1227
+ push({
1228
+ severity: 'error', code: 'ref-cycle', id: head,
1229
+ file: model.decisions.get(head).file, path: 'supersedes',
1230
+ message: `supersedes chain loops: ${[...rotated, head].join(' -> ')} — decision chains must be acyclic (§3.3)`,
1231
+ });
1232
+ }
1233
+ } else if (color.get(to) !== 2) {
1234
+ visit(to);
1235
+ }
1236
+ }
1237
+ stack.pop();
1238
+ color.set(id, 2);
1239
+ };
1240
+
1241
+ for (const id of [...model.decisions.keys()].sort(compare)) {
1242
+ if (!color.get(id)) visit(id);
1243
+ }
1244
+ }
1245
+
1246
+ /**
1247
+ * Run every structural check over a loaded model — the reusable seam
1248
+ * preflight (KK-26) consumes, so verdicts and this validator can never
1249
+ * disagree. Returns the stable-sorted findings list; loader health gating
1250
+ * (exit 2 on an unhealthy store) stays with the callers. `repoRoot` is where
1251
+ * source-of-truth paths resolve (the KK-08 two-root convention: pointers are
1252
+ * repo-root-relative, §9.1; in the flat dogfood layout it equals model.root).
1253
+ */
1254
+ export function runChecks(model, repoRoot = model.root) {
1255
+ const findings = [];
1256
+ const push = (f) => findings.push(f);
1257
+ checkCatalogs(model, push);
1258
+ checkConcepts(model, push, repoRoot);
1259
+ checkLeafPaths(model, push, repoRoot);
1260
+ checkOrphans(model, push);
1261
+ checkRegistryMembership(model, push);
1262
+ checkCitations(model, push);
1263
+ checkVerifiedDates(model, push);
1264
+ checkEditions(model, push);
1265
+ checkGraduations(model, push);
1266
+ checkDecisionCycles(model, push);
1267
+ findings.sort((a, b) =>
1268
+ compare(a.file, b.file) || compare(a.path, b.path) || compare(a.code, b.code) || compare(a.id, b.id));
1269
+ return findings;
1270
+ }
1271
+
1272
+ /**
1273
+ * Decision-entry provenance, published so a defect is traceable (UCS-1155).
1274
+ *
1275
+ * The point of recording an author and a skill version is being able to ask the
1276
+ * question backwards. When a graduated category turns out to be producing bad
1277
+ * changes, "which entries did that skill revision write?" is the question that
1278
+ * bounds the damage — and it is unanswerable if provenance is only ever stored
1279
+ * and never surfaced. So the validator publishes it for every entry that
1280
+ * carries it, and a bad skill revision becomes traceable like any other defect
1281
+ * rather than a thing someone has to remember.
1282
+ *
1283
+ * Entries WITHOUT provenance are omitted rather than listed as nulls. The field
1284
+ * is optional — the whole installed base predates it (D-001: no update channel,
1285
+ * so seeded stores keep what they have) — and a list padded with an entry for
1286
+ * every un-migrated record would bury the ones that can actually be traced.
1287
+ *
1288
+ * Stable-sorted by id and free of timestamps, like every other part of this
1289
+ * payload, so it stays baseline-diffable (D-012).
1290
+ *
1291
+ * @param {object} model the loaded store model
1292
+ * @returns {Array<{id: string, file: string, author: string|null, 'skill-version': string|null}>}
1293
+ */
1294
+ function decisionProvenance(model) {
1295
+ const rows = [];
1296
+ for (const entry of model.decisions.values()) {
1297
+ const provenance = entry.record?.provenance;
1298
+ if (!isObject(provenance)) continue;
1299
+ const author = typeof provenance.author === 'string' ? provenance.author : null;
1300
+ const skillVersion = typeof provenance['skill-version'] === 'string'
1301
+ ? provenance['skill-version']
1302
+ : null;
1303
+ // A provenance block carrying neither field records nothing; publishing it
1304
+ // would advertise traceability the entry does not actually have.
1305
+ if (author === null && skillVersion === null) continue;
1306
+ rows.push({ id: recordId(entry), file: entry.file, author, 'skill-version': skillVersion });
1307
+ }
1308
+ rows.sort((a, b) => compare(a.id, b.id));
1309
+ return rows;
1310
+ }
1311
+
1312
+ // ------------------------------------------------------------- CLI plumbing
1313
+
1314
+ function parseArgs(argv) {
1315
+ const { options } = parseFlags(argv, {
1316
+ boolean: ['json'],
1317
+ value: ['root'],
1318
+ repeatable: ['concepts'],
1319
+ // `--concepts=` is an empty filter, and an empty filter never ran.
1320
+ allowEmpty: ['concepts'],
1321
+ });
1322
+ const opts = { json: !!options.json, root: options.root ?? process.cwd(), concepts: null };
1323
+ if (options.concepts) {
1324
+ opts.concepts = normalizeConceptIds(options.concepts.flatMap((v) => v.split(',')));
1325
+ if (!opts.concepts.length) {
1326
+ throw new UsageError('--concepts must name at least one concept id — a filter that never ran is a failure, never a silent pass');
1327
+ }
1328
+ }
1329
+ return opts;
1330
+ }
1331
+
1332
+ function render(payload) {
1333
+ const { counts } = payload;
1334
+ const lines = [];
1335
+ lines.push(counts.errors + counts.warnings === 0
1336
+ ? 'structural validate -> 0 findings — structurally clean'
1337
+ : `structural validate -> ${counts.errors + counts.warnings} finding(s) (${counts.errors} error(s), ${counts.warnings} warning(s))`);
1338
+ lines.push(`checks run: ${payload.checks.join(', ')}`, '');
1339
+ const health = payload['store-health'];
1340
+ if (health.warnings) {
1341
+ lines.push(`store health: ${health.errors} error(s), ${health.warnings} warning(s) — loader warnings do not block; errors would have (exit 2)`, '');
1342
+ }
1343
+ if (payload.concepts) lines.push(`filtered to concepts: ${payload.concepts.join(', ')}`, '');
1344
+ // Provenance is traceability, so it is printed where a human reading a
1345
+ // failing run can see it — not only in the JSON a machine parses.
1346
+ if (payload.provenance.length) {
1347
+ lines.push('decision provenance (author / skill version):');
1348
+ for (const p of payload.provenance) {
1349
+ lines.push(` ${p.id} ${p.author ?? '—'} ${p['skill-version'] ?? '—'}`);
1350
+ }
1351
+ lines.push('');
1352
+ }
1353
+ for (const f of payload.findings) {
1354
+ lines.push(`${f.severity} ${f.code} ${f.id} ${f.file} ${f.path}`, ` ${f.message}`);
1355
+ }
1356
+ if (counts.errors) {
1357
+ lines.push('', 'fix every error-severity finding before merging — this validator is blocking-grade (PRD §4)');
1358
+ }
1359
+ return lines;
1360
+ }
1361
+
1362
+ export function main(argv) {
1363
+ {
1364
+ const opts = parseArgs(argv);
1365
+
1366
+ let model;
1367
+ try {
1368
+ // KK-08 two-root convention: --root is the REPO root; the stores live
1369
+ // at <root>/unknown-knowledge/ when seeded (§9.1) or at the root itself
1370
+ // (dogfood layout). Pointers stay repo-root-relative either way.
1371
+ model = loadStores(locateKitRoot(opts.root));
1372
+ } catch (error) {
1373
+ // An EXPECTED refusal from the loader — an unreadable root, an ambiguous
1374
+ // kit layout, a Store that will not load. The stores this command would
1375
+ // check never loaded, so its checks never ran: exit 2, never 1.
1376
+ process.stderr.write(`validate: ${error.message}\n`);
1377
+ rethrowIfBug(error); // a bug, or a UsageError raised deep in the loader, is not ours to speak for
1378
+ return EXIT_CODES.FAILURE;
1379
+ }
1380
+
1381
+ // Single health model (PRD §4): loader errors mean the stores this
1382
+ // validator would check never fully loaded — its structural checks never
1383
+ // ran, and a check that never ran is a blocking defect (exit 2), never a
1384
+ // silent pass or a partial findings list.
1385
+ if (!model.ok) {
1386
+ const { errors } = storeHealth(model);
1387
+ process.stderr.write(`validate: the store loader reported ${errors.length} error(s) — structural checks never ran (a check that never ran is a blocking defect, PRD §5)\n`);
1388
+ for (const d of errors) {
1389
+ process.stderr.write(` ${d.code} ${d.file}${d.path ? ` ${d.path}` : ''} ${d.message}\n`);
1390
+ }
1391
+ return EXIT_CODES.FAILURE;
1392
+ }
1393
+
1394
+ let findings = runChecks(model, opts.root);
1395
+ if (opts.concepts) {
1396
+ for (const id of opts.concepts) {
1397
+ // Loaded concepts plus catalog-declared ids (a declared-but-missing
1398
+ // record is exactly what a mid-session check needs to look at).
1399
+ const declared = model.stores.ontology.catalog?.entries?.some?.(
1400
+ (row) => isObject(row) && row.id === id);
1401
+ if (!model.concepts.has(id) && !declared) {
1402
+ process.stderr.write(`validate: unknown concept id "${id}" — filtering on a typo must never read as a clean pass\n`);
1403
+ return EXIT_CODES.FAILURE;
1404
+ }
1405
+ }
1406
+ const wanted = new Set(opts.concepts);
1407
+ findings = findings.filter((f) => wanted.has(f.id));
1408
+ }
1409
+
1410
+ const payload = {
1411
+ checks: CHECKS,
1412
+ ...(opts.concepts ? { concepts: opts.concepts } : {}),
1413
+ provenance: decisionProvenance(model),
1414
+ 'store-health': healthSummary(storeHealth(model)),
1415
+ counts: {
1416
+ errors: findings.filter((f) => f.severity === 'error').length,
1417
+ warnings: findings.filter((f) => f.severity === 'warning').length,
1418
+ },
1419
+ findings,
1420
+ };
1421
+
1422
+ const lines = opts.json ? [JSON.stringify(payload, null, 2)] : render(payload);
1423
+ process.stdout.write(`${lines.join('\n').replace(/\n+$/, '')}\n`);
1424
+ return payload.counts.errors ? EXIT_CODES.FINDINGS : EXIT_CODES.CLEAN;
1425
+ }
1426
+ }