@panaversity/ksor 0.0.39 → 0.0.41

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 (108) hide show
  1. package/CHANGELOG.md +965 -0
  2. package/README.md +29 -9
  3. package/dist/checker/check-main.mjs +14049 -0
  4. package/dist/cli.mjs +11528 -5213
  5. package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
  6. package/dist/gateway.d.mts +52 -13
  7. package/dist/gateway.mjs +2 -2
  8. package/dist/index.d.mts +1 -1
  9. package/dist/index.mjs +1 -1
  10. package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
  11. package/docs/authorization.md +8 -6
  12. package/docs/deploying.md +36 -25
  13. package/docs/index.md +26 -13
  14. package/docs/ingesting.md +70 -22
  15. package/docs/tool-surface.md +69 -16
  16. package/package.json +4 -3
  17. package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
  18. package/schema/schema.sql +77 -14
  19. package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
  20. package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
  21. package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
  22. package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
  23. package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
  24. package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
  25. package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
  26. package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
  27. package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
  28. package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
  29. package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
  30. package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
  31. package/templates/scaffold/.github/workflows/validate.yml +9 -1
  32. package/templates/scaffold/.ksor/governance.yaml +17 -0
  33. package/templates/scaffold/AGENTS.md +293 -119
  34. package/templates/scaffold/Dockerfile +5 -1
  35. package/templates/scaffold/README.md +160 -42
  36. package/templates/scaffold/env.example +37 -6
  37. package/templates/scaffold/gitignore +14 -6
  38. package/templates/scaffold/instance.md +21 -17
  39. package/templates/scaffold/knowledge/governance-ladder.md +11 -2
  40. package/templates/scaffold/knowledge/index.md +9 -0
  41. package/templates/scaffold/knowledge/surfaces/for-agents.md +13 -6
  42. package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
  43. package/templates/scaffold/knowledge/surfaces/index.md +4 -20
  44. package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
  45. package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
  46. package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
  47. package/templates/scaffold/package.json +3 -4
  48. package/templates/scaffold/pnpm-lock.yaml +3 -0
  49. package/templates/scaffold/system/gateways/content.ts +13 -0
  50. package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
  51. package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
  52. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +134 -91
  53. package/templates/scaffold/system/site/app/global.css +198 -6
  54. package/templates/scaffold/system/site/app/layout.tsx +8 -3
  55. package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
  56. package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
  57. package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
  58. package/templates/scaffold/system/site/components/code-block.tsx +87 -0
  59. package/templates/scaffold/system/site/components/embed.tsx +276 -0
  60. package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
  61. package/templates/scaffold/system/site/components/governance.tsx +219 -87
  62. package/templates/scaffold/system/site/components/mdx.tsx +10 -2
  63. package/templates/scaffold/system/site/components/record-index.tsx +5 -5
  64. package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
  65. package/templates/scaffold/system/site/components/record-views.tsx +6 -17
  66. package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
  67. package/templates/scaffold/system/site/lib/alert-rule.ts +214 -0
  68. package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
  69. package/templates/scaffold/system/site/lib/attachments.ts +0 -28
  70. package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
  71. package/templates/scaffold/system/site/lib/audience.ts +42 -146
  72. package/templates/scaffold/system/site/lib/embed-rule.ts +255 -0
  73. package/templates/scaffold/system/site/lib/governance.ts +339 -225
  74. package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
  75. package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
  76. package/templates/scaffold/system/site/lib/lock.ts +282 -0
  77. package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
  78. package/templates/scaffold/system/site/lib/record-href.ts +68 -0
  79. package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
  80. package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
  81. package/templates/scaffold/system/site/lib/shared.ts +67 -104
  82. package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
  83. package/templates/scaffold/system/site/lib/source.ts +256 -186
  84. package/templates/scaffold/system/site/lib/stage-knowledge.ts +638 -467
  85. package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
  86. package/templates/scaffold/system/site/package.json +1 -0
  87. package/templates/scaffold/system/site/record/actor.ts +23 -0
  88. package/templates/scaffold/system/site/record/check.ts +571 -0
  89. package/templates/scaffold/system/site/record/citations.ts +312 -0
  90. package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
  91. package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
  92. package/templates/scaffold/system/site/record/hygiene.ts +320 -0
  93. package/templates/scaffold/system/site/record/index-file.ts +150 -0
  94. package/templates/scaffold/system/site/record/index.ts +103 -0
  95. package/templates/scaffold/system/site/record/instance.ts +257 -0
  96. package/templates/scaffold/system/site/record/instant.ts +43 -0
  97. package/templates/scaffold/system/site/record/ledger.ts +694 -0
  98. package/templates/scaffold/system/site/record/load.ts +129 -0
  99. package/templates/scaffold/system/site/record/lock.ts +306 -0
  100. package/templates/scaffold/system/site/record/near-miss.ts +37 -0
  101. package/templates/scaffold/system/site/record/policy.ts +414 -0
  102. package/templates/scaffold/system/site/record/profile.ts +535 -0
  103. package/templates/scaffold/system/site/record/refusal.ts +106 -0
  104. package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
  105. package/templates/scaffold/system/site/source.config.ts +109 -23
  106. package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
  107. package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
  108. package/templates/scaffold/system/site/lib/page-order.ts +0 -93
