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,1624 @@
1
+ /**
2
+ * Store loader (KK-04) — the engine's single shared substrate (PRD §4).
3
+ *
4
+ * Parses all three stores (ontology, knowledge, decisions) from a kit root
5
+ * directory ONCE into an indexed in-memory model, with diagnostics on one
6
+ * error/warning scale. Every downstream surface — structural validator
7
+ * (KK-05), value validator (KK-07), audit (KK-12), resolver (KK-06),
8
+ * preflight (KK-26) — consumes these same diagnostics: the single-health-model
9
+ * guarantee that validator, audit, and preflight can never disagree.
10
+ *
11
+ * Layout loaded (PRD §9.1):
12
+ * ontology/ _catalog.yaml _rules.yaml classes/*.yaml (concept records)
13
+ * knowledge/ _catalog.yaml _rules.yaml **\/*.md (leaf = YAML front
14
+ * matter + markdown body)
15
+ * decisions/ _catalog.yaml entries/*.yaml (decision records)
16
+ * <store>/ _registries/*.yaml (governed vocabularies,
17
+ * UCS-1148 — optional;
18
+ * also the trust
19
+ * graduation category
20
+ * table, UCS-1155)
21
+ *
22
+ * Which files a store carries is DATA, not control flow: STORE_DESCRIPTORS
23
+ * below is the table, so a registry is a declared file class rather than a
24
+ * fourth bespoke reader.
25
+ *
26
+ * Model shape (all collections deterministically sorted — PRD §5 diffability):
27
+ * {
28
+ * root, // absolute root the stores were loaded from
29
+ * stores: { ontology|knowledge|decisions:
30
+ * { present, catalog, rules, files } }, // parsed docs (null if absent),
31
+ * // record files root-relative
32
+ * concepts: Map id -> { id, file, record },
33
+ * leaves: Map accession -> { identity, id, notation, file, record, body },
34
+ * // `identity` is the neutral id key consumers
35
+ * // read (UCS-1142); since UCS-1147 it is the
36
+ * // leaf's accession and nothing else. `id` and
37
+ * // `notation` are the two public wire names —
38
+ * // `id` the accession, `notation` the OPTIONAL
39
+ * // LEGACY display label, null when absent and
40
+ * // never an identity anything resolves through
41
+ * decisions: Map id -> { id, file, record },
42
+ * registries: Map "<store>/<name>" ->
43
+ * { name, store, file, hierarchical, minted:Set, suppressed:Set },
44
+ * // governed vocabularies (UCS-1148): the closed
45
+ * // value sets facets draw from. Membership is a
46
+ * // structural-validator check (KK-05), never a
47
+ * // schema enum — the vocabulary grows by steward
48
+ * // review, not by an engine release
49
+ * graduations: Map "<store>/<table>" ->
50
+ * { table, store, file, categories:Map name->row },
51
+ * // trust graduation category tables (UCS-1155):
52
+ * // which change categories may graduate from
53
+ * // full inspection to sampling and which are
54
+ * // permanently gated. Autonomy is per CATEGORY,
55
+ * // never per leaf, so this table is what a
56
+ * // graduation or revocation entry is held against
57
+ * phoenix: Map "<store>/<event>" ->
58
+ * { event, store, file, decision, scope, rows:Map id->row },
59
+ * // retained phoenix event mappings (UCS-1154):
60
+ * // the record of a bulk re-taxonomy that already
61
+ * // happened, kept so the validator can hold every
62
+ * // non-1 edition against a sanctioning event
63
+ * // without reading git history
64
+ * pointers: Map source-of-truth path -> [concept ids], // KK-06 --paths
65
+ * leavesByConcept: Map concept id -> [leaf identities],
66
+ * // the leaf→concept edge derived in REVERSE at
67
+ * // load (UCS-1151). Declared leaf-side, walked
68
+ * // from either end: resolving a concept surfaces
69
+ * // its declaring leaves structurally, with no
70
+ * // dependence on whether any term text matches
71
+ * refs: [{ from, type, to, file, path, resolved }], // cross-ref graph
72
+ * diagnostics: [{ severity, code, file, path, message }],
73
+ * ok, // true iff no error-severity diagnostic
74
+ * }
75
+ *
76
+ * Diagnostics (severity 'error' | 'warning'), stable-sorted by file/path/code:
77
+ * parse-error error unparseable YAML, multi-doc file, or a leaf
78
+ * without front matter
79
+ * <validate-record> error every schema/convention code from KK-02
80
+ * (missing-required, non-string-enumerates-value,
81
+ * …) carried through unchanged — including the
82
+ * §3.5 YAML-coercion hard error: files are parsed
83
+ * with scalar types intact (js-yaml YAML 1.2 core
84
+ * schema), so true/1.0/null enumerates values are
85
+ * detected as non-strings, never stringified
86
+ * duplicate-id error same id minted twice across (or within) files
87
+ * unresolved-ref error typed ref to an id that neither loaded as an
88
+ * entry nor is declared in the target store's
89
+ * catalog (catalog-declared ids with pending
90
+ * files resolve — the catalog never implies a
91
+ * recorded id is absent; file-pointer resolution
92
+ * is KK-05's check)
93
+ * missing-store warning store directory absent (pre-init / partial
94
+ * repos load well-defined; post-init repos always
95
+ * have all three)
96
+ * missing-catalog error store directory present without _catalog.yaml
97
+ * (the navigational entry point, PRD §3)
98
+ * registry-name-mismatch
99
+ * error a registry's declared name disagrees with its
100
+ * filename, so a finding could not name both the
101
+ * registry and the file a steward opens (UCS-1148)
102
+ * registry-store-mismatch
103
+ * error a registry's declared store disagrees with the
104
+ * directory it sits in (UCS-1148)
105
+ * duplicate-registry-value
106
+ * error one value declared twice in a registry —
107
+ * redundantly, or as both minted AND suppressed,
108
+ * which the engine must never settle by file
109
+ * order (UCS-1148)
110
+ *
111
+ * A nonexistent/unreadable root THROWS — an engine failure (exit-code 2
112
+ * territory, PRD §5), never a silent diagnostic.
113
+ */
114
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
115
+ import { join, resolve } from 'node:path';
116
+ import { load, YAMLException } from 'js-yaml';
117
+ import { validateStoreFile, ERROR_CODES, compare } from './validate-record.js';
118
+ // The accession grammar, from the one module that owns it (UCS-1142) — the same
119
+ // source the schemas bind to, so what the loader will INDEX and what the
120
+ // validator will ACCEPT can never be two different notions of a leaf id.
121
+ import { idPattern } from './id-grammars.js';
122
+ import { UsageError } from './usage-error.js';
123
+
124
+ export const SEVERITIES = Object.freeze(['error', 'warning']);
125
+
126
+ /**
127
+ * The field holding a leaf's LEGACY dotted notation (UCS-1142, UCS-1147).
128
+ *
129
+ * Named ONCE, here. It was the leaf's id field; since the contract phase it is
130
+ * an optional display label that nothing treats as identity and no citation
131
+ * resolves through. It is still read in one place — the loader copies it onto
132
+ * the indexed entry — because it remains a PUBLISHED resolver field (§4), and
133
+ * a published field needs a single spelling as much as an id space does.
134
+ */
135
+ export const LEAF_ID_FIELD = 'notation';
136
+
137
+ /**
138
+ * The field a knowledge leaf mints its ACCESSION id in (UCS-1144).
139
+ *
140
+ * An accession is opaque, minted at PR time, never reused, never positional —
141
+ * everything a dotted notation is not. Since UCS-1147 every leaf carries one
142
+ * and it IS the leaf's identity: the only key the store indexes by, and the
143
+ * only spelling any record may cite it as.
144
+ */
145
+ export const LEAF_ACCESSION_FIELD = 'id';
146
+
147
+ /**
148
+ * A leaf's identity: its accession, and nothing else (UCS-1147).
149
+ *
150
+ * The contract phase's whole shape, in one function. A leaf IS its accession —
151
+ * that is what every consumer reads, reports, indexes and serializes, and it is
152
+ * the only spelling a citation may reach it by. The expand phase (UCS-1144) let
153
+ * this fall back to the notation for a leaf that had not been minted one yet,
154
+ * and returned a second `keys` list of alternate spellings so notation-form
155
+ * citations kept resolving while the migrate batches ran. Both are gone: the
156
+ * migrations finished, the accession is required, and a leaf answers to exactly
157
+ * one name again.
158
+ *
159
+ * A leaf with NO accession therefore has no identity, and this says so by
160
+ * returning `undefined` rather than reaching for the notation. That is the
161
+ * honest answer and it is also the useful one: indexRecord declines a
162
+ * non-string id, so an unaccessioned leaf simply does not enter the index, and
163
+ * the schema's `missing-required` on `id` is the finding the author acts on.
164
+ * Falling back to the notation would instead index the leaf under a spelling
165
+ * nothing may cite — present in every enumeration, reachable by no reference.
166
+ *
167
+ * The id is checked against the ACCESSION GRAMMAR, not merely for being a
168
+ * string, and that is load-bearing rather than defensive. The index is what ref
169
+ * resolution consults, so whatever key a leaf lands under becomes a spelling
170
+ * that RESOLVES. A leaf carrying `id: "700.2"` would otherwise take identity
171
+ * under its own notation and quietly restore the dual-shape contract this
172
+ * ticket retired: the schema would report both records, and the citation would
173
+ * resolve anyway — the two mechanisms disagreeing about whether a notation is a
174
+ * citation. Refusing the malformed id here keeps one answer. The defect is
175
+ * already reported (a `pattern-mismatch` on the leaf's `id`, and `id-shape` on
176
+ * any catalog row naming it), so this adds no finding; it only declines to
177
+ * build an index entry on top of a value no check approved.
178
+ *
179
+ * Non-string and malformed ids are dropped rather than coerced — KK-02 already
180
+ * diagnoses both, and a coerced key would index a leaf under a spelling no
181
+ * author ever wrote.
182
+ *
183
+ * @param {object} record a parsed leaf front matter
184
+ * @returns {string|undefined} the leaf's identity, or undefined when it mints none
185
+ */
186
+ export function leafIdentity(record) {
187
+ const accession = record[LEAF_ACCESSION_FIELD];
188
+ return typeof accession === 'string' && idPattern('accessions').test(accession)
189
+ ? accession
190
+ : undefined;
191
+ }
192
+
193
+ /**
194
+ * The id of one indexed record, whatever store it came from.
195
+ *
196
+ * Concepts and decisions carry `id`, leaves carry `identity` — one accessor,
197
+ * so a consumer that walks all three spaces (catalog checks, orphan checks)
198
+ * never names a store-specific field. The Map key is the same value; this is
199
+ * for the code paths that hold the entry rather than the pair.
200
+ *
201
+ * @param {{ id?: string, identity?: string }} entry an indexed record
202
+ * @returns {string|undefined} the record's id
203
+ */
204
+ export const recordId = (entry) => entry.identity ?? entry.id;
205
+
206
+ export const DIAGNOSTIC_CODES = Object.freeze([
207
+ ...ERROR_CODES,
208
+ 'parse-error',
209
+ 'read-error',
210
+ 'duplicate-id',
211
+ 'unresolved-ref',
212
+ 'skipped-file',
213
+ 'missing-store',
214
+ 'missing-catalog',
215
+ 'missing-rules',
216
+ 'registry-name-mismatch',
217
+ 'registry-store-mismatch',
218
+ 'duplicate-registry-value',
219
+ 'phoenix-name-mismatch',
220
+ 'duplicate-phoenix-row',
221
+ 'graduation-table-name-mismatch',
222
+ 'graduation-table-store-mismatch',
223
+ 'duplicate-graduation-category',
224
+ 'graduation-threshold-shape',
225
+ ]);
226
+
227
+ /**
228
+ * Freeze a ref-field table through every level it has: the table, each kind's
229
+ * row array, each row, and an array-form field path. `Object.freeze` is
230
+ * shallow, so freezing only the outer object would leave every row writable —
231
+ * and a mutated row is a silently rewritten cross-reference graph.
232
+ *
233
+ * @template {Record<string, Array<{ field: string|string[], space: string }>>} T
234
+ * @param {T} table the declaration table
235
+ * @returns {Readonly<T>} the same table, frozen all the way down
236
+ */
237
+ function deepFreezeTable(table) {
238
+ for (const rows of Object.values(table)) {
239
+ for (const row of rows) {
240
+ if (Array.isArray(row.field)) Object.freeze(row.field);
241
+ Object.freeze(row);
242
+ }
243
+ Object.freeze(rows);
244
+ }
245
+ return Object.freeze(table);
246
+ }
247
+
248
+ /**
249
+ * Typed cross-references per store record shape (§3.1–3.3): field path → id space.
250
+ *
251
+ * This table IS the cross-reference graph. Every typed edge the engine knows
252
+ * about is a row here, and `collectRefs`/`resolveRefs` are generic over it —
253
+ * so a new edge is a new declaration, never a bespoke check bolted onto the
254
+ * walker. That property is what the rest of the frontmatter-v2 work leans on:
255
+ * `relates.depends-on`, `relates.contradicts` and friends arrive as rows.
256
+ *
257
+ * A `field` is a path of object keys ending at an ARRAY of id strings, spelled
258
+ * either as a dotted string ('relates.depends-on') or as an array of segments
259
+ * (['relates', 'depends-on']). Depth is arbitrary — one level, two, or the
260
+ * three that v2's nested `relates` map needs — because the walker descends the
261
+ * segments rather than destructuring a fixed `[head, tail]` pair, which is all
262
+ * it used to handle. Segments containing a literal dot must use the array form.
263
+ *
264
+ * The declared path doubles as the edge's `type` (a published field on
265
+ * `model.refs`, quoted in the unresolved-ref message), so it always reads as
266
+ * the dotted path an author would find in their own file.
267
+ *
268
+ * A row may set `scalar: true` for a field holding ONE id rather than a list
269
+ * (UCS-1155's `graduation.revokes`). It is declared rather than inferred from
270
+ * the value's runtime type: sniffing would silently accept `supersedes: D-001`
271
+ * — a field whose contract is a list — and index it as a working edge, turning
272
+ * a shape error into a reference that appears to resolve.
273
+ *
274
+ * Frozen all the way down (rows, and any array-form path): the table is a
275
+ * declaration every surface reads, so a consumer that could mutate a row would
276
+ * be rewriting the cross-reference graph out from under the loader.
277
+ *
278
+ * @type {Readonly<Record<string, ReadonlyArray<{ field: string|string[], space: string, scalar?: boolean }>>>}
279
+ */
280
+ export const REF_FIELDS = deepFreezeTable({
281
+ 'ontology-concept': [
282
+ { field: 'used-by', space: 'concepts' },
283
+ { field: 'confusable-with', space: 'concepts' },
284
+ { field: 'rationale', space: 'decisions' },
285
+ ],
286
+ 'knowledge-leaf': [
287
+ { field: 'cross-references.class-elsewhere', space: 'leaves' },
288
+ { field: 'cross-references.see-also', space: 'leaves' },
289
+ // Typed edges (UCS-1151). `concepts` is the leaf→ontology edge, declared
290
+ // leaf-side because deciding what a leaf is ABOUT is curatorial and the
291
+ // leaf is what sits under the human write gate; the loader derives the
292
+ // reverse direction at load (leavesByConcept). The four `relates` rows are
293
+ // the leaf→leaf edge kinds, each a distinct claim, each reaching the leaf
294
+ // space — which is what the prefactor's arbitrary-depth walker was built
295
+ // for. Note what is NOT here: `paths` names the working tree rather than an
296
+ // id space, so it cannot be a ref row (see checkLeafPaths in validate.js).
297
+ { field: 'concepts', space: 'concepts' },
298
+ { field: 'relates.depends-on', space: 'leaves' },
299
+ { field: 'relates.see-also', space: 'leaves' },
300
+ { field: 'relates.contradicts', space: 'leaves' },
301
+ { field: 'relates.supersedes', space: 'leaves' },
302
+ ],
303
+ 'decision-entry': [
304
+ { field: 'supersedes', space: 'decisions' },
305
+ { field: 'superseded-by', space: 'decisions' },
306
+ { field: 'relates-to.concepts', space: 'concepts' },
307
+ { field: 'relates-to.leaves', space: 'leaves' },
308
+ { field: 'relates-to.decisions', space: 'decisions' },
309
+ // A revocation names the graduation it withdraws (UCS-1155) — ONE id, so
310
+ // the row is `scalar`. Declared here rather than checked bespokely, so a
311
+ // revocation citing no real graduation is the same `unresolved-ref` error
312
+ // it would be anywhere else, and the withdrawal stays connected in the
313
+ // record to the thing it withdrew.
314
+ { field: 'graduation.revokes', space: 'decisions', scalar: true },
315
+ ],
316
+ });
317
+
318
+ /**
319
+ * The leaf→leaf edge KINDS, in the order a neighborhood presents them
320
+ * (UCS-1151), and the field the map itself lives under.
321
+ *
322
+ * Read from REF_FIELDS rather than restated, so the kinds a resolver expands
323
+ * over and the kinds the ref graph resolves are the same list by construction.
324
+ * Declaring a fifth kind is one more row in the table above; nothing here, and
325
+ * nothing in the resolver, has to learn about it.
326
+ *
327
+ * Order is the DECLARATION order, deliberately not alphabetical: `depends-on`
328
+ * before `see-also` before `contradicts` before `supersedes` is a rough reading
329
+ * of how load-bearing each edge is, and the table is where that judgement is
330
+ * recorded. Output within each kind is sorted; the kinds themselves keep this
331
+ * order, so a neighborhood is stable without being arbitrary.
332
+ */
333
+ export const RELATES_FIELD = 'relates';
334
+ export const RELATES_KINDS = Object.freeze(
335
+ REF_FIELDS['knowledge-leaf']
336
+ .map(({ field }) => (Array.isArray(field) ? field : field.split('.')))
337
+ .filter((segments) => segments.length === 2 && segments[0] === RELATES_FIELD)
338
+ .map((segments) => segments[1]),
339
+ );
340
+
341
+ /**
342
+ * The leaf field holding repo-tree paths (UCS-1151) — named once, here.
343
+ *
344
+ * Two surfaces read it and they must never disagree about the spelling: the
345
+ * validator checks each path EXISTS, and the resolver joins reverse lookups
346
+ * over the same list. A rename that reached one and not the other would leave
347
+ * paths reverse-looked-up but unchecked, or checked but unreachable — either
348
+ * way a seam that silently half-works.
349
+ */
350
+ export const LEAF_PATHS_FIELD = 'paths';
351
+
352
+ /**
353
+ * The concepts one leaf declares (UCS-1151) — the single reader of the leaf's
354
+ * `concepts` spelling, for the same reason `leafStage` is for `facets.stage`.
355
+ *
356
+ * Non-strings are dropped rather than coerced: KK-02 already diagnoses the
357
+ * wrong type, and a coerced id would join a leaf to a concept nobody named.
358
+ *
359
+ * @param {object} record a leaf's front-matter record
360
+ * @returns {string[]} the declared concept ids, in authored order
361
+ */
362
+ export function leafConcepts(record) {
363
+ const declared = record?.concepts;
364
+ return Array.isArray(declared) ? declared.filter((id) => typeof id === 'string') : [];
365
+ }
366
+
367
+ /** The id spaces a ref row may target, and the store each one is declared in. */
368
+ const SPACE_TO_STORE = Object.freeze({
369
+ concepts: 'ontology',
370
+ leaves: 'knowledge',
371
+ decisions: 'decisions',
372
+ });
373
+
374
+ /**
375
+ * The subdirectory a store keeps its governed vocabulary REGISTRIES in
376
+ * (UCS-1148), and the extension those files carry.
377
+ *
378
+ * Underscore-prefixed like `_catalog.yaml` and `_rules.yaml`, and for the same
379
+ * reason: it is governed store META, not a record. `listFiles` already skips
380
+ * every `_`-prefixed entry, so the record walks cannot see registries and a
381
+ * registry can never be mistaken for a leaf — the naming grammar does the
382
+ * separating, with no exception list to keep in sync.
383
+ */
384
+ /**
385
+ * The derived layer's directory name (UCS-1158) — declared HERE, where the
386
+ * loader that must ignore it lives.
387
+ *
388
+ * The derived layer is engine output: plural browse trees, synthesized call
389
+ * numbers, a resolution index. It is regenerable and disposable, and the
390
+ * property that makes those words true is that nothing reads it back. The
391
+ * loader is the surface that would break that first — browse trees are markdown
392
+ * files under `knowledge/`, exactly like leaves — so the name lives beside the
393
+ * walk that skips it rather than in lib/derived.js, which would make the loader
394
+ * import the layer it is supposed to be independent of.
395
+ *
396
+ * lib/derived.js re-exports this so the generating side and the ignoring side
397
+ * name one string.
398
+ */
399
+ export const DERIVED_DIR = 'derived';
400
+
401
+ export const REGISTRY_DIR = '_registries';
402
+ const REGISTRY_EXTENSION = '.yaml';
403
+
404
+ /**
405
+ * Where a store keeps its phoenix event mappings (UCS-1154).
406
+ *
407
+ * `_`-prefixed for the same reason `_registries` is: the record walks skip
408
+ * every underscore entry, so an event mapping can never be mistaken for a
409
+ * leaf. The mappings are RETAINED in the store after the event applies,
410
+ * because they are what lets the structural validator confirm — from the
411
+ * working tree alone, with no git history — that every non-1 edition was
412
+ * sanctioned by a phoenix event rather than typed by hand.
413
+ */
414
+ export const PHOENIX_DIR = '_phoenix';
415
+ const PHOENIX_EXTENSION = '.yaml';
416
+
417
+ /**
418
+ * The trust graduation category table's file class (UCS-1155).
419
+ *
420
+ * It lives in `_registries/` rather than a directory of its own, and that is a
421
+ * deliberate reading of what it IS: a governed table declaring a closed
422
+ * vocabulary — the change categories autonomy may ever be scoped to — with a
423
+ * warrant and a Decisions entry per row, which is precisely a registry's shape.
424
+ * A second directory would have split one governance idea across two file
425
+ * classes for no gain a steward can see.
426
+ *
427
+ * It is a distinct KIND inside that directory because it answers a different
428
+ * question. A vocabulary registry says which values a facet may take; this
429
+ * table says how much INSPECTION a class of change gets, and carries an
430
+ * eligibility and a threshold no registry row has. Loading it as a registry
431
+ * would have meant either bending the registry schema to hold graduation
432
+ * fields, or letting the table pass unvalidated — so the file class is shared
433
+ * and the schema is not, keyed off the document's own `table` key.
434
+ *
435
+ * The table is filed under DECISIONS because graduation governs the change
436
+ * process rather than the knowledge: the truth anchor is the team (D-003).
437
+ */
438
+ export const GRADUATION_TABLE_KEY = 'table';
439
+ const GRADUATION_EXTENSION = '.yaml';
440
+
441
+ /**
442
+ * Per-store file-class descriptors (UCS-1148) — what a store IS, as data.
443
+ *
444
+ * The loader used to carry each store's shape in its control flow: which
445
+ * directory the records sit in, which extension they wear, whether the walk
446
+ * recurses, whether a `_rules.yaml` is expected, and a `store !== 'decisions'`
447
+ * ternary at the call site. Adding registries as a fourth bespoke code path
448
+ * would have been the fifth place a store's shape is spelled, so the shape
449
+ * moved here instead: a store is a row, a registry is a declared file class,
450
+ * and `loadStores` reads the table rather than knowing the stores.
451
+ *
452
+ * dir the store directory, root-relative (also the store's name)
453
+ * records how the record files are found and read, or null for a store
454
+ * whose records are loaded by a bespoke reader (knowledge leaves
455
+ * are front matter + body, which is a parser, not a descriptor)
456
+ * reader names that bespoke reader in BESPOKE_READERS, for a store with
457
+ * no `records` shape — so "how is this store read" stays a fact
458
+ * in the table rather than a branch in the load loop
459
+ * subdir where under the store the record files live
460
+ * kind the KK-02 record kind each file validates as
461
+ * space the id space the records index into
462
+ * extension the file extension the walk reads
463
+ * recursive whether the walk descends into subdirectories
464
+ * rules whether the store declares a `_rules.yaml` (decisions does
465
+ * not, by design — §9.1)
466
+ * registries whether the store may carry `_registries/*.yaml`
467
+ * phoenix whether the store may carry `_phoenix/*.yaml` — the retained
468
+ * event mappings a bulk re-taxonomy leaves behind (UCS-1154)
469
+ *
470
+ * Frozen: every surface reads this table, so a consumer able to mutate a row
471
+ * would be redefining a store's shape out from under the loader.
472
+ *
473
+ * @type {Readonly<Record<string, Readonly<object>>>}
474
+ */
475
+ export const STORE_DESCRIPTORS = Object.freeze({
476
+ ontology: Object.freeze({
477
+ dir: 'ontology',
478
+ records: Object.freeze({
479
+ subdir: 'classes', kind: 'ontology-concept', space: 'concepts',
480
+ extension: '.yaml', recursive: false,
481
+ }),
482
+ rules: true,
483
+ registries: true,
484
+ }),
485
+ knowledge: Object.freeze({
486
+ dir: 'knowledge',
487
+ // Leaves are YAML front matter plus a markdown body — a parse shape, not a
488
+ // walk shape, so no `records` descriptor can express them. `reader` names
489
+ // the bespoke loader instead, which keeps the branch in the TABLE rather
490
+ // than in the load loop: the loop asks each store how it is read and never
491
+ // learns that one store is special.
492
+ records: null,
493
+ reader: 'loadLeafFiles',
494
+ rules: true,
495
+ registries: true,
496
+ // Only knowledge carries phoenix events: an event re-taxonomizes LEAVES,
497
+ // and the leaves live here.
498
+ phoenix: true,
499
+ }),
500
+ decisions: Object.freeze({
501
+ dir: 'decisions',
502
+ records: Object.freeze({
503
+ subdir: 'entries', kind: 'decision-entry', space: 'decisions',
504
+ extension: '.yaml', recursive: false,
505
+ }),
506
+ rules: false, // §9.1: decisions has no _rules.yaml
507
+ registries: true,
508
+ }),
509
+ });
510
+
511
+ /** The store directories the loader walks, in load order. */
512
+ export const STORES = Object.freeze(Object.keys(STORE_DESCRIPTORS));
513
+
514
+ /**
515
+ * The stores that may carry phoenix events, derived from the descriptors
516
+ * (UCS-1154).
517
+ *
518
+ * DERIVED, never restated. The loader reads `descriptor.phoenix` to decide
519
+ * where to look for mappings; a consumer that hardcoded "knowledge" instead
520
+ * would keep working right up until a second store gained the flag, at which
521
+ * point that store's events would load into `model.phoenix` and be unreachable
522
+ * from the command line — present in the model, absent from every lookup. One
523
+ * table decides, and everything asks it.
524
+ */
525
+ export const PHOENIX_STORES = Object.freeze(
526
+ STORES.filter((store) => STORE_DESCRIPTORS[store].phoenix),
527
+ );
528
+
529
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
530
+
531
+ function sortedMap(map) {
532
+ return new Map([...map.entries()].sort((a, b) => compare(a[0], b[0])));
533
+ }
534
+
535
+ /** Parse one YAML document with scalar types intact (§3.5 coercion trap). */
536
+ function parseYaml(ctx, file, text) {
537
+ try {
538
+ return { doc: load(text, { filename: file }) };
539
+ } catch (error) {
540
+ const reason = error instanceof YAMLException ? error.reason ?? error.message : error.message;
541
+ ctx.diagnostics.push({
542
+ severity: 'error', code: 'parse-error', file, path: '',
543
+ message: `unparseable YAML: ${reason}`,
544
+ });
545
+ return null;
546
+ }
547
+ }
548
+
549
+ /** Validate a store file via KK-02 and map its errors onto the one scale. */
550
+ function validateInto(ctx, kind, file, doc) {
551
+ const { ok, errors } = validateStoreFile(kind, doc);
552
+ for (const { path, code, message } of errors) {
553
+ ctx.diagnostics.push({ severity: 'error', code, file, path, message });
554
+ }
555
+ return ok;
556
+ }
557
+
558
+ /**
559
+ * Index one record by id; the second mint of an id is a duplicate-id error.
560
+ *
561
+ * @returns {boolean} whether the record took the id. A caller with further
562
+ * indexing to do for the same record must check: a record that LOST a
563
+ * collision owns nothing, and anything else indexed in its name would be
564
+ * filed under the winner (see loadLeafFiles).
565
+ */
566
+ function indexRecord(ctx, space, id, file, path, entry) {
567
+ if (typeof id !== 'string') return false; // shape defects already diagnosed by KK-02
568
+ const existing = ctx[space].get(id);
569
+ if (existing) {
570
+ ctx.diagnostics.push({
571
+ severity: 'error', code: 'duplicate-id', file, path,
572
+ message: `id "${id}" is already minted in ${existing.file} — published ids are immutable; the later PR renumbers its own entry (§3.5)`,
573
+ });
574
+ return false;
575
+ }
576
+ ctx[space].set(id, entry);
577
+ return true;
578
+ }
579
+
580
+ /**
581
+ * The segments of a declared ref field path. Dotted strings are the ordinary
582
+ * spelling; the array form exists for a segment that contains a literal dot.
583
+ *
584
+ * @param {string|string[]} field as declared in REF_FIELDS
585
+ * @returns {string[]} the object keys to descend, outermost first
586
+ */
587
+ const fieldSegments = (field) => (Array.isArray(field) ? field : field.split('.'));
588
+
589
+ /**
590
+ * The author-facing spelling of a declared path — the edge's `type`, and the
591
+ * stem of the `path` a finding quotes.
592
+ *
593
+ * Deliberately a plain dotted join, with NO escaping. This string's whole job
594
+ * is to be findable: an author reading `cross-references.see-also[0]` searches
595
+ * their own file for exactly that. An escaped rendering (`v1\.2.refs[0]`)
596
+ * would match nothing they wrote, so escaping would trade a real, everyday
597
+ * cost against a collision that only two DECLARATIONS can create.
598
+ *
599
+ * That collision is instead refused at the source (assertDistinctPaths): two
600
+ * rows like ['a.b','c'] and ['a','b.c'] would render identically, and the fix
601
+ * is to reject the ambiguous TABLE, not to disfigure every finding message.
602
+ *
603
+ * @param {string[]} segments the declared segments
604
+ * @returns {string} the dotted path as an author would find it in their file
605
+ */
606
+ const pathLabel = (segments) => segments.join('.');
607
+
608
+ /**
609
+ * Refuse a ref-field table in which two rows render the same author-facing
610
+ * path — an engine failure at load, never a silent diagnostic.
611
+ *
612
+ * Only the array form can cause this (a segment carrying a literal dot), and
613
+ * only against another row of the same record kind. Two indistinguishable
614
+ * edges would make a finding ambiguous about which declaration it came from,
615
+ * so the table is refused rather than believed: shipping it would be a check
616
+ * whose output cannot be acted on (PRD §5).
617
+ *
618
+ * @param {Record<string, ReadonlyArray<{ field: string|string[] }>>} table
619
+ * @throws {Error} if any record kind declares two rows with the same rendering
620
+ */
621
+ export function assertDistinctPaths(table) {
622
+ for (const [kind, rows] of Object.entries(table)) {
623
+ const seen = new Map();
624
+ for (const { field } of rows) {
625
+ const label = pathLabel(fieldSegments(field));
626
+ const previous = seen.get(label);
627
+ if (previous !== undefined) {
628
+ throw new Error(
629
+ `ref-field table for "${kind}" declares two edges that render the same path "${label}" `
630
+ + `(${JSON.stringify(previous)} and ${JSON.stringify(field)}) — findings could not say which edge they came from; `
631
+ + 'spell one of them so the rendered paths differ',
632
+ );
633
+ }
634
+ seen.set(label, field);
635
+ }
636
+ }
637
+ }
638
+
639
+ // The shipped table is checked as this module loads: an ambiguous declaration
640
+ // is a defect in the kit itself, and must surface the moment it is introduced
641
+ // rather than as a confusing finding in somebody's repo.
642
+ assertDistinctPaths(REF_FIELDS);
643
+
644
+ /**
645
+ * Follow a declared field path into one record, at any depth.
646
+ *
647
+ * Returns the value at the end of the path, or undefined if any intermediate
648
+ * segment is missing or is not an object — a record that simply does not carry
649
+ * the edge is the common case, not a defect, and shape defects at the leaf are
650
+ * already diagnosed by KK-02. The caller decides what a non-array end means.
651
+ *
652
+ * @param {object} record the parsed record
653
+ * @param {string[]} segments object keys to descend, outermost first
654
+ * @returns {unknown} the value at the path's end, or undefined
655
+ */
656
+ function valueAtPath(record, segments) {
657
+ let node = record;
658
+ for (const segment of segments) {
659
+ if (!isObject(node)) return undefined;
660
+ node = node[segment];
661
+ }
662
+ return node;
663
+ }
664
+
665
+ /**
666
+ * The typed ref edges one record declares — the whole ref-graph walker.
667
+ *
668
+ * Generic over the DECLARATION at any depth: it descends the declared segments,
669
+ * so a nested map of typed arrays (frontmatter v2's `relates`) walks the same
670
+ * code path as a top-level array. Both the edge's `type` and its `path` are
671
+ * built from the declaration, which is why introducing an edge is adding a row
672
+ * to REF_FIELDS and nothing else — there is no per-edge branch to extend.
673
+ *
674
+ * Pure: it takes the rows rather than reaching for REF_FIELDS, and returns
675
+ * edges rather than pushing into the loader's context. That is what lets a
676
+ * test drive a synthetic record kind through the real walker without a fake
677
+ * edge being added to the shipped table (tests/load-stores.test.js).
678
+ *
679
+ * Non-string members are skipped: KK-02 already diagnoses the wrong type, and
680
+ * a second complaint from the ref graph would double-report one defect.
681
+ *
682
+ * @param {ReadonlyArray<{ field: string|string[], space: string }>} rows the
683
+ * declared edges for this record kind
684
+ * @param {object} record the parsed record to read edges out of
685
+ * @param {{ from: string, file: string, basePath?: string }} origin what the
686
+ * edges are attributed to — the record's id, its file, and the path prefix
687
+ * the record sits at within that file ('' for a one-record file)
688
+ * @returns {Array<{ from, type, to, file, path, space }>} edges in declaration
689
+ * order, then array order
690
+ */
691
+ export function refEdges(rows, record, { from, file, basePath = '' }) {
692
+ const edges = [];
693
+ for (const { field, space, scalar } of rows) {
694
+ const segments = fieldSegments(field);
695
+ const value = valueAtPath(record, segments);
696
+ const type = pathLabel(segments);
697
+ // A `scalar` row holds ONE id rather than a list. Declared on the row, not
698
+ // sniffed from the value: inferring "array means list, string means scalar"
699
+ // would silently accept `supersedes: D-001` — a field whose whole contract
700
+ // is a list — and index it as a resolvable edge, so a shape error would
701
+ // read as a working reference (KK-02 diagnoses the wrong type instead).
702
+ if (scalar) {
703
+ if (typeof value !== 'string') continue; // absent, or a wrong-type KK-02 diagnosed
704
+ edges.push({
705
+ from, type, to: value, file,
706
+ path: basePath ? `${basePath}.${type}` : type,
707
+ space,
708
+ });
709
+ continue;
710
+ }
711
+ if (!Array.isArray(value)) continue;
712
+ value.forEach((to, i) => {
713
+ if (typeof to !== 'string') return; // wrong-type already diagnosed
714
+ const path = basePath ? `${basePath}.${type}[${i}]` : `${type}[${i}]`;
715
+ edges.push({ from, type, to, file, path, space });
716
+ });
717
+ }
718
+ return edges;
719
+ }
720
+
721
+ /** Collect one record's declared edges into the loader's graph. */
722
+ function collectRefs(ctx, kind, from, file, basePath, record) {
723
+ ctx.refs.push(...refEdges(REF_FIELDS[kind], record, { from, file, basePath }));
724
+ }
725
+
726
+ /**
727
+ * Read one store file. Only a genuinely absent file returns null; any other
728
+ * failure (permissions, I/O) is an engine-visible read-error — an unread
729
+ * file must never masquerade as a missing or malformed one (PRD §5).
730
+ */
731
+ function readText(ctx, file) {
732
+ try {
733
+ return readFileSync(join(ctx.root, file), 'utf8');
734
+ } catch (error) {
735
+ if (error.code !== 'ENOENT') {
736
+ ctx.diagnostics.push({
737
+ severity: 'error', code: 'read-error', file, path: '',
738
+ message: `cannot read file: ${error.message}`,
739
+ });
740
+ }
741
+ return null;
742
+ }
743
+ }
744
+
745
+ /**
746
+ * One pipeline for every store meta file: read → parse → validate → assign.
747
+ *
748
+ * `kind` may be a function of the PARSED document rather than a fixed string,
749
+ * for a directory holding more than one kind (`_registries/` carries both the
750
+ * vocabulary registries and the trust graduation category table, UCS-1155).
751
+ * Dispatching inside the pipeline keeps one read and one parse: a caller that
752
+ * peeked at the file first to choose a kind would read every registry twice and
753
+ * could diagnose a parse error twice with it.
754
+ */
755
+ function loadMetaFile(ctx, file, kind, { onMissing, onParsed } = {}) {
756
+ const text = readText(ctx, file);
757
+ if (text === null) {
758
+ onMissing?.();
759
+ return null;
760
+ }
761
+ const parsed = parseYaml(ctx, file, text);
762
+ if (!parsed) return null;
763
+ const resolvedKind = typeof kind === 'function' ? kind(parsed.doc) : kind;
764
+ const valid = validateInto(ctx, resolvedKind, file, parsed.doc);
765
+ onParsed?.(parsed.doc, valid, resolvedKind);
766
+ return valid ? parsed.doc : null;
767
+ }
768
+
769
+ function loadCatalogAndRules(ctx, store, hasRules) {
770
+ const meta = ctx.stores[store];
771
+ meta.catalog = loadMetaFile(ctx, `${store}/_catalog.yaml`, 'catalog', {
772
+ onMissing: () => ctx.diagnostics.push({
773
+ severity: 'error', code: 'missing-catalog', file: `${store}/_catalog.yaml`, path: '',
774
+ message: `store "${store}" has no _catalog.yaml — the navigational entry point every store shares (PRD §3)`,
775
+ }),
776
+ // Harvest declared ids from every id-bearing row even when the catalog
777
+ // has schema defects elsewhere: one bad row must not turn every ref to a
778
+ // validly declared pending id into a spurious unresolved-ref cascade.
779
+ onParsed: (doc) => {
780
+ if (!isObject(doc) || !Array.isArray(doc.entries)) return;
781
+ for (const entry of doc.entries) {
782
+ if (isObject(entry) && typeof entry.id === 'string') ctx.declared[store].add(entry.id);
783
+ }
784
+ },
785
+ });
786
+ if (hasRules) {
787
+ meta.rules = loadMetaFile(ctx, `${store}/_rules.yaml`, 'rules', {
788
+ onMissing: () => ctx.diagnostics.push({
789
+ severity: 'warning', code: 'missing-rules', file: `${store}/_rules.yaml`, path: '',
790
+ message: `store "${store}" has no _rules.yaml (§9.1) — rules-dependent surfaces have no input`,
791
+ }),
792
+ });
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Load one store's governed vocabulary registries (UCS-1148).
798
+ *
799
+ * A registry is a DECLARED FILE CLASS, reached through the store descriptor,
800
+ * not a fourth bespoke reader: the same read → parse → validate pipeline every
801
+ * store meta file rides, pointed at `<store>/_registries/*.yaml`.
802
+ *
803
+ * Registry ABSENCE is deliberately not diagnosed here, and that is the ticket's
804
+ * central conduct choice. A store with no registries is the whole installed
805
+ * base (and every fixture written before this ticket), so demanding registries
806
+ * unconditionally would fail every existing store for a governance layer it
807
+ * never opted into. Absence surfaces where it can actually mean something
808
+ * instead: at the point a record CITES a governed facet. A leaf naming
809
+ * `facets.domain` in a store with no domains registry is an explicit
810
+ * `missing-registry` finding naming both the value and the registry it wanted —
811
+ * never a silent pass, and never a demand on a store that governs nothing.
812
+ *
813
+ * A MALFORMED registry, by contrast, is a hard error the moment it is read:
814
+ * unparseable YAML is `parse-error` and a schema defect carries its KK-02 code,
815
+ * both error-severity, both gating the validator to exit 2. A vocabulary the
816
+ * engine could not read is a check that never ran, so membership must never be
817
+ * judged against half a registry.
818
+ *
819
+ * @param {object} ctx the loader context
820
+ * @param {string} store the store whose registries to load
821
+ */
822
+ function loadRegistryFiles(ctx, store) {
823
+ const dir = `${store}/${REGISTRY_DIR}`;
824
+ for (const file of listFiles(ctx, dir, REGISTRY_EXTENSION, false, { skipUnderscore: false })) {
825
+ const name = file.slice(dir.length + 1, -REGISTRY_EXTENSION.length);
826
+ ctx.stores[store].files.push(file);
827
+ // The graduation category table shares this directory and is a different
828
+ // KIND (see GRADUATION_TABLE_KEY). Dispatch on the document's own `table:`
829
+ // key rather than on the filename, so the table is recognized by what it
830
+ // says it is: keying off a reserved basename would mean a steward who
831
+ // renamed the file got it silently validated as a registry, and every
832
+ // graduation row would then read as an unknown-property defect pointing at
833
+ // the wrong schema entirely.
834
+ let isTable = false;
835
+ const doc = loadMetaFile(ctx, file, (parsed) => {
836
+ isTable = isObject(parsed) && typeof parsed[GRADUATION_TABLE_KEY] === 'string';
837
+ return isTable ? 'graduation-categories' : 'registry';
838
+ });
839
+ if (doc === null) continue; // parse-error or schema defect already diagnosed
840
+ if (isTable) {
841
+ loadGraduationTable(ctx, store, file, name, doc);
842
+ continue;
843
+ }
844
+ // A registry whose declared name disagrees with its filename would make
845
+ // every membership finding cite a file that does not answer to the name it
846
+ // quotes. Refusing here keeps "the registry a finding names" and "the file
847
+ // a steward opens" the same thing.
848
+ if (doc.registry !== name) {
849
+ ctx.diagnostics.push({
850
+ severity: 'error', code: 'registry-name-mismatch', file, path: 'registry',
851
+ message: `registry declares name "${doc.registry}" but lives at ${file} — a finding that names a registry must name the file a steward opens`,
852
+ });
853
+ continue;
854
+ }
855
+ // The same argument one level up: a registry filed under the wrong store
856
+ // governs facets in a store it does not sit in. Registries are keyed
857
+ // "<store>/<name>", so believing the declaration would index this file
858
+ // under a key its own path contradicts — and a steward following the key
859
+ // would open a different store's directory.
860
+ if (doc.store !== store) {
861
+ ctx.diagnostics.push({
862
+ severity: 'error', code: 'registry-store-mismatch', file, path: 'store',
863
+ message: `registry declares store "${doc.store}" but lives under ${store}/ — a registry governs the store it sits in, and the two spellings must agree`,
864
+ });
865
+ continue;
866
+ }
867
+ const registry = {
868
+ name,
869
+ store,
870
+ file,
871
+ hierarchical: doc.hierarchical === true,
872
+ minted: new Set(),
873
+ suppressed: new Set(),
874
+ };
875
+ // A value is declared ONCE. Two rows claiming one value is a defect
876
+ // whichever statuses they carry, and the two shapes fail differently:
877
+ //
878
+ // same status twice — a redundant row. Harmless to the sets, but one of
879
+ // the two warrants is the live one and a reader
880
+ // cannot tell which, so the vocabulary's own record
881
+ // of why a term exists has become ambiguous.
882
+ // minted AND suppressed — the value lands in both sets, and `judgeValue`
883
+ // tests suppression first, so a MINTED value silently
884
+ // reads as refused. The registry contradicts itself
885
+ // and the engine resolves it by evaluation order,
886
+ // which is not a governance decision anyone made.
887
+ //
888
+ // Both are refused rather than reconciled: "minted or suppressed" is the
889
+ // one question a registry exists to answer, and a file that answers it
890
+ // twice must be fixed by a steward, never guessed at here.
891
+ const declared = new Map(); // value -> the status its first row carried
892
+ for (const [i, entry] of doc.values.entries()) {
893
+ if (!isObject(entry) || typeof entry.value !== 'string') continue; // KK-02 diagnosed the shape
894
+ const status = entry.status === 'suppressed' ? 'suppressed' : 'minted';
895
+ const first = declared.get(entry.value);
896
+ if (first !== undefined) {
897
+ ctx.diagnostics.push({
898
+ severity: 'error', code: 'duplicate-registry-value', file, path: `values[${i}].value`,
899
+ message: first === status
900
+ ? `value "${entry.value}" is declared twice, both times as ${status} — a value is declared once, and a duplicate row leaves two warrants with no way to tell which one governs`
901
+ : `value "${entry.value}" is declared as both ${first} and ${status} — a registry cannot mint and refuse the same value, and resolving the contradiction by file order would be a governance decision nobody made`,
902
+ });
903
+ continue;
904
+ }
905
+ declared.set(entry.value, status);
906
+ // "Each minting a Decisions entry" (UCS-1148) enforced rather than
907
+ // merely documented: the citation rides the ordinary ref graph, so an
908
+ // id naming no decision is the same `unresolved-ref` error it would be
909
+ // anywhere else, and the registry's governance is checked by the same
910
+ // machinery as every other cross-store citation.
911
+ if (typeof entry.decision === 'string') {
912
+ ctx.refs.push({
913
+ // Store-qualified, matching the `<store>/<name>` registry key: `refs`
914
+ // is a published, `from`-sorted model field, so two identically named
915
+ // registries in different stores must not share an edge origin.
916
+ from: `${store}/${name}/${entry.value}`,
917
+ type: 'registry.decision',
918
+ to: entry.decision,
919
+ file,
920
+ path: `values[${i}].decision`,
921
+ space: 'decisions',
922
+ });
923
+ }
924
+ // Absent status means minted; only an explicit suppression withholds a
925
+ // value. Both sets are kept because they answer different questions: a
926
+ // suppressed value is not usable, but it IS accounted for, and a finding
927
+ // that can say so tells an author "this was refused" rather than the far
928
+ // less useful "this does not exist".
929
+ registry[status].add(entry.value);
930
+ }
931
+ ctx.registries.set(`${store}/${name}`, registry);
932
+ }
933
+ }
934
+
935
+ /**
936
+ * Index one trust graduation category table (UCS-1155).
937
+ *
938
+ * The table declares which change CATEGORIES may graduate from full inspection
939
+ * to sampling and which are permanently gated. Autonomy is per category and
940
+ * never per leaf, so the table is the only thing that can answer "may this
941
+ * class of change graduate at all" — and a graduation Decisions entry naming a
942
+ * category absent from it is a validator finding rather than a silent pass.
943
+ *
944
+ * Three defects are refused HERE rather than downstream, because each one makes
945
+ * the table unable to answer the question it exists for:
946
+ *
947
+ * graduation-table-name-mismatch
948
+ * the declared `table` disagrees with the filename, so a finding
949
+ * naming the table would cite a file no steward can open under
950
+ * that name — the same rule registries and phoenix events keep.
951
+ * graduation-table-store-mismatch
952
+ * the declared `store` disagrees with the directory the file sits
953
+ * in. Every store loads `_registries/`, so a table can physically
954
+ * land under knowledge/ while declaring `decisions` and pass its
955
+ * schema; the key it indexes under would then contradict its own
956
+ * path.
957
+ * duplicate-graduation-category
958
+ * one category declared twice. Which row governs would then be
959
+ * decided by file order: two rows can carry different
960
+ * eligibilities, so a category could be both graduation-eligible
961
+ * and permanently gated, and the engine would pick one by
962
+ * accident. That is a governance decision nobody made.
963
+ * graduation-threshold-shape
964
+ * an `eligible` row with no threshold, or a `gated` row carrying
965
+ * one. The schema cannot state this — the engine's keyword subset
966
+ * has no conditional — and a rule stated in a keyword nothing
967
+ * enforces is contract drift wearing the appearance of a check.
968
+ * An eligible category with no threshold is eligible against no
969
+ * bar at all; a gated one with a threshold advertises a bar that
970
+ * can never be met, which reads as an oversight either way.
971
+ *
972
+ * Each row's `decision` rides the ordinary ref graph exactly as a registry
973
+ * minting's does, so a row citing no real decision is the same `unresolved-ref`
974
+ * error it would be anywhere else: autonomy nobody signed is the ungoverned
975
+ * drift this whole mechanism exists to prevent.
976
+ *
977
+ * @param {object} ctx the in-flight load context
978
+ * @param {string} store the store directory carrying the table
979
+ * @param {string} file the table's root-relative path
980
+ * @param {string} name the table's basename
981
+ * @param {object} doc the parsed, schema-valid table
982
+ */
983
+ function loadGraduationTable(ctx, store, file, name, doc) {
984
+ if (doc[GRADUATION_TABLE_KEY] !== name) {
985
+ ctx.diagnostics.push({
986
+ severity: 'error', code: 'graduation-table-name-mismatch', file, path: GRADUATION_TABLE_KEY,
987
+ message: `graduation table declares name "${doc[GRADUATION_TABLE_KEY]}" but lives at ${file} — a finding that names a table must name the file a steward opens`,
988
+ });
989
+ return;
990
+ }
991
+ // The same argument one level up, and the same check a registry already
992
+ // keeps: a table filed under the wrong store governs a trust boundary in a
993
+ // store it does not sit in. Tables are keyed "<store>/<name>", so believing
994
+ // the declaration would index this file under a key its own path
995
+ // contradicts, and a steward following the key would open a different
996
+ // store's directory. The schema requires the field; the loader is what makes
997
+ // it mean something.
998
+ if (doc.store !== store) {
999
+ ctx.diagnostics.push({
1000
+ severity: 'error', code: 'graduation-table-store-mismatch', file, path: 'store',
1001
+ message: `graduation table declares store "${doc.store}" but lives under ${store}/ — a table governs the store it sits in, and the two spellings must agree`,
1002
+ });
1003
+ return;
1004
+ }
1005
+ const categories = new Map();
1006
+ for (const [i, row] of doc.categories.entries()) {
1007
+ if (!isObject(row) || typeof row.category !== 'string') continue; // KK-02 diagnosed the shape
1008
+ if (categories.has(row.category)) {
1009
+ ctx.diagnostics.push({
1010
+ severity: 'error', code: 'duplicate-graduation-category', file, path: `categories[${i}].category`,
1011
+ message: `category "${row.category}" is declared twice — one row per category, or the table states two eligibilities for one class of change and the engine would pick by file order`,
1012
+ });
1013
+ continue;
1014
+ }
1015
+ // The conditional the schema subset cannot express, checked where it can
1016
+ // name the row an author must edit.
1017
+ const hasThreshold = Number.isInteger(row.threshold);
1018
+ if (row.eligibility === 'eligible' && !hasThreshold) {
1019
+ ctx.diagnostics.push({
1020
+ severity: 'error', code: 'graduation-threshold-shape', file, path: `categories[${i}].threshold`,
1021
+ message: `category "${row.category}" is graduation-eligible but declares no threshold — N is the bar a steward judges the recorded approved-unmodified count against, and an eligible category without one is eligible against nothing`,
1022
+ });
1023
+ continue;
1024
+ }
1025
+ if (row.eligibility === 'gated' && hasThreshold) {
1026
+ ctx.diagnostics.push({
1027
+ severity: 'error', code: 'graduation-threshold-shape', file, path: `categories[${i}].threshold`,
1028
+ message: `category "${row.category}" is permanently gated but declares a threshold of ${row.threshold} — a gated category can never graduate, so a bar that can never be met reads as an eligibility somebody forgot to set`,
1029
+ });
1030
+ continue;
1031
+ }
1032
+ categories.set(row.category, {
1033
+ category: row.category,
1034
+ eligibility: row.eligibility,
1035
+ threshold: hasThreshold ? row.threshold : null,
1036
+ warrant: row.warrant,
1037
+ decision: row.decision,
1038
+ file,
1039
+ index: i,
1040
+ });
1041
+ // The row's warrant rides the ordinary ref graph, exactly as a registry
1042
+ // minting's does.
1043
+ if (typeof row.decision === 'string') {
1044
+ ctx.refs.push({
1045
+ from: `${store}/${name}/${row.category}`,
1046
+ type: 'graduation.decision',
1047
+ to: row.decision,
1048
+ file,
1049
+ path: `categories[${i}].decision`,
1050
+ space: 'decisions',
1051
+ });
1052
+ }
1053
+ }
1054
+ ctx.graduations.set(`${store}/${name}`, { table: name, store, file, categories });
1055
+ }
1056
+
1057
+ /**
1058
+ * Load a store's retained phoenix event mappings (UCS-1154).
1059
+ *
1060
+ * Shaped after `loadRegistryFiles`, and for the same reason: the file's
1061
+ * declared id must agree with its filename, or a finding that names an event
1062
+ * would cite a file a steward cannot open under that name.
1063
+ *
1064
+ * The mapping is indexed but never APPLIED here. Applying it is the phoenix
1065
+ * command's job, and it happens once; what the loader publishes is the record
1066
+ * of an event that already happened, so the validator can hold every non-1
1067
+ * edition against it.
1068
+ *
1069
+ * @param {object} ctx the in-flight load context
1070
+ * @param {string} store the store directory carrying `_phoenix/`
1071
+ */
1072
+ function loadPhoenixFiles(ctx, store) {
1073
+ const dir = `${store}/${PHOENIX_DIR}`;
1074
+ for (const file of listFiles(ctx, dir, PHOENIX_EXTENSION, false, { skipUnderscore: false })) {
1075
+ const name = file.slice(dir.length + 1, -PHOENIX_EXTENSION.length);
1076
+ ctx.stores[store].files.push(file);
1077
+ const doc = loadMetaFile(ctx, file, 'phoenix-event');
1078
+ if (doc === null) continue; // parse-error or schema defect already diagnosed
1079
+ if (doc.event !== name) {
1080
+ ctx.diagnostics.push({
1081
+ severity: 'error', code: 'phoenix-name-mismatch', file, path: 'event',
1082
+ message: `phoenix event declares id "${doc.event}" but lives at ${file} — a finding that names an event must name the file a steward opens`,
1083
+ });
1084
+ continue;
1085
+ }
1086
+ // The event's warrant rides the ordinary ref graph, exactly as a registry
1087
+ // minting's does: a decision id naming no decision is the same
1088
+ // `unresolved-ref` error it would be anywhere else. A bulk re-taxonomy
1089
+ // nobody signed is the ungoverned drift this mechanism exists to replace.
1090
+ ctx.refs.push({
1091
+ from: `${store}/${PHOENIX_DIR}/${doc.event}`,
1092
+ type: 'phoenix.decision',
1093
+ to: doc.decision,
1094
+ file,
1095
+ path: 'decision',
1096
+ space: 'decisions',
1097
+ });
1098
+ const rows = new Map();
1099
+ for (const [i, row] of doc.leaves.entries()) {
1100
+ if (!isObject(row) || typeof row.id !== 'string') continue; // KK-02 diagnosed the shape
1101
+ // One row per leaf. Two rows for one accession would make "what did this
1102
+ // event do to L-x" a question the file answers twice, and choosing by
1103
+ // file order would be a governance decision nobody made.
1104
+ if (rows.has(row.id)) {
1105
+ ctx.diagnostics.push({
1106
+ severity: 'error', code: 'duplicate-phoenix-row', file, path: `leaves[${i}].id`,
1107
+ message: `leaf "${row.id}" is mapped twice by event ${doc.event} — one row per leaf, or the event states two fates for one leaf and the engine would pick by file order`,
1108
+ });
1109
+ continue;
1110
+ }
1111
+ rows.set(row.id, { ...row, index: i });
1112
+ }
1113
+ ctx.phoenix.set(`${store}/${name}`, {
1114
+ event: doc.event, store, file, decision: doc.decision, scope: doc.scope, rows,
1115
+ });
1116
+ }
1117
+ }
1118
+
1119
+ function listFiles(ctx, dir, extension, recursive, { skipUnderscore = true, skipDirs = [] } = {}) {
1120
+ const out = [];
1121
+ const skipped = new Set(skipDirs);
1122
+ const walk = (rel) => {
1123
+ let entries;
1124
+ try {
1125
+ entries = readdirSync(join(ctx.root, rel), { withFileTypes: true });
1126
+ } catch {
1127
+ return;
1128
+ }
1129
+ for (const entry of entries) {
1130
+ // Underscore-prefixed names are governed store META (`_catalog.yaml`,
1131
+ // `_rules.yaml`, `_registries/`), never records — so a record walk skips
1132
+ // them, and the registry walk (which IS a meta walk, already pointed at
1133
+ // the `_registries` directory) does not.
1134
+ if (skipUnderscore && entry.name.startsWith('_')) continue;
1135
+ if (entry.name.startsWith('.')) continue;
1136
+ // A named directory the walk must not descend into, and must not warn
1137
+ // about either (UCS-1158). `derived/` holds engine OUTPUT in the same
1138
+ // extension the records use — browse trees are markdown, and so are
1139
+ // leaves — so neither the underscore rule nor the extension check can
1140
+ // tell them apart. Skipping by name is what keeps a generated artifact
1141
+ // from being loaded as a leaf, which would make the derived layer
1142
+ // load-bearing: the trees would enter the model, fail the leaf schema,
1143
+ // and a store's health would depend on a directory whose whole contract
1144
+ // is that deleting it loses nothing.
1145
+ if (entry.isDirectory() && skipped.has(entry.name)) continue;
1146
+ const relPath = `${rel}/${entry.name}`;
1147
+ if (entry.isDirectory()) {
1148
+ if (recursive) walk(relPath);
1149
+ } else if (entry.name.endsWith(extension)) {
1150
+ // Symlinked record files load like regular ones (isFile() is false
1151
+ // for symlinks; the readFileSync that follows resolves them).
1152
+ out.push(relPath);
1153
+ } else {
1154
+ // A file the loader will not read must never be a silent pass
1155
+ // (PRD §5): its ids simply wouldn't exist, with nothing recorded.
1156
+ ctx.diagnostics.push({
1157
+ severity: 'warning', code: 'skipped-file', file: relPath, path: '',
1158
+ message: `not a ${extension} file — the loader only reads ${extension} records here; rename it or move it out of the store`,
1159
+ });
1160
+ }
1161
+ }
1162
+ };
1163
+ walk(dir);
1164
+ return out.sort(compare);
1165
+ }
1166
+
1167
+ /**
1168
+ * Ontology class files / decision entries files: the storeFile envelope.
1169
+ * Driven by the store's `records` descriptor, so the two stores that share
1170
+ * this shape share it as data rather than as two call sites spelling it out.
1171
+ */
1172
+ function loadEntriesFiles(ctx, store, { subdir, kind, space, extension, recursive }) {
1173
+ for (const file of listFiles(ctx, `${store}/${subdir}`, extension, recursive)) {
1174
+ ctx.stores[store].files.push(file);
1175
+ const text = readText(ctx, file);
1176
+ if (text === null) continue; // read-error already diagnosed
1177
+ const parsed = parseYaml(ctx, file, text);
1178
+ if (!parsed) continue;
1179
+ validateInto(ctx, kind, file, parsed.doc);
1180
+ if (!isObject(parsed.doc) || !Array.isArray(parsed.doc.entries)) continue;
1181
+ parsed.doc.entries.forEach((record, i) => {
1182
+ if (!isObject(record)) return;
1183
+ const basePath = `entries[${i}]`;
1184
+ indexRecord(ctx, space, record.id, file, `${basePath}.id`, { id: record.id, file, record });
1185
+ if (typeof record.id === 'string') collectRefs(ctx, kind, record.id, file, basePath, record);
1186
+ });
1187
+ }
1188
+ }
1189
+
1190
+ /** Knowledge leaves: YAML front matter + markdown body, one leaf per file. */
1191
+ function loadLeafFiles(ctx) {
1192
+ for (const file of listFiles(ctx, 'knowledge', '.md', true, { skipDirs: [DERIVED_DIR] })) {
1193
+ ctx.stores.knowledge.files.push(file);
1194
+ const raw = readText(ctx, file);
1195
+ if (raw === null) continue; // read-error already diagnosed
1196
+ // Editors and autocrlf produce BOMs and CRLF; both are well-formed input.
1197
+ const text = raw.replace(/^/, '').replace(/\r\n/g, '\n');
1198
+ const match = /^---\n([^]*?\n)?---(?:\n|$)([^]*)$/.exec(text);
1199
+ if (!match) {
1200
+ ctx.diagnostics.push({
1201
+ severity: 'error', code: 'parse-error', file, path: '',
1202
+ message: 'knowledge leaf must open with YAML front matter fenced by "---" lines (§3.2)',
1203
+ });
1204
+ continue;
1205
+ }
1206
+ const parsed = parseYaml(ctx, file, match[1] ?? '');
1207
+ if (!parsed) continue;
1208
+ validateInto(ctx, 'knowledge-leaf', file, parsed.doc);
1209
+ const record = parsed.doc;
1210
+ if (!isObject(record)) continue;
1211
+ // The leaf's identity is read through leafIdentity(), never by naming a
1212
+ // field here: that function is the whole seam an id-space change moves
1213
+ // through (UCS-1142, UCS-1144, UCS-1147). `identity` is the neutral key
1214
+ // consumers read; `notation` stays alongside it because it is a PUBLIC
1215
+ // resolver field — the optional legacy display label — and `id` because it
1216
+ // is what the resolver publishes as the leaf's identity.
1217
+ const identity = leafIdentity(record);
1218
+ const entry = {
1219
+ identity,
1220
+ [LEAF_ACCESSION_FIELD]: record[LEAF_ACCESSION_FIELD] ?? null,
1221
+ [LEAF_ID_FIELD]: record[LEAF_ID_FIELD],
1222
+ file,
1223
+ record,
1224
+ body: match[2],
1225
+ };
1226
+ // `leaves` is keyed by the accession alone — one entry per leaf, under the
1227
+ // one name anything may cite it by. A leaf that mints no accession has no
1228
+ // identity to be keyed under, so indexRecord declines it (a non-string id)
1229
+ // and the schema's missing-required on `id` is the finding the author acts
1230
+ // on. Indexing it under its notation instead would put a leaf in every
1231
+ // enumeration that no citation could reach.
1232
+ //
1233
+ // Everything downstream is indexed under this leaf's identity, so it all
1234
+ // hangs on the leaf actually TAKING that identity. A leaf that lost the id
1235
+ // to an earlier file owns nothing, and its cross-references would otherwise
1236
+ // enter the graph as edges the WINNER never declared — worse than the leaf
1237
+ // simply not being there, which is what a losing mint means.
1238
+ if (indexRecord(ctx, 'leaves', identity, file, LEAF_ACCESSION_FIELD, entry)) {
1239
+ collectRefs(ctx, 'knowledge-leaf', identity, file, '', record);
1240
+ }
1241
+ }
1242
+ }
1243
+
1244
+ /**
1245
+ * The bespoke record readers a descriptor may name, by name (UCS-1148).
1246
+ *
1247
+ * A store whose records are not a walk-and-parse shape names its reader in the
1248
+ * descriptor rather than being special-cased in the load loop. The indirection
1249
+ * through a name exists because the descriptor table is declared above these
1250
+ * functions: holding the function itself would be a forward reference into a
1251
+ * frozen const, and reordering the module to avoid it would put the store shape
1252
+ * table below the machinery that reads it.
1253
+ *
1254
+ * @type {Readonly<Record<string, (ctx: object, store: string) => void>>}
1255
+ */
1256
+ const BESPOKE_READERS = Object.freeze({
1257
+ loadLeafFiles: (ctx) => loadLeafFiles(ctx),
1258
+ });
1259
+
1260
+ /**
1261
+ * Refuse a descriptor naming a reader that does not exist — an engine failure
1262
+ * at load, never a silent pass. A store whose reader never resolves would load
1263
+ * ZERO records and report nothing, which reads exactly like an empty store.
1264
+ *
1265
+ * @param {Record<string, { reader?: string }>} descriptors
1266
+ * @throws {Error} if a named reader is not in BESPOKE_READERS
1267
+ */
1268
+ export function assertReadersResolve(descriptors) {
1269
+ for (const [store, descriptor] of Object.entries(descriptors)) {
1270
+ if (descriptor.reader && !BESPOKE_READERS[descriptor.reader]) {
1271
+ throw new Error(
1272
+ `store "${store}" names reader "${descriptor.reader}", which does not exist — `
1273
+ + 'its records would silently fail to load and the store would read as empty',
1274
+ );
1275
+ }
1276
+ }
1277
+ }
1278
+
1279
+ assertReadersResolve(STORE_DESCRIPTORS);
1280
+
1281
+ /** Pointer index: source-of-truth path → concept ids (KK-06 reverse lookup). */
1282
+ function buildPointers(ctx) {
1283
+ const pointers = new Map();
1284
+ for (const { id, record } of ctx.concepts.values()) {
1285
+ if (!Array.isArray(record['source-of-truth'])) continue;
1286
+ for (const path of record['source-of-truth']) {
1287
+ if (typeof path !== 'string') continue;
1288
+ if (!pointers.has(path)) pointers.set(path, []);
1289
+ const ids = pointers.get(path);
1290
+ if (!ids.includes(id)) ids.push(id);
1291
+ }
1292
+ }
1293
+ for (const ids of pointers.values()) ids.sort(compare);
1294
+ return sortedMap(pointers);
1295
+ }
1296
+
1297
+ /**
1298
+ * The leaf↔concept edge, derived in BOTH directions at load (UCS-1151).
1299
+ *
1300
+ * The edge is authored once, leaf-side, because deciding what a leaf is about
1301
+ * is curatorial work under the human write gate. But it has to be traversable
1302
+ * from either end: an agent that resolves a concept needs the leaves that claim
1303
+ * it, and an agent holding a leaf needs the concepts it answers to. Deriving
1304
+ * the reverse here is what makes the concept→leaves direction STRUCTURAL — the
1305
+ * join is over declared ids, so it holds whether or not the leaf's `terms`
1306
+ * happen to spell the concept's term or one of its aliases. Text matching was
1307
+ * the only join before this ticket, which meant a leaf reached its concept by
1308
+ * term LUCK: rename the concept, or write the leaf with a different vocabulary,
1309
+ * and the two silently stopped seeing each other at exit 0.
1310
+ *
1311
+ * Keyed by concept id, valued by leaf IDENTITY (never the entry) so the index
1312
+ * cannot become a second copy of a leaf that the leaves map disagrees with.
1313
+ * Every list is de-duplicated and sorted, and the map itself is sorted, because
1314
+ * this is a published model field that resolver output is built from.
1315
+ *
1316
+ * Unresolvable concept ids are left in: this index says what the leaf CLAIMS,
1317
+ * and the ref graph is what judges whether the claim resolves. Filtering here
1318
+ * would silently drop the very edge the unresolved-ref finding is about.
1319
+ *
1320
+ * @param {object} ctx the loader context
1321
+ * @returns {Map<string, string[]>} concept id -> declaring leaf identities
1322
+ */
1323
+ function buildLeavesByConcept(ctx) {
1324
+ const index = new Map();
1325
+ for (const entry of ctx.leaves.values()) {
1326
+ for (const id of leafConcepts(entry.record)) {
1327
+ if (!index.has(id)) index.set(id, []);
1328
+ const identities = index.get(id);
1329
+ if (!identities.includes(entry.identity)) identities.push(entry.identity);
1330
+ }
1331
+ }
1332
+ for (const identities of index.values()) identities.sort(compare);
1333
+ return sortedMap(index);
1334
+ }
1335
+
1336
+ /** Resolve every collected edge; a miss is an unresolved-ref error. */
1337
+ function resolveRefs(ctx) {
1338
+ for (const ref of ctx.refs) {
1339
+ const store = SPACE_TO_STORE[ref.space];
1340
+ // One index, one spelling. The alternate-spelling lookup the expand phase
1341
+ // added (UCS-1144) left with the alias table itself: a leaf answers to its
1342
+ // accession, and a notation-form citation resolves to nothing (UCS-1147).
1343
+ ref.resolved = ctx[ref.space].has(ref.to) || ctx.declared[store].has(ref.to);
1344
+ if (!ref.resolved) {
1345
+ ctx.diagnostics.push({
1346
+ severity: 'error', code: 'unresolved-ref', file: ref.file, path: ref.path,
1347
+ message: `${ref.type} ref "${ref.to}" does not resolve to any ${store} entry or catalog-declared id`,
1348
+ });
1349
+ }
1350
+ delete ref.space;
1351
+ }
1352
+ ctx.refs.sort((a, b) =>
1353
+ compare(a.from, b.from) || compare(a.type, b.type) || compare(a.to, b.to) || compare(a.path, b.path));
1354
+ }
1355
+
1356
+ /**
1357
+ * Load the three stores under `root` into the indexed model described above.
1358
+ * @param {string} root kit root directory (the dir containing ontology/,
1359
+ * knowledge/, decisions/ — absent stores yield missing-store warnings)
1360
+ * @returns the model; `model.ok` is the single health verdict every
1361
+ * downstream surface shares
1362
+ */
1363
+ export function loadStores(root) {
1364
+ const absRoot = resolve(root);
1365
+ if (!statSync(absRoot, { throwIfNoEntry: false })?.isDirectory()) {
1366
+ throw new Error(`store root is not a readable directory: ${absRoot}`);
1367
+ }
1368
+ const ctx = {
1369
+ root: absRoot,
1370
+ stores: Object.fromEntries(STORES.map((store) =>
1371
+ [store, { present: false, catalog: null, rules: null, files: [] }])),
1372
+ declared: Object.fromEntries(STORES.map((store) => [store, new Set()])),
1373
+ concepts: new Map(),
1374
+ leaves: new Map(),
1375
+ decisions: new Map(),
1376
+ registries: new Map(),
1377
+ graduations: new Map(),
1378
+ phoenix: new Map(),
1379
+ refs: [],
1380
+ diagnostics: [],
1381
+ };
1382
+
1383
+ for (const store of STORES) {
1384
+ const descriptor = STORE_DESCRIPTORS[store];
1385
+ const meta = ctx.stores[store];
1386
+ meta.present = !!statSync(join(absRoot, store), { throwIfNoEntry: false })?.isDirectory();
1387
+ if (!meta.present) {
1388
+ ctx.diagnostics.push({
1389
+ severity: 'warning', code: 'missing-store', file: store, path: '',
1390
+ message: `store directory "${store}/" is absent — loading proceeds; refs into it cannot resolve`,
1391
+ });
1392
+ continue;
1393
+ }
1394
+ loadCatalogAndRules(ctx, store, descriptor.rules);
1395
+ if (descriptor.registries) loadRegistryFiles(ctx, store);
1396
+ if (descriptor.phoenix) loadPhoenixFiles(ctx, store);
1397
+ }
1398
+ // Records load after every registry, in every store: membership is judged
1399
+ // against the whole governed vocabulary, so no record may be read before the
1400
+ // vocabulary it draws from is complete.
1401
+ for (const store of STORES) {
1402
+ if (!ctx.stores[store].present) continue;
1403
+ const { records, reader } = STORE_DESCRIPTORS[store];
1404
+ if (records) loadEntriesFiles(ctx, store, records);
1405
+ else if (reader) BESPOKE_READERS[reader](ctx, store);
1406
+ }
1407
+
1408
+ const pointers = buildPointers(ctx);
1409
+ const leavesByConcept = buildLeavesByConcept(ctx);
1410
+ resolveRefs(ctx);
1411
+ ctx.diagnostics.sort((a, b) =>
1412
+ compare(a.file, b.file) || compare(a.path, b.path) || compare(a.code, b.code));
1413
+
1414
+ return {
1415
+ root: absRoot,
1416
+ stores: ctx.stores,
1417
+ concepts: sortedMap(ctx.concepts),
1418
+ leaves: sortedMap(ctx.leaves),
1419
+ decisions: sortedMap(ctx.decisions),
1420
+ registries: sortedMap(ctx.registries),
1421
+ graduations: sortedMap(ctx.graduations),
1422
+ phoenix: sortedMap(ctx.phoenix),
1423
+ pointers,
1424
+ leavesByConcept,
1425
+ refs: ctx.refs,
1426
+ diagnostics: ctx.diagnostics,
1427
+ ok: ctx.diagnostics.every((d) => d.severity !== 'error'),
1428
+ };
1429
+ }
1430
+
1431
+ /**
1432
+ * Loader diagnostics summarized on the one scale every downstream surface
1433
+ * shares — validator, value check, and preflight report store health from
1434
+ * this single shape, so they can never disagree about it (PRD §4).
1435
+ */
1436
+
1437
+
1438
+ /**
1439
+ * Store health — the ONE authority (UCS-939/945/951).
1440
+ *
1441
+ * The validator, the reverse audit and preflight can never disagree about a
1442
+ * Store, because they all ask this function rather than each filtering
1443
+ * `model.diagnostics` themselves. It once reported only counts, which is not
1444
+ * what any caller needed, so seven sites reached past it and the resolver
1445
+ * re-declared its own copy. A seam too narrow to be used is a seam that gets
1446
+ * walked around.
1447
+ *
1448
+ * Derived from `model.ok` and `model.diagnostics` — the single health model —
1449
+ * and from nothing else. `ok` is the loader's verdict, never recomputed from
1450
+ * the counts: a surface that recomputed it would BE a second health model.
1451
+ *
1452
+ * @param {object} model a loaded store model
1453
+ * @returns {{ ok: boolean, errors: object[], warnings: object[], errorCount: number, warningCount: number }}
1454
+ */
1455
+ export function storeHealth(model) {
1456
+ const errors = model.diagnostics.filter((d) => d.severity === 'error');
1457
+ const warnings = model.diagnostics.filter((d) => d.severity === 'warning');
1458
+ return {
1459
+ ok: model.ok,
1460
+ errors,
1461
+ warnings,
1462
+ errorCount: errors.length,
1463
+ warningCount: warnings.length,
1464
+ };
1465
+ }
1466
+
1467
+ /**
1468
+ * The wire shape of store health: counts, not Diagnostics. Every surface's
1469
+ * `store-health` JSON key is this, and it has looked like this since KK-04.
1470
+ *
1471
+ * It takes the HEALTH, never the model — so it is a projection of the one
1472
+ * authority and cannot become a second derivation of it. That is the whole
1473
+ * point: `healthSummary(storeHealth(model))` reads as what it is.
1474
+ *
1475
+ * @param {{ ok: boolean, errorCount: number, warningCount: number }} health
1476
+ * @returns {{ ok: boolean, errors: number, warnings: number }}
1477
+ */
1478
+ export function healthSummary(health) {
1479
+ return { ok: health.ok, errors: health.errorCount, warnings: health.warningCount };
1480
+ }
1481
+
1482
+ /**
1483
+ * A --concepts id the ontology does not carry — a check that never ran.
1484
+ * A usage error: the caller named something that does not exist, so the command
1485
+ * refused its arguments rather than failing mid-run. Exit 2, never 1.
1486
+ */
1487
+ export class UnknownConceptsError extends UsageError {
1488
+ name = 'UnknownConceptsError';
1489
+ }
1490
+
1491
+ /**
1492
+ * The `--leaves` counterpart (UCS-1149): an id the knowledge store does not
1493
+ * carry, under either of a leaf's two legal spellings. A usage error for the
1494
+ * same reason — the caller named something that does not exist, so the command
1495
+ * refused its arguments rather than reporting on nothing. Exit 2, never 1.
1496
+ */
1497
+ export class UnknownLeavesError extends UsageError {
1498
+ name = 'UnknownLeavesError';
1499
+ }
1500
+
1501
+ /**
1502
+ * The `--concepts` grammar, settled in one place (UCS-935).
1503
+ *
1504
+ * Ids are trimmed, empties dropped, duplicates collapsed, order stable. Every
1505
+ * surface taking the flag reads it the same way, because a filter that means
1506
+ * two different things is worse than no filter: the structural validator used
1507
+ * to accept `" K-101 "` while the value validator rejected the same argument
1508
+ * as a check that never ran, so a CI pipeline that padded its arguments got a
1509
+ * clean pass from one gate and a blocking defect from the other.
1510
+ *
1511
+ * Returns the normalized ids. Whether an EMPTY result is an error is the
1512
+ * caller's policy, not the grammar's: the validators refuse it (a filter that
1513
+ * names nothing never ran), while preflight reads it as store-health-only.
1514
+ *
1515
+ * @param {string[]} raw ids as spelled on the command line, pre-split on commas
1516
+ * @returns {string[]} trimmed, de-duplicated, stably sorted ids
1517
+ */
1518
+ export function normalizeConceptIds(raw) {
1519
+ return [...new Set(raw.map((s) => s.trim()).filter(Boolean))].sort(compare);
1520
+ }
1521
+
1522
+ /**
1523
+ * Select concepts by id (null/undefined = every concept). An unknown id
1524
+ * throws UnknownConceptsError: a verdict or finding set "filtered" to a typo
1525
+ * would be a check that never ran reading as a silent pass (PRD §5). Every
1526
+ * --concepts consumer shares this contract from here.
1527
+ */
1528
+ export function selectConcepts(model, ids) {
1529
+ if (!ids) return [...model.concepts.values()];
1530
+ const unknown = ids.filter((id) => !model.concepts.has(id));
1531
+ if (unknown.length) {
1532
+ throw new UnknownConceptsError(`--concepts names id(s) not in the ontology: ${unknown.join(', ')} — a check that never ran is a blocking defect, never a silent pass (PRD §5)`);
1533
+ }
1534
+ return ids.map((id) => model.concepts.get(id));
1535
+ }
1536
+
1537
+ /**
1538
+ * Select leaves by id (null/undefined = every leaf) — the `--leaves` analogue
1539
+ * of selectConcepts (UCS-1149).
1540
+ *
1541
+ * Same contract, for the same reason: an id the store does not carry throws
1542
+ * rather than filtering to nothing, because a verdict set "filtered" to a typo
1543
+ * is a check that never ran wearing a clean exit (PRD §5).
1544
+ *
1545
+ * Leaves are named by accession and nothing else (UCS-1147), the same spelling
1546
+ * a citation uses. The de-duplication below survives that narrowing: naming one
1547
+ * leaf twice is still one leaf, not two verdicts that would then disagree about
1548
+ * nothing.
1549
+ *
1550
+ * @param {object} model the loaded store model
1551
+ * @param {string[]|null} ids leaf ids as the caller spelled them
1552
+ * @returns {object[]} the indexed leaf entries, in the order named
1553
+ */
1554
+ export function selectLeaves(model, ids) {
1555
+ if (!ids) return [...model.leaves.values()];
1556
+ const unknown = ids.filter((id) => leafIdentityOf(model, id) === undefined);
1557
+ if (unknown.length) {
1558
+ throw new UnknownLeavesError(`--leaves names id(s) not in the knowledge store: ${unknown.join(', ')} — a check that never ran is a blocking defect, never a silent pass (PRD §5)`);
1559
+ }
1560
+ const seen = new Set();
1561
+ const out = [];
1562
+ for (const id of ids) {
1563
+ const identity = leafIdentityOf(model, id);
1564
+ if (seen.has(identity)) continue;
1565
+ seen.add(identity);
1566
+ out.push(model.leaves.get(identity));
1567
+ }
1568
+ return out;
1569
+ }
1570
+
1571
+ /**
1572
+ * The IDENTITY a leaf id names, or undefined when the store has no such leaf.
1573
+ *
1574
+ * Since UCS-1147 a leaf answers to exactly one spelling, so this no longer
1575
+ * TRANSLATES anything — it answers "is this a leaf of this store, and what is
1576
+ * its identity". It stays a named function, and exported, because that question
1577
+ * is still asked from two places that must answer it identically: preflight's
1578
+ * healthy path resolves ids through selectLeaves, and its store-wide-failure
1579
+ * path cannot (the verdicts are degraded without selecting anything). Inlining
1580
+ * `model.leaves.has(id)` at both would be the same code twice, which is how the
1581
+ * two drifted apart the first time.
1582
+ *
1583
+ * @param {object} model the loaded store model
1584
+ * @param {string} id a leaf id as the caller spelled it
1585
+ * @returns {string|undefined} the leaf's identity, or undefined if unknown
1586
+ */
1587
+ export function leafIdentityOf(model, id) {
1588
+ return model.leaves.has(id) ? id : undefined;
1589
+ }
1590
+
1591
+
1592
+ /**
1593
+ * §3.5: draft/proposed concepts get structural checks only — the value check
1594
+ * skips them and preflight verdicts them unknown. One predicate, so the two
1595
+ * surfaces can never diverge on which statuses that means.
1596
+ *
1597
+ * Frontmatter v2 puts LEAVES through this same predicate (UCS-1149). A leaf has
1598
+ * no `status`; it has `facets.stage`, and `leafStage` below is the one place
1599
+ * that spelling is read. The predicate itself is unchanged and untyped as to
1600
+ * what it is judging, which is what lets a leaf's `draft` and a concept's
1601
+ * `draft` mean the same thing to every surface that asks.
1602
+ */
1603
+ export const isPrePromotionStatus = (status) => status === 'draft' || status === 'proposed';
1604
+
1605
+ /**
1606
+ * A leaf's promotion stage, or null when it declares none (UCS-1149).
1607
+ *
1608
+ * The single reader of the `facets.stage` spelling. Two surfaces ask this
1609
+ * question — the resolver, to downrank a provisional leaf, and preflight, to
1610
+ * verdict one unknown — and if either reached into the record itself, a later
1611
+ * move of the field would leave one of them silently reading `undefined`:
1612
+ * a leaf that stopped being downranked, at exit 0, with nothing said.
1613
+ *
1614
+ * A stage that is not a string is null rather than the raw value: the schema
1615
+ * has already reported it, and passing a number into the predicate would just
1616
+ * return false, quietly promoting the leaf the defect was meant to hold back.
1617
+ *
1618
+ * @param {object} record a leaf's front-matter record
1619
+ * @returns {string|null}
1620
+ */
1621
+ export function leafStage(record) {
1622
+ const stage = record?.facets?.stage;
1623
+ return typeof stage === 'string' ? stage : null;
1624
+ }