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,530 @@
1
+ /**
2
+ * Preflight verdict module (KK-26) — the session gate (PRD §4, D-011). Joins
3
+ * the KK-04 loader's diagnostics and the KK-05/KK-07 validator results to the
4
+ * concepts a task resolved, emitting one deterministic verdict per concept
5
+ * plus a next action. JSON-first; the engine computes verdicts ONLY — conduct
6
+ * on a verdict (quarantine-and-continue, fail-stop, …) is protocol-layer
7
+ * policy the client owns (KK-20, D-011).
8
+ *
9
+ * node payload/engine/preflight.js [--concepts <ids>] [--leaves <ids>] [--json]
10
+ * [--root <dir>] [--today <YYYY-MM-DD>] [--log]
11
+ *
12
+ * `--leaves` is the LEAF-facing surface (UCS-1149), and it exists because
13
+ * frontmatter v2 gave leaves a promotion stage. A `stage: draft` leaf must
14
+ * yield an unknown-class verdict through the SAME `isPrePromotionStatus`
15
+ * predicate that downranks it in the resolver — otherwise the two surfaces
16
+ * could disagree about which leaves are provisional, which is exactly the
17
+ * divergence one shared predicate exists to prevent. Leaf verdicts land in
18
+ * `leaf-verdicts` and are counted with the concept ones, so a quarantined leaf
19
+ * gates the run; a `--concepts`-only invocation is byte-identical to before.
20
+ *
21
+ * Verdicts (derived, never cached — a stale "trusted" is a false all-clear,
22
+ * D-011; every run recomputes from the working tree):
23
+ * trusted every check attributable to the concept ran and ran clean
24
+ * quarantined the structural (KK-05) or value (KK-07) checks yield
25
+ * error-severity findings or hard errors attributable to the
26
+ * concept — do not rely on it until the evidence is fixed
27
+ * unknown the checks could not certify anything: draft/proposed status
28
+ * (§3.5: structural checks only, value checks skipped), a leaf
29
+ * under time governance whose freshness could not be computed
30
+ * (UCS-1150 — no `verified` date, or no injected --today), or a
31
+ * store-wide failure (loader error-severity diagnostics), which
32
+ * degrades ALL requested verdicts to unknown — no check ran
33
+ * stale LEAVES ONLY (UCS-1150): the leaf's age exceeds the pinned
34
+ * limit for its volatility class — 365 days for `stable`, 90 for
35
+ * `volatile`; `static` never stales. Its own class rather than a
36
+ * mapping onto the others, because nothing about a stale leaf is
37
+ * broken (quarantined) and its checks DID run and returned a
38
+ * definite answer (unknown). The action is re-verification
39
+ * against the cited sources, which neither of those would say.
40
+ *
41
+ * `--today <YYYY-MM-DD>` is what time verdicts are measured against; the engine
42
+ * never reads the wall clock (D-012). Without it, a time-governed leaf verdicts
43
+ * unknown rather than trusted, and the `time-check` line in the output says the
44
+ * check was skipped — a check that never ran is never a silent pass.
45
+ *
46
+ * Exit codes (PRD §5, lib/exit-codes.js): 0 = all trusted, 1 = quarantines or
47
+ * stale verdicts present, 2 = engine failure / check-never-ran. Any unknown
48
+ * verdict gates at 2 — a check that never ran is a blocking defect, never a
49
+ * silent pass; only an all-trusted run may read as clean. A stale verdict gates
50
+ * at 1 rather than 2, because the check ran: rotted knowledge is a finding to
51
+ * fix, not a broken engine. An id --concepts names that the ontology does not
52
+ * carry is exit 2 for the same reason a never-run check is: a verdict on a typo
53
+ * must never read as anything.
54
+ *
55
+ * Empty or omitted --concepts = store-health-only validation: the run exits
56
+ * on the store verdict alone (trusted when the loader's single health model
57
+ * is clean, unknown/exit 2 otherwise) and computes no per-concept checks.
58
+ *
59
+ * Quarantine finding trigger (KK-13, engine-attributed): with `--log`, every
60
+ * quarantined verdict appends one open finding fragment to logs/findings/ via
61
+ * the KK-13 helper (one file per entry, D-010). `--log` requires `--today`
62
+ * because the helper never reads the wall clock (PRD §5) — diffable output
63
+ * stays date-injected. Capture content policy (§3.4): the summary carries
64
+ * concept IDs, finding codes, and file paths ONLY — never verbatim user text.
65
+ *
66
+ * Reuses the exported check logic — runChecks (validate.js), validateValues
67
+ * (validate-values.js) — over one loadStores model: the single-health-model
68
+ * guarantee that preflight and the validators can never disagree. Output is
69
+ * deterministic and stable-sorted (verdicts by concept id), no wall-clock
70
+ * timestamps; only --log introduces fragment file names (random suffixes are
71
+ * the D-010 id space, and they live in `logged`, written on request only).
72
+ */
73
+ import process from 'node:process';
74
+ import { resolve } from 'node:path';
75
+ import { fileURLToPath } from 'node:url';
76
+ import { healthSummary, loadStores, isPrePromotionStatus, leafIdentityOf, leafStage, normalizeConceptIds, selectConcepts, selectLeaves, storeHealth, UnknownConceptsError, UnknownLeavesError } from '../lib/load-stores.js';
77
+ import { locateKitRoot } from '../lib/kit-root.js';
78
+ import { EXIT_CODES } from '../lib/exit-codes.js';
79
+ import { UsageError, parseArgs as parseFlags, rethrowIfBug } from '../lib/cli.js';
80
+ import { compare } from '../lib/validate-record.js';
81
+ import { createEntry } from '../lib/log-entry.js';
82
+ import { runChecks } from './validate.js';
83
+ import { validateValues } from './validate-values.js';
84
+ import { isCalendarDate } from '../lib/iso-date.js';
85
+ // The Time facet (UCS-1150) — the same verdict function the resolver ranks on,
86
+ // so a leaf demoted stale there is never verdicted trusted here.
87
+ import { TIME_VERDICTS, timeCheckStatus, timeVerdict } from '../lib/time-verdicts.js';
88
+
89
+ export const USAGE = 'usage: node payload/engine/preflight.js [--concepts <ids>] [--leaves <ids>] [--json] [--root <dir>] [--today <YYYY-MM-DD>] [--log]';
90
+
91
+ /** finding.schema.json conceptRef — `consulted` only carries conforming ids. */
92
+ const CONCEPT_REF = /^K-[0-9]+$/;
93
+
94
+ /** The per-verdict next action (engine hint; conduct is protocol policy). */
95
+ const NEXT_ACTIONS = Object.freeze({
96
+ trusted: 'proceed — this verdict was computed fresh this run; never cache it (a stale "trusted" is a false all-clear, D-011)',
97
+ quarantined: 'treat the concept as untrusted and fix the error-severity evidence, then re-run preflight — what a session does meanwhile (quarantine-and-continue vs. fail-stop) is protocol-layer policy (KK-20, D-011)',
98
+ 'unknown-status': 'do not rely on the enumerated values — only structural checks ran (§3.5); promote the concept to active to make its checks blocking-grade, or verify against the source-of-truth directly',
99
+ 'unknown-stage': 'do not rely on this leaf — a pre-promotion stage means no moderator has verified its citations (UCS-1149); read the cited sources directly, or have the leaf promoted to a verified stage',
100
+ 'unknown-store': 'repair the store first (fix the loader error diagnostics), then re-run preflight — no check ran for this concept, and a check that never ran is a blocking defect, never a silent pass (PRD §5)',
101
+ // The Time facet (UCS-1150). A stale leaf is not broken and its checks did
102
+ // run — the action is re-verification against the sources, which is a
103
+ // steward's job rather than a repair.
104
+ stale: 're-verify this leaf against its cited sources and update its `verified` date, or treat the claim as unverified — the knowledge is past the pinned freshness limit for its volatility class, so nothing currently vouches for it (UCS-1150)',
105
+ // A leaf that asked to be governed by time and gave nothing to measure from.
106
+ // Its verdict can only ever be `undated`, so the fix is the missing field.
107
+ 'unknown-undated': 'add the `verified` date this leaf is missing — it declares a volatility class, so it is under time governance, but its age cannot be computed and its freshness can never be certified (UCS-1150); the validator reports the same omission as a missing-verified finding',
108
+ // No --today was injected, so no freshness verdict was computed at all.
109
+ 'unknown-skipped': 'pass --today <YYYY-MM-DD> to compute time verdicts — this leaf declares a volatility class but nothing measured its age this run, and a check that never ran is never a silent pass (PRD §5, D-012)',
110
+ });
111
+
112
+ // ---------------------------------------------------------- verdict joining
113
+
114
+ /**
115
+ * Join both validators' results to the requested concepts — one verdict per
116
+ * concept. Only called on a healthy store (the store-wide degradation path
117
+ * never reaches the validators: their checks would not have run).
118
+ */
119
+ function computeVerdicts(model, ids, repoRoot) {
120
+ const structural = runChecks(model, repoRoot);
121
+ const values = validateValues(model, null, repoRoot); // full run; attribution below
122
+
123
+ return selectConcepts(model, ids).map(({ id, record }) => {
124
+ const status = record.status ?? null;
125
+ // Evidence: error-severity findings from either validator, plus value
126
+ // hard errors (unknown-kind, source-missing, …) — all attributable to
127
+ // this concept, all reasons not to trust it (quarantine, per §4).
128
+ const evidence = [
129
+ ...structural
130
+ .filter((f) => f.id === id && f.severity === 'error')
131
+ .map(({ code, file, path, message }) => ({ check: 'structural', code, severity: 'error', file, path, message })),
132
+ ...values.findings
133
+ .filter((f) => f.concept === id && f.severity === 'error')
134
+ .map(({ code, file, path, source, value, message }) => ({ check: 'value', code, severity: 'error', file, path, ...(source ? { source } : {}), ...(value !== undefined ? { value } : {}), message })),
135
+ ...values.hardErrors
136
+ .filter((e) => e.concept === id)
137
+ .map(({ code, file, path, source, message }) => ({ check: 'value', code, severity: 'hard-error', file, path, ...(source ? { source } : {}), message })),
138
+ ].sort((a, b) => compare(a.check, b.check) || compare(a.path ?? '', b.path ?? '')
139
+ || compare(a.code, b.code) || compare(a.value ?? '', b.value ?? ''));
140
+
141
+ if (evidence.length) {
142
+ return {
143
+ concept: id, status, verdict: 'quarantined',
144
+ reason: `${evidence.length} error-severity check result(s) attributable to this concept — see evidence`,
145
+ 'next-action': NEXT_ACTIONS.quarantined,
146
+ evidence,
147
+ };
148
+ }
149
+ if (isPrePromotionStatus(status)) {
150
+ return {
151
+ concept: id, status, verdict: 'unknown',
152
+ reason: `status "${status}" — structural checks only (§3.5); the value checks were skipped, so nothing certifies the claims`,
153
+ 'next-action': NEXT_ACTIONS['unknown-status'],
154
+ evidence,
155
+ };
156
+ }
157
+ return {
158
+ concept: id, status, verdict: 'trusted',
159
+ reason: 'every attributable check ran clean this run',
160
+ 'next-action': NEXT_ACTIONS.trusted,
161
+ evidence,
162
+ };
163
+ }).sort((a, b) => compare(a.concept, b.concept));
164
+ }
165
+
166
+ /**
167
+ * Leaf verdicts (UCS-1149) — the same three verdicts, computed for leaves.
168
+ *
169
+ * A leaf earns a verdict on the same two questions a concept does, asked of the
170
+ * evidence a leaf actually has:
171
+ *
172
+ * quarantined error-severity structural findings attributable to this leaf —
173
+ * an unminted facet value, a citation with no authority tier, a
174
+ * cross-reference that does not resolve. There is no value-check
175
+ * half: value checks diff a descriptor against source code, and
176
+ * a leaf carries no descriptor. Its evidence is structural only,
177
+ * which is stated rather than silently implied by an empty list.
178
+ * unknown `facets.stage` is pre-promotion — the SAME predicate the
179
+ * concept path calls, so a draft leaf and a draft concept cannot
180
+ * be verdicted differently by two surfaces that both think they
181
+ * are asking one question. This is preflight's half of the
182
+ * draft-stage contract; the resolver's half is the downrank.
183
+ * trusted neither.
184
+ *
185
+ * Attribution is by the finding's `id`, which for a leaf is its identity — its
186
+ * accession (UCS-1142/1147) — so a leaf is matched by the same string the
187
+ * validator names it by, not by a second guess at its id space.
188
+ */
189
+ function computeLeafVerdicts(model, ids, repoRoot, today) {
190
+ const structural = runChecks(model, repoRoot);
191
+
192
+ return selectLeaves(model, ids).map((entry) => {
193
+ const id = entry.identity;
194
+ const stage = leafStage(entry.record);
195
+ const time = timeVerdict(entry.record, today);
196
+ const base = { leaf: id, stage, time };
197
+ const evidence = structural
198
+ .filter((f) => f.id === id && f.severity === 'error')
199
+ .map(({ code, file, path, message }) => ({ check: 'structural', code, severity: 'error', file, path, message }))
200
+ .sort((a, b) => compare(a.path ?? '', b.path ?? '') || compare(a.code, b.code));
201
+
202
+ if (evidence.length) {
203
+ return {
204
+ ...base, verdict: 'quarantined',
205
+ reason: `${evidence.length} error-severity check result(s) attributable to this leaf — see evidence`,
206
+ 'next-action': NEXT_ACTIONS.quarantined,
207
+ evidence,
208
+ };
209
+ }
210
+ if (isPrePromotionStatus(stage)) {
211
+ return {
212
+ ...base, verdict: 'unknown',
213
+ reason: `stage "${stage}" — this leaf is pre-promotion, so no moderator has certified its citations and nothing vouches for the claim`,
214
+ 'next-action': NEXT_ACTIONS['unknown-stage'],
215
+ evidence,
216
+ };
217
+ }
218
+ // The Time facet (UCS-1150). Asked AFTER stage, because a leaf that no
219
+ // moderator has promoted is unverified for a reason that outranks its age:
220
+ // re-dating a draft would not make it trusted. A promoted leaf, though, is
221
+ // exactly the one whose freshness is the remaining question.
222
+ //
223
+ // `stale` is its OWN verdict class rather than a mapping onto `unknown` or
224
+ // `quarantined`, and the choice is the ticket's ("trusted/stale verdicts").
225
+ // The existing three each mean something a stale leaf is not: nothing about
226
+ // it is broken (quarantined), and its checks did run and returned a
227
+ // definite answer (unknown). Folding it into either would tell a steward to
228
+ // do the wrong thing — repair evidence that is fine, or pass a flag they
229
+ // already passed — and would make the leaf-verdicts surface dishonest about
230
+ // what it computed. It gates like the others: only trusted reads as clean.
231
+ if (time.stale) {
232
+ return {
233
+ ...base, verdict: 'stale',
234
+ reason: time.reason,
235
+ 'next-action': NEXT_ACTIONS.stale,
236
+ evidence,
237
+ };
238
+ }
239
+ // A leaf under time governance whose freshness could not be computed is
240
+ // NOT trusted. Two ways that happens, and they need different actions: the
241
+ // leaf is missing its date (`undated`), or this run never injected one
242
+ // (`skipped`). Both are unknown-class — a check that never ran is never a
243
+ // silent pass — and each says which fix applies.
244
+ if (time.verdict === TIME_VERDICTS.UNDATED || time.verdict === TIME_VERDICTS.SKIPPED) {
245
+ return {
246
+ ...base, verdict: 'unknown',
247
+ reason: time.reason,
248
+ 'next-action': NEXT_ACTIONS[`unknown-${time.verdict}`],
249
+ evidence,
250
+ };
251
+ }
252
+ return {
253
+ ...base, verdict: 'trusted',
254
+ reason: 'every attributable check ran clean this run',
255
+ 'next-action': NEXT_ACTIONS.trusted,
256
+ evidence,
257
+ };
258
+ }).sort((a, b) => compare(a.leaf, b.leaf));
259
+ }
260
+
261
+ /**
262
+ * Store-wide failure: no check ran — every requested LEAF verdict is unknown.
263
+ *
264
+ * Ids are resolved through `leafIdentityOf`, the same lookup the healthy path's
265
+ * `selectLeaves` uses, so a leaf that IS in the store reports under its own
266
+ * identity whether the store loaded clean or not. An id that resolves to
267
+ * nothing keys on the caller's spelling instead — see below.
268
+ */
269
+ function degradeAllLeaves(model, ids, today) {
270
+ const errors = storeHealth(model).errorCount;
271
+ // De-duplicated by IDENTITY, like selectLeaves: naming one leaf twice is one
272
+ // leaf, and emitting two verdict rows for it would have a caller reconciling
273
+ // two answers about a single record. An
274
+ // id that resolves to nothing keys on the caller's spelling instead — on a
275
+ // store this broken the leaf may simply have failed to load, so echoing back
276
+ // what was asked for is more honest than inventing an identity, and two
277
+ // distinct unresolved ids stay two rows.
278
+ const seen = new Set();
279
+ const out = [];
280
+ for (const id of ids) {
281
+ const identity = leafIdentityOf(model, id) ?? id;
282
+ if (seen.has(identity)) continue;
283
+ seen.add(identity);
284
+ const record = model.leaves.get(identity)?.record;
285
+ out.push({
286
+ leaf: identity, stage: leafStage(record),
287
+ // The time verdict travels on the degraded path too, computed from
288
+ // whatever loaded. A key that vanished on a broken store would make a
289
+ // consumer's presence check mean two things at once.
290
+ time: timeVerdict(record, today),
291
+ verdict: 'unknown',
292
+ reason: `store-wide failure: the loader reported ${errors} error(s) — no check ran for any leaf (single health model, PRD §4)`,
293
+ 'next-action': NEXT_ACTIONS['unknown-store'],
294
+ evidence: [],
295
+ });
296
+ }
297
+ return out.sort((a, b) => compare(a.leaf, b.leaf));
298
+ }
299
+
300
+ /** Store-wide failure: no check ran — every requested verdict is unknown. */
301
+ function degradeAll(model, ids) {
302
+ const errors = storeHealth(model).errorCount;
303
+ return ids.map((id) => ({
304
+ concept: id, status: model.concepts.get(id)?.record.status ?? null, verdict: 'unknown',
305
+ reason: `store-wide failure: the loader reported ${errors} error(s) — no check ran for any concept (single health model, PRD §4)`,
306
+ 'next-action': NEXT_ACTIONS['unknown-store'],
307
+ evidence: [],
308
+ })).sort((a, b) => compare(a.concept, b.concept));
309
+ }
310
+
311
+ // -------------------------------------------- quarantine findings (KK-13)
312
+
313
+ /**
314
+ * Append one engine-attributed quarantine finding per quarantined concept
315
+ * (capture content policy §3.4: concept ids, codes, and paths only).
316
+ * Returns the root-relative fragment paths, sorted.
317
+ */
318
+ function logQuarantines(root, verdicts, today) {
319
+ const logged = [];
320
+ for (const v of verdicts) {
321
+ if (v.verdict !== 'quarantined') continue;
322
+ const codes = [...new Set(v.evidence.map((e) => e.code))].sort(compare);
323
+ const paths = [...new Set(v.evidence.flatMap((e) => [e.file, e.source]).filter(Boolean))].sort(compare);
324
+ const { file } = createEntry({
325
+ root, log: 'findings', date: today,
326
+ fields: {
327
+ trigger: 'quarantine',
328
+ session: 'engine/preflight.js',
329
+ summary: `preflight quarantined ${v.concept}: ${codes.join(', ')} (${paths.join(', ')})`,
330
+ ...(CONCEPT_REF.test(v.concept) ? { consulted: { concepts: [v.concept] } } : {}),
331
+ },
332
+ });
333
+ logged.push(file);
334
+ }
335
+ return logged.sort(compare);
336
+ }
337
+
338
+ // ------------------------------------------------------------- CLI plumbing
339
+
340
+ function parseArgs(argv) {
341
+ const { options } = parseFlags(argv, {
342
+ boolean: ['json', 'log'],
343
+ value: ['root', 'today'],
344
+ repeatable: ['concepts', 'leaves'],
345
+ // PRD §7: an explicitly empty --concepts selects store-health-only.
346
+ allowEmpty: ['concepts', 'leaves'],
347
+ });
348
+ const opts = {
349
+ json: !!options.json,
350
+ log: !!options.log,
351
+ root: options.root ?? process.cwd(),
352
+ today: options.today ?? null,
353
+ concepts: options.concepts ? normalizeConceptIds(options.concepts.flatMap((v) => v.split(','))) : null,
354
+ // Same grammar as --concepts, deliberately: an id list is an id list, and
355
+ // two surfaces that trimmed arguments differently is the divergence
356
+ // normalizeConceptIds was written to end (UCS-935).
357
+ leaves: options.leaves ? normalizeConceptIds(options.leaves.flatMap((v) => v.split(','))) : null,
358
+ };
359
+ if (opts.today !== null && !isCalendarDate(opts.today)) {
360
+ // --log writes --today into permanent fragments, so a date that does not
361
+ // exist would be stamped into an audit trail forever.
362
+ throw new UsageError(`--today must be a real calendar date (YYYY-MM-DD), got ${JSON.stringify(opts.today)}`);
363
+ }
364
+ if (opts.log && !opts.today) {
365
+ throw new UsageError('--log requires --today <YYYY-MM-DD> — the finding helper never reads the wall clock (PRD §5)');
366
+ }
367
+ return opts;
368
+ }
369
+
370
+ function renderHuman(payload) {
371
+ const lines = [];
372
+ const { counts } = payload;
373
+ const leafVerdicts = payload['leaf-verdicts'] ?? [];
374
+ if (payload.mode === 'store-health') {
375
+ lines.push(`preflight (store-health only — no --concepts/--leaves): store verdict ${payload['store-verdict']}`);
376
+ } else {
377
+ // One counted subject line over both record kinds, matching `counts`: a
378
+ // header that tallied only concepts would disagree with the exit code the
379
+ // moment a leaf was quarantined.
380
+ const subjects = [
381
+ payload.verdicts.length ? `${payload.verdicts.length} concept(s)` : null,
382
+ leafVerdicts.length ? `${leafVerdicts.length} leaf/leaves` : null,
383
+ ].filter(Boolean).join(' + ');
384
+ lines.push(
385
+ `preflight: ${subjects} — ${counts.trusted} trusted, `
386
+ + `${counts.quarantined} quarantined, ${counts.stale} stale, ${counts.unknown} unknown `
387
+ + `(store verdict ${payload['store-verdict']})`,
388
+ );
389
+ }
390
+ // Whether time verdicts ran at all — printed whenever leaves were asked
391
+ // about, computed or not. A skipped check that said nothing would read
392
+ // exactly like a check that passed (PRD §5).
393
+ if (payload['time-check']) lines.push(`time check: ${payload['time-check']}`);
394
+ for (const d of payload['store-errors'] ?? []) {
395
+ lines.push(` store error ${d.code} ${d.file}${d.path ? ` ${d.path}` : ''}`, ` ${d.message}`);
396
+ }
397
+ for (const v of payload.verdicts) {
398
+ lines.push('', `${v.verdict.toUpperCase()} ${v.concept}${v.status ? ` (${v.status})` : ''}`, ` ${v.reason}`);
399
+ for (const e of v.evidence) {
400
+ lines.push(` ${e.severity === 'hard-error' ? 'HARD ERROR' : 'error'} ${e.code} ${e.file} ${e.path}${e.source ? ` (source: ${e.source})` : ''}`);
401
+ }
402
+ lines.push(` next: ${v['next-action']}`);
403
+ }
404
+ for (const v of leafVerdicts) {
405
+ // The time verdict rides the subject line beside the stage: both are
406
+ // properties of the leaf a reader judges it by, and a stale leaf must say
407
+ // so where its verdict is read rather than only in the tally.
408
+ const time = v.time ? ` (time: ${v.time.verdict}${v.time.volatility ? `, ${v.time.volatility}` : ''}${v.time.age === null ? '' : `, ${v.time.age}d`})` : '';
409
+ lines.push('', `${v.verdict.toUpperCase()} ${v.leaf}${v.stage ? ` (stage: ${v.stage})` : ''}${time}`, ` ${v.reason}`);
410
+ for (const e of v.evidence) {
411
+ lines.push(` error ${e.code} ${e.file} ${e.path}`);
412
+ }
413
+ lines.push(` next: ${v['next-action']}`);
414
+ }
415
+ for (const file of payload.logged ?? []) {
416
+ lines.push('', `quarantine finding appended: ${file}`);
417
+ }
418
+ if (payload.ok) {
419
+ lines.push('', payload.mode === 'store-health'
420
+ ? 'store health is clean — per-record verdicts need a --concepts or --leaves list'
421
+ : 'everything requested is trusted this run — verdicts are never cached (D-011)');
422
+ }
423
+ return lines;
424
+ }
425
+
426
+ export function main(argv) {
427
+ {
428
+ const opts = parseArgs(argv);
429
+
430
+ let model;
431
+ try {
432
+ // --root is the repo root (§9.1), same as validate-values.js.
433
+ model = loadStores(locateKitRoot(opts.root));
434
+ } catch (error) {
435
+ // An EXPECTED refusal from the loader — an unreadable root, an ambiguous
436
+ // kit layout, a Store that will not load. The stores this command would
437
+ // check never loaded, so its checks never ran: exit 2, never 1.
438
+ process.stderr.write(`preflight: ${error.message}\n`);
439
+ rethrowIfBug(error); // a bug, or a UsageError raised deep in the loader, is not ours to speak for
440
+ return EXIT_CODES.FAILURE;
441
+ }
442
+
443
+ // One read of the one authority; the wire shape is a projection of it.
444
+ const fullHealth = storeHealth(model);
445
+ const health = healthSummary(fullHealth);
446
+ const storeVerdict = model.ok ? 'trusted' : 'unknown';
447
+ const storeErrors = fullHealth.errors
448
+ .map(({ code, file, path, message }) => ({ code, file, path, message }));
449
+
450
+ // Empty/omitted --concepts AND --leaves: store-health-only — exit on the
451
+ // store verdict alone (§7); no per-record check runs, so no per-record
452
+ // verdict exists. Either flag alone selects that flag's records; both
453
+ // select both, because "which concepts" and "which leaves" are two
454
+ // questions and a run may legitimately ask one, the other, or both.
455
+ const wantConcepts = !!opts.concepts && opts.concepts.length > 0;
456
+ const wantLeaves = !!opts.leaves && opts.leaves.length > 0;
457
+ const storeHealthOnly = !wantConcepts && !wantLeaves;
458
+
459
+ let verdicts = [];
460
+ let leafVerdicts = [];
461
+ if (wantConcepts) {
462
+ // Store-wide failures degrade ALL requested verdicts to unknown: the
463
+ // validators' checks never ran over a store that failed to load, and a
464
+ // check that never ran is a blocking defect, never a silent pass.
465
+ verdicts = model.ok ? computeVerdicts(model, opts.concepts, opts.root) : degradeAll(model, opts.concepts);
466
+ }
467
+ if (wantLeaves) {
468
+ leafVerdicts = model.ok
469
+ ? computeLeafVerdicts(model, opts.leaves, opts.root, opts.today)
470
+ : degradeAllLeaves(model, opts.leaves, opts.today);
471
+ }
472
+
473
+ // Counted TOGETHER, over both verdict lists. Splitting the counts would let
474
+ // a run exit 0 on clean concepts while a requested leaf was quarantined —
475
+ // the gate reading as clean about the half of the question it liked.
476
+ const all = [...verdicts, ...leafVerdicts];
477
+ const counts = {
478
+ trusted: all.filter((v) => v.verdict === 'trusted').length,
479
+ quarantined: all.filter((v) => v.verdict === 'quarantined').length,
480
+ unknown: all.filter((v) => v.verdict === 'unknown').length,
481
+ // Counted separately (UCS-1150) so a stale leaf is visible in the tally
482
+ // rather than absorbed into a class that means something else. `ok`
483
+ // below still requires trusted === all.length, so a stale leaf gates.
484
+ stale: all.filter((v) => v.verdict === 'stale').length,
485
+ };
486
+ const logged = opts.log ? logQuarantines(model.root, verdicts, opts.today) : null;
487
+
488
+ const ok = storeHealthOnly
489
+ ? storeVerdict === 'trusted'
490
+ : storeVerdict === 'trusted' && counts.trusted === all.length;
491
+ const payload = {
492
+ ok,
493
+ // The mode names what was ASKED. `leaves` and `concepts+leaves` are new
494
+ // (UCS-1149); a run that named only concepts reads exactly as it did
495
+ // before, so no existing consumer sees a shape it did not ask for.
496
+ mode: storeHealthOnly
497
+ ? 'store-health'
498
+ : [wantConcepts ? 'concepts' : null, wantLeaves ? 'leaves' : null].filter(Boolean).join('+'),
499
+ 'store-verdict': storeVerdict,
500
+ 'store-health': health,
501
+ ...(storeErrors.length ? { 'store-errors': storeErrors } : {}),
502
+ counts,
503
+ verdicts,
504
+ // Present only when leaves were asked about, for the same reason `mode`
505
+ // still says `concepts`: a --concepts-only run's JSON is unchanged.
506
+ // `time-check` rides the same condition — leaves are the only records the
507
+ // time facet governs, so a concepts-only run has no time check to report
508
+ // and inventing one would answer a question nobody asked. When leaves
509
+ // ARE asked about, it is always present: a run that computed no freshness
510
+ // verdicts must never look like one that checked and found them fresh.
511
+ ...(wantLeaves ? { 'time-check': timeCheckStatus(opts.today), 'leaf-verdicts': leafVerdicts } : {}),
512
+ ...(logged ? { logged } : {}),
513
+ };
514
+
515
+ const lines = opts.json ? [JSON.stringify(payload, null, 2)] : renderHuman(payload);
516
+ process.stdout.write(`${lines.join('\n').replace(/\n+$/, '')}\n`);
517
+
518
+ // Exit-code contract (PRD §5, D-011): only all-trusted reads as clean.
519
+ // Any unknown — store-wide failure or a draft/proposed skip — gates at 2:
520
+ // its checks never ran, and that is a blocking defect, never exit 0.
521
+ if (storeHealthOnly) return storeVerdict === 'trusted' ? EXIT_CODES.CLEAN : EXIT_CODES.FAILURE;
522
+ if (storeVerdict !== 'trusted' || counts.unknown > 0) return EXIT_CODES.FAILURE;
523
+ // A stale verdict gates at 1, alongside quarantine, and NOT at 2 (UCS-1150).
524
+ // The distinction is the one the exit contract already draws: 2 means a
525
+ // check never ran, and the time check ran — it returned a definite answer
526
+ // a steward can act on. Rotted knowledge is a finding to fix, not a broken
527
+ // engine, and only an all-trusted run still reads as clean.
528
+ return counts.quarantined > 0 || counts.stale > 0 ? EXIT_CODES.FINDINGS : EXIT_CODES.CLEAN;
529
+ }
530
+ }