@@ -0,0 +1,571 @@
1
+ /**
2
+ * The record checker — ONE rule set (record spec §6), run by `ksor build`,
3
+ * `ksor ingest` and the emitted `check.mjs`. It reads an in-memory tree so
4
+ * the same function judges a checkout, a staged projection and a test
5
+ * fixture identically; `load.ts` is the one place the filesystem is touched.
6
+ */
7
+ import { ATTACHMENT_SUFFIXES, attachmentKindOf, parentDocumentOf } from "../lib/attachment-rule";
8
+ import { mayReach } from "../lib/audience-rule";
9
+ import { checkFootnotes, linkTargets, resolveLink } from "./citations";
10
+ import { splitFrontmatter } from "./frontmatter";
11
+ import { checkHygiene } from "./hygiene";
12
+ import { generateIndexes } from "./index-file";
13
+ import { parseInstanceDocument } from "./instance";
14
+ import {
15
+ checkLedgerActors,
16
+ checkLedgerAgainstTree,
17
+ checkLedgerAppendOnly,
18
+ ledgerDigests,
19
+ parseLedger,
20
+ type LedgerBaseline,
21
+ } from "./ledger";
22
+ import { parsePolicy, resolveApprovers, resolveOwner, type Policy } from "./policy";
23
+ import { conceptIdOf, parseConcept, type Concept } from "./profile";
24
+ import { sortRefusals, type Refusal } from "./refusal";
25
+
26
+ export interface RecordFiles {
27
+ /** Record-relative path → text, for every file the checker reads (`knowledge/**`, `.ksor/*.yaml`, `instance.md`). */
28
+ readonly files: ReadonlyMap<string, string>;
29
+ /** Record-relative directories under `knowledge/`, empty ones included. */
30
+ readonly dirs: readonly string[];
31
+ /** Every other file under `knowledge/` (images, strays) with its bytes; OS junk excluded. */
32
+ readonly assets?: ReadonlyMap<string, Uint8Array>;
33
+ /** Symbolic links the loader met and did not follow. */
34
+ readonly symlinks?: readonly string[];
35
+ }
36
+
37
+ export interface CheckOptions {
38
+ /** `check` is read-only and refuses a stale index; `build` regenerates and never does. */
39
+ readonly mode: "check" | "build";
40
+ /** What the ledger must still contain, entry by entry (git history, the committed lock). */
41
+ /** Required, not optional: a caller that forgets these silently gets the STRICT rule, which is how ingest came to refuse a departed authority the site had published. */
42
+ readonly ledgerBaselines: readonly LedgerBaseline[];
43
+ }
44
+
45
+ export interface CheckResult {
46
+ readonly refusals: readonly Refusal[];
47
+ /** Record-relative index path → bytes, generated from the tree. */
48
+ readonly indexes: ReadonlyMap<string, string>;
49
+ readonly concepts: readonly Concept[];
50
+ /** `(id, digest)` per entry, in file order — what the lock records so the next build can compare text. */
51
+ readonly ledgerEntries: readonly { readonly id: string; readonly digest: string }[];
52
+ readonly policy: Policy | null;
53
+ }
54
+
55
+ const KNOWLEDGE = "knowledge/";
56
+ const POLICY_PATH = ".ksor/governance.yaml";
57
+ const LEDGER_PATH = ".ksor/takedowns.yaml";
58
+ const INSTANCE_PATH = "instance.md";
59
+ /** What a link may resolve to besides a concept: companions, assets, directories, indexes, the root. */
60
+ interface LinkTargets {
61
+ readonly concepts: ReadonlyMap<string, Concept>;
62
+ readonly exists: (id: string) => boolean;
63
+ /** Bundle-relative ids of the record's asset files — `secret/org-chart.png`. */
64
+ readonly assets: ReadonlySet<string>;
65
+ /** Bundle-relative directories — `secret`. A link may name one, and it inherits by POSITION. */
66
+ readonly directories: ReadonlySet<string>;
67
+ }
68
+
69
+ export function checkRecord(record: RecordFiles, options: CheckOptions): CheckResult {
70
+ const refusals: Refusal[] = [];
71
+ const paths = [...record.files.keys()].sort();
72
+
73
+ const policyResult = parsePolicy(record.files.get(POLICY_PATH) ?? null, POLICY_PATH);
74
+ const policy = policyResult.ok ? policyResult.policy : null;
75
+ if (!policyResult.ok) refusals.push(...policyResult.refusals);
76
+
77
+ // `null` when instance.md could not be read: the index generator still needs
78
+ // a heading, and the staleness block still needs to know it is guessing.
79
+ const instanceTitle = checkInstance(record.files.get(INSTANCE_PATH) ?? null, refusals);
80
+ const title = instanceTitle ?? "Index";
81
+
82
+ const assets = record.assets ?? new Map<string, Uint8Array>();
83
+
84
+ // ── the bundle: concepts, companions, reserved names ───────────────────
85
+ // A document that fails to parse is not a concept, so the indexes generated
86
+ // below are the indexes of a DIFFERENT tree — see the staleness block. Its
87
+ // id is kept, because every rule that asks "is this in the tree?" must not
88
+ // read the absence of a CONCEPT as the absence of a DOCUMENT: the ledger and
89
+ // the supersession pointer both did, and both fabricated a refusal about a
90
+ // file still sitting in the checkout (2026-08-25 review).
91
+ const unreadable = new Set<string>();
92
+ const concepts = new Map<string, Concept>();
93
+ const bodies = new Map<string, string>();
94
+ for (const path of paths) {
95
+ if (!path.startsWith(KNOWLEDGE)) continue;
96
+ const name = path.slice(path.lastIndexOf("/") + 1);
97
+ if (name === "log.md" || name === "README.md") {
98
+ refusals.push({
99
+ slug: "ksor-reserved-name",
100
+ path,
101
+ why: `\`${name}\` is reserved — \`log.md\` by OKF §9 and \`README.md\` by this profile; neither is a concept`,
102
+ fix: "move the prose into a named concept such as `overview.md` and delete the file",
103
+ });
104
+ continue;
105
+ }
106
+ if (name === "index.md" || attachmentKindOf(name) !== null || !name.endsWith(".md")) continue;
107
+ // `.mdx` and every other stray are the hygiene rules' to name.
108
+ const text = record.files.get(path) ?? "";
109
+ const split = splitFrontmatter(text, path);
110
+ if (!split.ok) {
111
+ refusals.push(split.refusal);
112
+ unreadable.add(conceptIdOf(path));
113
+ continue;
114
+ }
115
+ const parsed = parseConcept(path, split.frontmatter ?? {});
116
+ if (!parsed.ok) {
117
+ refusals.push(...parsed.refusals);
118
+ unreadable.add(conceptIdOf(path));
119
+ continue;
120
+ }
121
+ concepts.set(parsed.concept.id, parsed.concept);
122
+ bodies.set(parsed.concept.id, split.body);
123
+ }
124
+
125
+ // ── indexes ────────────────────────────────────────────────────────────
126
+ const dirs = record.dirs
127
+ .filter((d) => d.startsWith(KNOWLEDGE))
128
+ .map((d) => d.slice(KNOWLEDGE.length));
129
+ const generated = generateIndexes({
130
+ title,
131
+ concepts: [...concepts.values()].map((c) => ({
132
+ id: c.id,
133
+ title: c.title,
134
+ description: c.description,
135
+ order: c.order,
136
+ })),
137
+ dirs,
138
+ });
139
+ const indexes = new Map([...generated].map(([p, text]) => [`${KNOWLEDGE}${p}`, text]));
140
+ const dirSet = new Set(record.dirs);
141
+ const targets: LinkTargets = {
142
+ concepts,
143
+ assets: new Set([...assets.keys()].map((p) => p.slice(KNOWLEDGE.length))),
144
+ directories: new Set(dirs),
145
+ exists: (id) =>
146
+ id === "" ||
147
+ concepts.has(id) ||
148
+ record.files.has(`${KNOWLEDGE}${id}.md`) ||
149
+ record.files.has(`${KNOWLEDGE}${id}`) ||
150
+ assets.has(`${KNOWLEDGE}${id}`) ||
151
+ dirSet.has(`${KNOWLEDGE}${id}`) ||
152
+ indexes.has(`${KNOWLEDGE}${id}.md`),
153
+ };
154
+
155
+ for (const path of paths) {
156
+ if (!path.startsWith(KNOWLEDGE)) continue;
157
+ const base = path.slice(path.lastIndexOf("/") + 1);
158
+ // The canonical rule (`lib/attachment-rule.ts`), never a copy of it. The
159
+ // regex that used to sit here was a THIRD hand-written list and had already
160
+ // drifted — `.summary.mdx` was in the canonical one and not in it, so an
161
+ // `.mdx` summary got no orphan check, no `type: Summary` check and none of
162
+ // its parent's governance. `hygiene.ts` happens to refuse every `.mdx`, but
163
+ // the mask was in another module from the drift, which is the arrangement
164
+ // decision 18 exists to end.
165
+ const kind = attachmentKindOf(base);
166
+ if (kind === null) continue;
167
+ const dir = path.slice(0, path.lastIndexOf("/") + 1);
168
+ const parentName = parentDocumentOf(base)!;
169
+ const parentId = conceptIdOf(`${dir}${parentName}`);
170
+ // A generated index is not a document (record spec §1): no route, no node,
171
+ // no llms.txt line, no governance of its own — so nothing can attach to it,
172
+ // and decision 27 retires the `index.summary.md` row from the canonical
173
+ // table with the authored index. Refused BY NAME rather than left to the
174
+ // orphan rule, which cannot see it: the orphan rule asks whether the parent
175
+ // FILE is in the tree, and the generated `index.md` is committed, so it
176
+ // passed. Everything downstream then declined to publish it — staging
177
+ // gathers companions of admitted CONCEPTS only, and `index.md` is not one —
178
+ // so the file was accepted, stamped into the lock's `companions[]` and into
179
+ // `build_id`, and rendered nowhere, ever, in silence.
180
+ if (parentName === "index.md") {
181
+ refusals.push({
182
+ slug: "ksor-attachment-of-index",
183
+ path,
184
+ why: `\`${dir}index.md\` is a GENERATED index, not a document — it has no route, no node and no governance of its own, so nothing can be attached to it; this file would be accepted here, stamped into \`build.lock.json\` and published on no surface at all`,
185
+ fix: `attach it to a document instead: move the prose into a named concept such as \`${dir}overview.md\` and rename this to \`${dir}overview.summary.md\`, or delete it`,
186
+ });
187
+ continue;
188
+ }
189
+ if (!concepts.has(parentId) && !record.files.has(`${KNOWLEDGE}${parentId}.md`)) {
190
+ refusals.push({
191
+ slug: "ksor-attachment-orphan",
192
+ path,
193
+ why: `no \`${parentId}.md\` exists for this attachment to belong to — it has no identity of its own`,
194
+ fix: "restore the parent document, or delete the attachment",
195
+ });
196
+ }
197
+ if (kind !== "summary") continue;
198
+ const split = splitFrontmatter(record.files.get(path) ?? "", path);
199
+ if (!split.ok) {
200
+ refusals.push(split.refusal);
201
+ continue;
202
+ }
203
+ const fm = split.frontmatter;
204
+ const keys = fm === null ? [] : Object.keys(fm);
205
+ if (fm === null || keys.length !== 1 || fm["type"] !== "Summary") {
206
+ refusals.push({
207
+ slug: "ksor-attachment-frontmatter",
208
+ path,
209
+ why: `a summary's frontmatter is exactly \`type: Summary\` — it inherits its parent's audience, status and takedown, and any other key would claim governance a non-node cannot carry (found: ${keys.length === 0 ? "none" : keys.join(", ")})`,
210
+ fix: "write exactly these three lines:\n---\ntype: Summary\n---",
211
+ });
212
+ continue;
213
+ }
214
+ const parent = concepts.get(parentId);
215
+ if (parent !== undefined)
216
+ checkLinks(path, parent.audience, split.body, parentId, targets, refusals);
217
+ }
218
+
219
+ refusals.push(
220
+ ...checkHygiene({
221
+ textPaths: paths.filter((p) => p.startsWith(KNOWLEDGE)),
222
+ assets,
223
+ dirs: record.dirs,
224
+ symlinks: record.symlinks ?? [],
225
+ conceptIds: new Set(concepts.keys()),
226
+ }),
227
+ );
228
+
229
+ // ── rules that need the policy ─────────────────────────────────────────
230
+ for (const concept of concepts.values()) {
231
+ const body = bodies.get(concept.id) ?? "";
232
+ refusals.push(...checkFootnotes(concept.path, body, concept.sourceIds));
233
+ checkLinks(concept.path, concept.audience, body, concept.id, targets, refusals);
234
+ checkSupersession(concept, concepts, unreadable, refusals);
235
+ if (policy !== null) checkAgainstPolicy(concept, policy, refusals);
236
+ }
237
+
238
+ // Staleness is only answerable when the generator's inputs were ALL readable
239
+ // — every document, and the instance whose title is the root index's heading.
240
+ // A refused document is not a concept, so its directory generates a different
241
+ // index or none at all, and comparing against that produced one extra refusal
242
+ // per affected directory AND per ancestor — each prescribing "run `ksor
243
+ // build`", which refuses on the real error and writes nothing, and each
244
+ // saying of a correct index that it belongs to "a directory that earns none".
245
+ // A refused instance did the same to the root index through the fallback
246
+ // heading, so a single typo in instance.md printed two errors and sent the
247
+ // operator at the one they cannot act on (2026-08-25 review). One bad input,
248
+ // one problem; fix it and the next run answers this honestly.
249
+ if (options.mode === "check" && unreadable.size === 0 && instanceTitle !== null) {
250
+ const expected = new Set(indexes.keys());
251
+ const committed = paths.filter((p) => p.startsWith(KNOWLEDGE) && p.endsWith("/index.md"));
252
+ for (const path of new Set([...expected, ...committed])) {
253
+ if (record.files.get(path) === indexes.get(path)) continue;
254
+ refusals.push({
255
+ slug: "ksor-index-stale",
256
+ path,
257
+ why: indexes.has(path)
258
+ ? "the committed index does not match what the tree generates — an index is never authored"
259
+ : "an index exists for a directory that earns none",
260
+ fix: "run `ksor build`, which regenerates every index, and commit the result",
261
+ });
262
+ }
263
+ }
264
+
265
+ // ── the ledger ─────────────────────────────────────────────────────────
266
+ const ledgerResult = parseLedger(record.files.get(LEDGER_PATH) ?? null, LEDGER_PATH);
267
+ let ledgerEntries: readonly { readonly id: string; readonly digest: string }[] = [];
268
+ if (!ledgerResult.ok) {
269
+ refusals.push(...ledgerResult.refusals);
270
+ } else {
271
+ const ledger = ledgerResult.ledger;
272
+ ledgerEntries = ledgerDigests(ledger);
273
+ // The baselines are the WHOLE of the departed-authority rule, and this call
274
+ // used to omit them — two arguments, so `baselines` took its default and the
275
+ // accepted set was always empty, while `options.ledgerBaselines` sat right
276
+ // here and was forwarded to `checkLedgerAppendOnly` one line below. The
277
+ // BOTH the parameter and `CheckOptions.ledgerBaselines` are required now,
278
+ // so a caller that forgets does not compile. Requiring only the inner
279
+ // parameter left the public seam optional, and the next caller did forget:
280
+ // ingest passed `{ mode: "build" }` and got the strict rule in silence.
281
+ if (policy !== null) {
282
+ refusals.push(...checkLedgerActors(ledger, policy.takedownActors, options.ledgerBaselines));
283
+ }
284
+ refusals.push(
285
+ ...checkLedgerAgainstTree(ledger, {
286
+ documentIds: new Set([...concepts.keys(), ...unreadable]),
287
+ dirs: new Set(dirs),
288
+ }),
289
+ );
290
+ refusals.push(...checkLedgerAppendOnly(ledger, options.ledgerBaselines));
291
+ }
292
+
293
+ return {
294
+ refusals: sortRefusals(refusals),
295
+ indexes,
296
+ concepts: [...concepts.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
297
+ ledgerEntries,
298
+ policy,
299
+ };
300
+ }
301
+
302
+ /**
303
+ * The instance title for the root index, or `null` when the instance could not
304
+ * be read — a distinction the caller needs, not decoration: the fallback
305
+ * heading generates a root index nothing in the tree matches, and reporting
306
+ * THAT as staleness prints a second refusal whose remedy cannot run.
307
+ * Refuses a pre-profile instance (one reader: `record/instance.ts`).
308
+ */
309
+ function checkInstance(text: string | null, refusals: Refusal[]): string | null {
310
+ if (text === null) {
311
+ refusals.push({
312
+ slug: "ksor-instance-format",
313
+ path: INSTANCE_PATH,
314
+ why: "instance.md is missing — it says what this record is authoritative for; without it nothing states the record's scope and the MCP server has no instructions",
315
+ fix: "restore instance.md from git history, or run the intake-interview skill to write it",
316
+ });
317
+ return null;
318
+ }
319
+ const parsed = parseInstanceDocument(text, INSTANCE_PATH);
320
+ if (!parsed.ok) {
321
+ refusals.push(...parsed.refusals);
322
+ return null;
323
+ }
324
+ return parsed.instance.title;
325
+ }
326
+
327
+ function checkLinks(
328
+ path: string,
329
+ audience: readonly string[],
330
+ body: string,
331
+ sourceId: string,
332
+ targets: LinkTargets,
333
+ refusals: Refusal[],
334
+ ): void {
335
+ const seen = new Set<string>();
336
+ for (const target of linkTargets(body)) {
337
+ const id = resolveLink(sourceId, target);
338
+ if (id === null) {
339
+ if (seen.has(target)) continue;
340
+ seen.add(target);
341
+ refusals.push({
342
+ slug: "ksor-link-escapes",
343
+ path,
344
+ why: `\`${target}\` leaves the record — the record must survive without the system, and an outward link breaks the walk-away promise`,
345
+ fix: "move the file into knowledge/ beside the document, or use an absolute URL",
346
+ });
347
+ continue;
348
+ }
349
+ if (seen.has(id)) continue;
350
+ seen.add(id);
351
+ if (!targets.exists(id)) {
352
+ refusals.push({
353
+ slug: "ksor-link-dead",
354
+ path,
355
+ why: `dead link \`${target}\` — nothing at \`knowledge/${id}\`; a record with dead internal links serves different truths by path`,
356
+ fix: "fix the path (it resolves against this document's directory, or against knowledge/ when it starts with `/`) or remove the link",
357
+ });
358
+ continue;
359
+ }
360
+ const found = targets.concepts.get(id);
361
+ if (found === undefined) {
362
+ nonConceptWidens(path, audience, id, targets, refusals);
363
+ continue;
364
+ }
365
+ if (mayReach(audience, found.audience)) continue;
366
+ refusals.push({
367
+ slug: "ksor-link-widens",
368
+ path,
369
+ why: `links to \`${id}\` (audience [${found.audience.join(", ")}]), which not every reader of this document (audience [${audience.join(", ")}]) may read`,
370
+ fix: "widen the target's audience, narrow this document's, or remove the link",
371
+ });
372
+ }
373
+ }
374
+
375
+ /**
376
+ * A companion's id → its parent concept's, in both shapes `resolveLink`
377
+ * produces: it strips a trailing `.md` and nothing else (`citations.ts`), so
378
+ * `x.summary.md` arrives as `x.summary` and `x.flashcards.yaml` arrives whole.
379
+ *
380
+ * DERIVED from the canonical suffix list for the same reason the companion pass
381
+ * above is — this was the FOURTH hand-written copy of "what is a companion" and
382
+ * it carried the same `.summary.mdx` gap.
383
+ */
384
+ const COMPANION_TARGET = new RegExp(
385
+ `(${[...new Set(ATTACHMENT_SUFFIXES.map((e) => e.suffix.replace(/\.md$/, "")))]
386
+ .map((s) => s.replace(/\./g, "\\."))
387
+ .join("|")})$`,
388
+ );
389
+
390
+ /**
391
+ * Every link target that is NOT a concept, judged by the same audience rule a
392
+ * concept target is — because `targets.exists` admits five kinds and only one
393
+ * of them used to be judged at all.
394
+ *
395
+ * A COMPANION inherits its parent's audience entirely (decision 24), so a link
396
+ * to `secret/plan.summary.md` is a link to `secret/plan` under another name;
397
+ * refusing the second while publishing the first was one branch's worth of
398
+ * difference in a public build (found live: the id and the directory name both
399
+ * reached the page HTML, the `/md/` twin and `llms-full.txt`).
400
+ *
401
+ * Everything else declares no audience, so it inherits one by POSITION — the
402
+ * directory it names or sits in. If every concept under that directory is out
403
+ * of the linking document's reach, then linking it publishes the directory's
404
+ * NAME (and, for an asset, its bytes) into a build that excludes everything
405
+ * else in it. Reproduced: a public policy with `![chart](/secret/org-chart.png)`
406
+ * put `secret/org-chart.png` in the public `out/`, past a sweep that asserts no
407
+ * byte of `secret/` appears.
408
+ *
409
+ * A directory holding NO concept says nothing about audience — an `images/`
410
+ * folder is shared furniture — so the question is passed UP to the nearest
411
+ * ancestor that does hold one. Found live: checking only the immediate
412
+ * directory was defeated by nesting the asset one level deeper
413
+ * (`secret/img/chart.svg`) — that directory holds no concept, so the rule said
414
+ * nothing and the public build carried `secret/img/` and its bytes. The walk
415
+ * stops below the bundle root, which stays furniture like `images/`: the root
416
+ * holds the linking document itself, so testing it could only ever pass.
417
+ */
418
+ function nonConceptWidens(
419
+ path: string,
420
+ audience: readonly string[],
421
+ id: string,
422
+ targets: LinkTargets,
423
+ refusals: Refusal[],
424
+ ): void {
425
+ // The bundle root is the linking document's own directory.
426
+ if (id === "") return;
427
+
428
+ const parentId = id.replace(COMPANION_TARGET, "");
429
+ const parent = parentId === id ? undefined : targets.concepts.get(parentId);
430
+ if (parent !== undefined) {
431
+ if (mayReach(audience, parent.audience)) return;
432
+ refusals.push({
433
+ slug: "ksor-link-widens",
434
+ path,
435
+ why: `links to \`${id}\`, which is the companion of \`${parentId}\` (audience [${parent.audience.join(", ")}]) and inherits its audience entirely, which not every reader of this document (audience [${audience.join(", ")}]) may read`,
436
+ fix: "widen the parent's audience, narrow this document's, or remove the link",
437
+ });
438
+ return;
439
+ }
440
+
441
+ // Where the target sits: a directory (or its generated index) IS its own
442
+ // position; anything else takes the directory it lives in.
443
+ const asset = targets.assets.has(id);
444
+ const dirOfId = id.slice(0, Math.max(id.lastIndexOf("/"), 0));
445
+ const named = targets.directories.has(id);
446
+ const indexDir = id.endsWith("/index") ? id.slice(0, -"/index".length) : null;
447
+ const start = named ? id : (indexDir ?? dirOfId);
448
+ const what = named
449
+ ? `the directory \`${id}/\``
450
+ : indexDir !== null
451
+ ? `the generated index of \`${indexDir}/\``
452
+ : asset
453
+ ? `the asset \`${id}\``
454
+ : `the file \`${id}\``;
455
+ const carries = asset ? "that directory's name and its bytes" : "that directory's name";
456
+
457
+ const concepts = [...targets.concepts.values()];
458
+ for (let dir = start; dir !== ""; dir = dir.slice(0, Math.max(dir.lastIndexOf("/"), 0))) {
459
+ const inside = concepts.filter((c) => c.id === dir || c.id.startsWith(`${dir}/`));
460
+ if (inside.length === 0) continue;
461
+ if (inside.some((c) => mayReach(audience, c.audience))) return;
462
+ refusals.push({
463
+ slug: "ksor-link-widens",
464
+ path,
465
+ why: `links to ${what} — \`${dir}/\` holds ${inside.length} concept${inside.length === 1 ? "" : "s"} and not one this document's readers (audience [${audience.join(", ")}]) may read, so publishing it puts ${carries} in a build that excludes everything else in it`,
466
+ fix: `move it beside this document (or into a directory its readers may enter), or widen something under \`${dir}/\``,
467
+ });
468
+ return;
469
+ }
470
+ }
471
+
472
+ function checkSupersession(
473
+ concept: Concept,
474
+ concepts: ReadonlyMap<string, Concept>,
475
+ unreadable: ReadonlySet<string>,
476
+ refusals: Refusal[],
477
+ ): void {
478
+ if (concept.supersededBy === null) return;
479
+ if (concept.status !== "deprecated") {
480
+ // The key goes "with deprecated" (§2.2). On a live concept it announces a
481
+ // replacement no surface shows and no reader follows — the old checker
482
+ // refused it, and a silent acceptance would be a governance claim nothing
483
+ // enforces.
484
+ refusals.push({
485
+ slug: "ksor-supersession-strands",
486
+ path: concept.path,
487
+ why: `\`ksor.superseded_by: ${concept.supersededBy}\` on a \`${concept.status}\` concept — supersession is what \`deprecated\` means, so no surface will show this pointer and no reader will follow it`,
488
+ fix: "set `status: deprecated` with `ksor.deprecated: { by, at }`, or drop the pointer",
489
+ });
490
+ return;
491
+ }
492
+ // The successor's document is right there and merely unreadable, so "names
493
+ // no concept" would be false and its remedy — drop the pointer — throws away
494
+ // a correct one. Its own parse refusal is the error, and it is already
495
+ // pushed; the next run judges this pointer against a real successor.
496
+ if (unreadable.has(concept.supersededBy)) return;
497
+ const target = concepts.get(concept.supersededBy);
498
+ const reason =
499
+ target === undefined
500
+ ? "names no concept"
501
+ : target.status !== "stable"
502
+ ? `is \`${target.status}\`, not \`stable\``
503
+ : !mayReach(concept.audience, target.audience)
504
+ ? `has audience [${target.audience.join(", ")}], which not every reader of this document may read`
505
+ : null;
506
+ if (reason === null) return;
507
+ refusals.push({
508
+ slug: "ksor-supersession-strands",
509
+ path: concept.path,
510
+ why: `\`ksor.superseded_by: ${concept.supersededBy}\` ${reason} — a reader sent to the successor would be stranded`,
511
+ fix: "point at a stable successor every reader of this document may read, or drop the pointer",
512
+ });
513
+ }
514
+
515
+ function checkAgainstPolicy(concept: Concept, policy: Policy, refusals: Refusal[]): void {
516
+ for (const a of concept.audience) {
517
+ if (a === "public" || policy.audiences.includes(a)) continue;
518
+ refusals.push({
519
+ slug: "ksor-audience-unregistered",
520
+ path: concept.path,
521
+ why: `\`ksor.audience\` names \`${a}\`, which the policy's registry does not declare — an unknown identifier is a typo, and a typo reads as a restriction`,
522
+ fix: `use \`public\` or a registered audience (${policy.audiences.join(", ") || "none registered"}), or register it in \`.ksor/governance.yaml\``,
523
+ });
524
+ }
525
+ if (concept.approval !== null) {
526
+ const approvers = resolveApprovers(policy, concept.id, concept.type);
527
+ if (!approvers.ok) refusals.push(approvers.refusal);
528
+ else if (!approvers.actors.includes(concept.approval.by)) {
529
+ refusals.push({
530
+ slug: "ksor-approver-unauthorised",
531
+ path: concept.path,
532
+ why: `\`ksor.approval.by: ${concept.approval.by}\` is not in the approval authority set the policy resolves for this concept (${approvers.actors.join(", ")})`,
533
+ fix: "record an approval by an authorised actor, or extend the policy in a reviewed change",
534
+ });
535
+ }
536
+ }
537
+ if (concept.deprecated !== null) {
538
+ const resolved = resolveOwner(policy, concept.id, concept.type);
539
+ if (!resolved.ok) {
540
+ refusals.push(resolved.refusal);
541
+ return;
542
+ }
543
+ const by = concept.deprecated.by;
544
+ // The owner is whoever the POLICY resolves, and ONLY that. It used to fall
545
+ // back to `concept.owner` when no `ownership` rule matched — but
546
+ // `ksor.owner` is free text the DOCUMENT writes about itself (the profile
547
+ // does not even form-check it), so `ksor.owner: human:mallory` beside
548
+ // `ksor.deprecated.by: human:mallory` withdrew a document on nobody's
549
+ // authority but its own, in any record whose policy declares no
550
+ // `ownership:` at all — which is the default shape `ksor migrate` emits.
551
+ // Withdrawal is a governance act; an act a document attests for itself is
552
+ // not one. This is the same rule `resolveApprovers` has always enforced by
553
+ // REFUSING when no rule matches, and decision 21's — a slot that records
554
+ // WHO is never filled from ambient state, the document included.
555
+ if (by !== resolved.owner && !policy.takedownActors.includes(by)) {
556
+ const authorities = policy.takedownActors.join(", ");
557
+ refusals.push({
558
+ slug: "ksor-deprecator-unauthorised",
559
+ path: concept.path,
560
+ why:
561
+ resolved.owner === null
562
+ ? `\`ksor.deprecated.by: ${by}\` is not a takedown authority (${authorities}), and no \`ownership\` rule in \`${POLICY_PATH}\` binds this concept — so the record names no owner who could withdraw it. \`ksor.owner\` is not that owner: it is a string this document writes about itself`
563
+ : `\`ksor.deprecated.by: ${by}\` is neither the owner the policy resolves (${resolved.owner}) nor a takedown authority (${authorities})`,
564
+ fix:
565
+ resolved.owner === null
566
+ ? `record the deprecation by a takedown authority, or add an \`ownership:\` rule to \`${POLICY_PATH}\` naming who owns this path (R23)`
567
+ : "record the deprecation by the owner or a takedown authority (R23)",
568
+ });
569
+ }
570
+ }
571
+ }