@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,535 @@
1
+ /**
2
+ * The KSoR Profile of OKF, §2 of the record spec, as one schema: what a
3
+ * concept's frontmatter must say, and the refusals when it does not. Only
4
+ * shape and self-consistency live here; anything that needs the policy (the
5
+ * audience registry, who may approve or deprecate) is `policy.ts`'s, and
6
+ * anything that needs the rest of the tree (links, footnotes, supersession)
7
+ * is `check.ts`'s.
8
+ */
9
+ import { z } from "zod";
10
+
11
+ import { isIndividualActor } from "./actor";
12
+ import { parseInstant } from "./instant";
13
+ import { nearest } from "./near-miss";
14
+ import { sortRefusals, type Refusal, type RefusalSlug } from "./refusal";
15
+
16
+ export const RESERVED_TYPES = [
17
+ "Policy",
18
+ "Procedure",
19
+ "Control",
20
+ "Standard",
21
+ "Definition",
22
+ "Decision Record",
23
+ "Example",
24
+ "Attested Computation",
25
+ ] as const;
26
+
27
+ export const STATUSES = ["draft", "stable", "deprecated"] as const;
28
+ export type Status = (typeof STATUSES)[number];
29
+
30
+ /** Pre-profile keys whose silent survival would be silent loss of governance (§2.7). */
31
+ export const LEGACY_KEYS = [
32
+ "id",
33
+ "name",
34
+ "visibility",
35
+ "provenance",
36
+ "owner",
37
+ "effective",
38
+ "superseded",
39
+ // The pre-profile supersession pointer. It is NOT harmless to preserve as an
40
+ // unknown key (§2.7): the profile reads `ksor.superseded_by`, so a top-level
41
+ // one announces a successor no surface shows and no reader follows — the
42
+ // silent loss of governance this list exists to prevent.
43
+ "superseded_by",
44
+ "sor_id",
45
+ ] as const;
46
+
47
+ /**
48
+ * The keys the BUILD writes into a concept's markdown twin and its
49
+ * `llms-full.txt` block — the derived trust tier and the R14 stamps. They are
50
+ * appended under the record's own frontmatter, intact, so a concept that
51
+ * declares one publishes it TWICE: the twin then fails the record's own reader
52
+ * (`uniqueKeys: true` → `ksor-frontmatter-invalid`), and a lenient consumer
53
+ * picks one of the two, which makes the derived tier non-authoritative and the
54
+ * build stamp forgeable by whoever writes the document.
55
+ */
56
+ export const DERIVED_KEYS = [
57
+ "trust_tier",
58
+ "build_id",
59
+ "source_commit",
60
+ "ksor_version",
61
+ "dirty",
62
+ "unstamped",
63
+ ] as const;
64
+
65
+ export const TRUST_TIERS = ["unverified", "machine-confirmed", "human-reviewed"] as const;
66
+ export type TrustTier = (typeof TRUST_TIERS)[number];
67
+
68
+ const actor = z.custom<string>(isIndividualActor, { message: "actor" });
69
+ const instant = z.custom<string>((v) => parseInstant(v) !== null, { message: "instant" });
70
+ const act = z.object({ by: actor, at: instant });
71
+
72
+ const source = z.object({
73
+ resource: z.string().min(1, "resource"),
74
+ id: z.string().min(1).optional(),
75
+ title: z.string().optional(),
76
+ });
77
+
78
+ /**
79
+ * The `ksor:` block's keys, CLOSED. The namespace is ksor's own, not OKF's, so
80
+ * §11's preserve-unknown-keys rule does not reach it — and the keys that fail
81
+ * open here are the OPTIONAL ones, because a typo in a required key already
82
+ * surfaces as `ksor-missing-key`. `ksor.effective-from` (one hyphen) published
83
+ * an embargoed policy four weeks early with nothing red, and a mistyped
84
+ * `stale_after` serves a document that should have expired forever
85
+ * (reproduced 2026-08-25).
86
+ */
87
+ export const NAMESPACE_KEYS = [
88
+ "audience",
89
+ "owner",
90
+ "approval",
91
+ "effective_from",
92
+ "superseded_by",
93
+ "deprecated",
94
+ ] as const;
95
+
96
+ const ksorBlock = z.object({
97
+ audience: z.array(z.string().min(1)).min(1, "audience"),
98
+ owner: z.string().min(1).optional(),
99
+ approval: act.optional(),
100
+ effective_from: instant.optional(),
101
+ superseded_by: z.string().min(1).optional(),
102
+ deprecated: act.optional(),
103
+ });
104
+
105
+ const conceptSchema = z
106
+ .object({
107
+ type: z.string().min(1),
108
+ title: z.string().min(1),
109
+ description: z.string().min(1),
110
+ status: z.enum(STATUSES),
111
+ order: z.number().optional(),
112
+ generated: z.object({ by: actor, at: instant.optional() }).optional(),
113
+ sources: z.array(source).optional(),
114
+ // OKF §5.2: a consumer MUST accept a bare mapping as a one-element list.
115
+ verified: z.preprocess(
116
+ (v) => (typeof v === "object" && v !== null && !Array.isArray(v) ? [v] : v),
117
+ z.array(act).optional(),
118
+ ),
119
+ stale_after: instant.optional(),
120
+ ksor: ksorBlock,
121
+ })
122
+ .loose();
123
+
124
+ type Parsed = z.infer<typeof conceptSchema>;
125
+
126
+ export interface Concept {
127
+ /** Record-relative path: `knowledge/<id>.md`. */
128
+ readonly path: string;
129
+ /** OKF concept id: bundle-relative path without `.md`. */
130
+ readonly id: string;
131
+ readonly type: string;
132
+ readonly reserved: boolean;
133
+ readonly title: string;
134
+ readonly description: string;
135
+ readonly status: Status;
136
+ readonly order: number | null;
137
+ readonly audience: readonly string[];
138
+ readonly owner: string | null;
139
+ readonly generatedAt: number | null;
140
+ readonly approval: { readonly by: string; readonly at: number } | null;
141
+ readonly deprecated: { readonly by: string; readonly at: number } | null;
142
+ readonly verified: readonly { readonly by: string; readonly at: number }[];
143
+ readonly trustTier: TrustTier;
144
+ readonly effectiveFrom: number | null;
145
+ readonly staleAfter: number | null;
146
+ readonly supersededBy: string | null;
147
+ readonly sourceIds: readonly string[];
148
+ /** Everything, unknown keys included (OKF §11). */
149
+ readonly frontmatter: Readonly<Record<string, unknown>>;
150
+ }
151
+
152
+ export type ConceptResult =
153
+ | { readonly ok: true; readonly concept: Concept }
154
+ | { readonly ok: false; readonly refusals: readonly Refusal[] };
155
+
156
+ const FLOOR_KEYS = ["type", "title", "description", "status"] as const;
157
+
158
+ /** The keys §8 renders into one index bullet, so each is one line (see `parseConcept`). */
159
+ const ONE_LINE_KEYS = ["title", "description"] as const;
160
+
161
+ /**
162
+ * The profile's own top-level keys. The concept schema stays OPEN (OKF §11: a
163
+ * consumer preserves keys it does not know), so this list is not a closed set
164
+ * — it is the target of the near-miss net below.
165
+ */
166
+ const PROFILE_KEYS = [
167
+ "type",
168
+ "title",
169
+ "description",
170
+ "status",
171
+ "order",
172
+ "generated",
173
+ "sources",
174
+ "verified",
175
+ "stale_after",
176
+ "ksor",
177
+ ] as const;
178
+
179
+ export function conceptIdOf(path: string): string {
180
+ return path.replace(/^knowledge\//, "").replace(/\.md$/, "");
181
+ }
182
+
183
+ export function parseConcept(path: string, frontmatter: Record<string, unknown>): ConceptResult {
184
+ const refusals: Refusal[] = [];
185
+ // The dotted paths the rules below have already refused in the AUTHOR's own
186
+ // words, so the schema pass does not print the same defect a second time in
187
+ // zod's. A record of what was pushed, never an assumption that something
188
+ // was: assuming it is what made a wrong-typed floor key vanish in silence
189
+ // (see `suppressed`).
190
+ const named = new Set<string>();
191
+ const refuse = (slug: RefusalSlug, why: string, fix: string, ...at: string[]): void => {
192
+ refusals.push({ slug, path, why, fix });
193
+ for (const key of at) named.add(key);
194
+ };
195
+
196
+ for (const key of LEGACY_KEYS) {
197
+ if (key in frontmatter) {
198
+ refuse(
199
+ "ksor-legacy-key",
200
+ `\`${key}\` is a pre-profile key; the profile does not read it, so whatever it governed would be silently lost`,
201
+ "run `ksor migrate` to move it into the profile's shape, then delete it",
202
+ );
203
+ }
204
+ }
205
+ for (const key of DERIVED_KEYS) {
206
+ if (!(key in frontmatter)) continue;
207
+ refuse(
208
+ "ksor-derived-key",
209
+ `\`${key}\` is written by the BUILD, not by a document — the markdown twin and the \`llms-full.txt\` block append it under this frontmatter, so declaring it here publishes the key twice and the derived value stops being the authoritative one`,
210
+ `remove \`${key}:\` — the trust tier comes from \`verified\`, and the build stamps come from \`build.lock.json\``,
211
+ );
212
+ }
213
+ // A key of the `ksor:` block written at the concept's TOP level is the near
214
+ // miss without the miss: spelled right, so no edit-distance net can see it,
215
+ // and §11 therefore preserves it — published, read by nothing, enforcing
216
+ // nothing. `effective_from: 2099-01-01T00:00:00Z` here built clean, exited 0
217
+ // and admitted the document to every surface the same day; the identical
218
+ // instant under `ksor:` admitted it to none (reproduced 2026-08-25). Refused
219
+ // by NAME, because a name is all there is to go on. `owner` and
220
+ // `superseded_by` are skipped: they are pre-profile keys too, and
221
+ // `ksor-legacy-key` already names them with the migration that moves them.
222
+ const misplaced = new Set<string>();
223
+ for (const key of NAMESPACE_KEYS) {
224
+ if (!(key in frontmatter) || (LEGACY_KEYS as readonly string[]).includes(key)) continue;
225
+ misplaced.add(key);
226
+ refuse(
227
+ "ksor-key-misplaced",
228
+ `\`${key}\` is a key of the \`ksor:\` block, written at the concept's top level — the profile reads \`ksor.${key}\` and nothing reads this one, so §2.7 preserves it verbatim and whatever it was meant to govern is governed by nothing`,
229
+ `move it under \`ksor:\` as \`ksor.${key}\`, with the value it has here — the value is the guarantee, so it moves rather than goes`,
230
+ );
231
+ }
232
+ // OKF §11 keeps a key nobody knows — but a key ONE edit from a profile key is
233
+ // not an extension, it is the profile key failing open. `stale_afer:` never
234
+ // expires; `titel:` renders no title. Refusing beats preserving here.
235
+ for (const key of Object.keys(frontmatter)) {
236
+ if (misplaced.has(key)) continue;
237
+ const near = nearest(key, PROFILE_KEYS, 1);
238
+ if (near !== null) {
239
+ refuse(
240
+ "ksor-key-near-miss",
241
+ `\`${key}\` is one edit from \`${near}\`, the profile key it is almost certainly meant to be — unknown keys are preserved (§2.7), so a near miss would be kept and the governance it carried would simply stop existing`,
242
+ `rename it to \`${near}:\`, or — if it really is an extension key of your own — give it a name no profile key is one edit from`,
243
+ );
244
+ continue;
245
+ }
246
+ // Both mistakes at once, which is neither net's alone: `efective_from` at
247
+ // the top level is a `ksor:` key misspelled AND one level out, and it is
248
+ // near nothing the top level reads.
249
+ const nearKsor = nearest(key, NAMESPACE_KEYS, 1);
250
+ if (nearKsor === null) continue;
251
+ refuse(
252
+ "ksor-key-misplaced",
253
+ `\`${key}\` is one edit from \`${nearKsor}\`, a key of the \`ksor:\` block — so it is misspelled AND one level out from where the profile reads it, and §2.7 would preserve it as an extension key that governs nothing`,
254
+ `write it under \`ksor:\` as \`ksor.${nearKsor}\`, keeping the value; if it really is an extension key of your own, give it a name no governance key is one edit from`,
255
+ );
256
+ }
257
+ for (const key of FLOOR_KEYS) {
258
+ if (!(key in frontmatter)) {
259
+ refuse(
260
+ "ksor-missing-key",
261
+ `\`${key}\` is required on every concept`,
262
+ `add \`${key}:\` to the frontmatter`,
263
+ key,
264
+ );
265
+ continue;
266
+ }
267
+ // A floor key that is PRESENT but carries no text is absent for every
268
+ // purpose it serves — `title: 42` renders no title, `description: ""`
269
+ // writes an empty §8 bullet, `status: 5` names no lifecycle — and YAML
270
+ // makes it one character wide: quotes lost off a numeric title, a key
271
+ // typed with nothing after it. It reaches this loop as a key that exists,
272
+ // so the branch above says nothing about it, and until it was named here
273
+ // the schema pass below swallowed it as a duplicate of a refusal nobody
274
+ // had made (found in review, 2026-08-25).
275
+ const value = frontmatter[key];
276
+ if (typeof value !== "string" || value.trim() === "") {
277
+ refuse(
278
+ "ksor-missing-key",
279
+ `\`${key}\` is present but carries no text — it is ${shapeOf(value)}, and a floor key with nothing readable in it governs nothing`,
280
+ `write \`${key}\` as a non-empty line of text; quote it where YAML would otherwise read it as a number, a date or a boolean (\`${key}: "2026"\`)`,
281
+ key,
282
+ );
283
+ }
284
+ }
285
+ // YAML's core schema resolves `.inf`, `-.inf`, `.nan` — and an exponent that
286
+ // overflows, `1e400` — to real numbers, so `order:` can hold a value that is
287
+ // not a position. zod refuses a non-finite number already; what it says is
288
+ // "expected number, received number", which names no file and no remedy.
289
+ const order = frontmatter["order"];
290
+ if (typeof order === "number" && !Number.isFinite(order)) {
291
+ refuse(
292
+ "ksor-frontmatter-invalid",
293
+ `\`order\` is ${Number.isNaN(order) ? "not a number" : `${order > 0 ? "" : "negative "}infinity`} — an order is a finite number, because it is a position among siblings; YAML reads \`.inf\`, \`-.inf\`, \`.nan\` and an overflowing \`1e400\` as real numbers, and the two surfaces would not agree where to file one (the index generator would sort \`-.inf\` first, the door's \`orderValue\` sorts every non-finite value last — decision 18)`,
294
+ "give `order` a finite number, or remove it — a concept that declares no `order` sorts after every concept that does",
295
+ "order",
296
+ );
297
+ }
298
+ // §8 renders `title` and `description` into ONE index bullet, so a line break
299
+ // inside either does not render badly — it makes the bullet unparseable and
300
+ // the concept simply LEAVES the index, the sidebar and the reading order,
301
+ // while keeping its route and staying served by the door (found live,
302
+ // 2026-08-25). Nothing goes red: the generator and the parser are two halves
303
+ // of one format and agree on the broken output, so `ksor-index-stale` still
304
+ // passes. A trailing break is the same defect wearing a YAML scalar style —
305
+ // `>` folds onto one line and keeps the newline, which empties the
306
+ // description in the bullet and nowhere else. Refused here, at the one place
307
+ // both surfaces read, rather than repaired per surface (decision 18).
308
+ for (const key of ONE_LINE_KEYS) {
309
+ const value = frontmatter[key];
310
+ if (typeof value !== "string" || !/[\r\n]/.test(value)) continue;
311
+ refuse(
312
+ "ksor-one-line-form",
313
+ `\`${key}\` contains a line break — it is one line, because the §8 index renders it into a single bullet; a break makes that bullet unreadable and the concept disappears from the index, the sidebar and the reading order while its page stays published`,
314
+ `write \`${key}\` on one line — for a long one, a folded scalar that strips its trailing break (\`${key}: >-\`) keeps the file readable and stays one line`,
315
+ );
316
+ }
317
+
318
+ const ksor = frontmatter["ksor"];
319
+ const audience =
320
+ typeof ksor === "object" && ksor !== null && !Array.isArray(ksor)
321
+ ? (ksor as Record<string, unknown>)["audience"]
322
+ : undefined;
323
+ if (!Array.isArray(audience) || audience.length === 0) {
324
+ refuse(
325
+ "ksor-audience-missing",
326
+ "`ksor.audience` is required and is a non-empty list — omission is refused, never defaulted (record spec §2.4)",
327
+ "add these two lines to the frontmatter, naming the audiences who may read this:\nksor:\n audience: [public]",
328
+ "ksor",
329
+ "ksor.audience",
330
+ );
331
+ }
332
+ if (typeof ksor === "object" && ksor !== null && !Array.isArray(ksor)) {
333
+ for (const key of Object.keys(ksor as Record<string, unknown>)) {
334
+ if ((NAMESPACE_KEYS as readonly string[]).includes(key)) continue;
335
+ // The mirror of the rule above, and the half that printed a DESTRUCTIVE
336
+ // remedy: `ksor.stale_after` was refused as a key of a closed block and
337
+ // told the author to remove it, which on a document already past that
338
+ // instant flipped `admitted: []` to `admitted: ["public"]` — the fix
339
+ // line published what the author had withdrawn (reproduced 2026-08-25).
340
+ if (key !== "ksor" && (PROFILE_KEYS as readonly string[]).includes(key)) {
341
+ refuse(
342
+ "ksor-key-misplaced",
343
+ `\`ksor.${key}\` is \`${key}\`, a TOP-LEVEL key of the profile, written inside the \`ksor:\` block — the block's key set is closed, so nothing reads it here: a \`ksor.stale_after\` expires nothing and a \`ksor.verified\` earns no trust tier`,
344
+ `move \`${key}:\` out of the \`ksor:\` block to the concept's top level, with the value it has here — the value is the guarantee, so it moves rather than goes`,
345
+ );
346
+ continue;
347
+ }
348
+ const near = nearest(key, NAMESPACE_KEYS, 2);
349
+ refuse(
350
+ "ksor-ksor-key-unknown",
351
+ `\`ksor.${key}\` is not a key of the \`ksor:\` block — the block is ksor's own namespace and its key set is closed, so a key it does not read is a guarantee that stops existing rather than one the record announces`,
352
+ near === null
353
+ ? `an extension key of your own belongs at the concept's top level, where §2.7 PRESERVES it — move \`${key}:\` out of the block rather than deleting it (allowed under \`ksor:\`: ${NAMESPACE_KEYS.join(", ")})`
354
+ : `did you mean \`${near}:\`? (allowed under \`ksor:\`: ${NAMESPACE_KEYS.join(", ")})`,
355
+ );
356
+ }
357
+ }
358
+ if (
359
+ typeof frontmatter["status"] === "string" &&
360
+ !STATUSES.includes(frontmatter["status"] as Status)
361
+ ) {
362
+ refuse(
363
+ "ksor-status-unknown",
364
+ `\`status: ${frontmatter["status"]}\` is not one of ${STATUSES.join(" | ")}`,
365
+ "`draft` while it is being written, `stable` once approved, `deprecated` with its successor",
366
+ "status",
367
+ );
368
+ }
369
+
370
+ const parsed = conceptSchema.safeParse(frontmatter);
371
+ if (!parsed.success) {
372
+ for (const issue of parsed.error.issues) {
373
+ const at = issue.path.map(String).join(".");
374
+ if (issue.message === "actor") {
375
+ refuse(
376
+ "ksor-actor-form",
377
+ `\`${at}\` is not an actor — \`human:<id>\`, \`process:<id>\` or \`<producer>/<version>\`; \`team:\` is allowed only in \`ksor.owner\`, because trust tiers key on the \`human:\` prefix`,
378
+ "name the person or process that performed the act",
379
+ );
380
+ } else if (issue.message === "instant") {
381
+ refuse(
382
+ "ksor-instant-form",
383
+ `\`${at}\` is not an ISO 8601 instant with an explicit offset (e.g. 2026-08-20T09:00:00Z)`,
384
+ "write the full instant; `ksor migrate` widens a bare date to midnight UTC",
385
+ );
386
+ } else if (
387
+ issue.message === "resource" ||
388
+ (at.startsWith("sources.") && at.endsWith(".resource"))
389
+ ) {
390
+ refuse(
391
+ "ksor-source-unresourced",
392
+ `\`${at}\` is required — a URL, a bundle path, or a scope descriptor (OKF §5.1)`,
393
+ "name where the source lives; a scope descriptor is allowed until a URL exists",
394
+ );
395
+ } else if (suppressed(at, named)) {
396
+ // The floor, status, order and audience refusals above are the author-facing form of the same issue.
397
+ } else {
398
+ refuse(
399
+ "ksor-frontmatter-invalid",
400
+ `\`${at || "(root)"}\`: ${issue.message}`,
401
+ "check the key's shape against the profile (record spec §2)",
402
+ );
403
+ }
404
+ }
405
+ return refused(path, refusals);
406
+ }
407
+
408
+ const fm = parsed.data;
409
+ const reserved = (RESERVED_TYPES as readonly string[]).includes(fm.type);
410
+ if (reserved && (fm.sources === undefined || fm.sources.length === 0)) {
411
+ refuse(
412
+ "ksor-reserved-type-unsourced",
413
+ `\`type: ${fm.type}\` is a reserved type, which carries governance meaning and therefore needs \`sources\``,
414
+ "list where this knowledge comes from, or use a non-reserved type such as `Document`",
415
+ );
416
+ }
417
+ if (reserved && fm.ksor.owner === undefined) {
418
+ refuse(
419
+ "ksor-reserved-type-unowned",
420
+ `\`type: ${fm.type}\` is a reserved type and needs \`ksor.owner\``,
421
+ "name the owning team or person, or use a non-reserved type such as `Document`",
422
+ );
423
+ }
424
+ const generatedAt = fm.generated?.at === undefined ? null : parseInstant(fm.generated.at);
425
+ if (fm.status === "stable") {
426
+ if (generatedAt === null) {
427
+ refuse(
428
+ "ksor-stable-ungenerated",
429
+ "a `stable` concept must carry `generated: { by, at }` — when and by what it was produced",
430
+ "add `generated`, or keep `status: draft`",
431
+ );
432
+ }
433
+ if (fm.ksor.approval === undefined) {
434
+ refuse(
435
+ "ksor-stable-unapproved",
436
+ "a `stable` concept must carry `ksor.approval: { by, at }` — the authority decision to publish",
437
+ "record the approval an authorised actor gave, or keep `status: draft`",
438
+ );
439
+ } else if (generatedAt !== null && generatedAt > parseInstant(fm.ksor.approval.at)!) {
440
+ refuse(
441
+ "ksor-generated-after-approval",
442
+ "`generated.at` is after `ksor.approval.at` — the approved text is not the text that was generated (R23)",
443
+ "re-approve in the same reviewed change, or fall back to `status: draft`",
444
+ );
445
+ }
446
+ }
447
+ if (fm.status === "deprecated" && fm.ksor.deprecated === undefined) {
448
+ refuse(
449
+ "ksor-deprecated-unattributed",
450
+ "a `deprecated` concept must carry `ksor.deprecated: { by, at }` — who withdrew it",
451
+ "record the deprecation by the owner or a takedown authority, usually with `ksor.superseded_by`",
452
+ );
453
+ }
454
+ if (refusals.length > 0) return refused(path, refusals);
455
+
456
+ return { ok: true, concept: toConcept(path, fm, frontmatter, reserved, generatedAt) };
457
+ }
458
+
459
+ /**
460
+ * Every exit that says `ok: false`. A refusal-free failure is not a state a
461
+ * document can be in: `check.ts` spreads the list, marks the concept unreadable
462
+ * and moves on, so an empty one drops a governed document — no page, no MCP
463
+ * node, no lock entry — and the build still exits 0. The fallback has to say
464
+ * something an author can act on even though the fault is ours, and it is here
465
+ * so that a rule added above cannot reopen the hole `suppressed` closed.
466
+ */
467
+ function refused(path: string, refusals: Refusal[]): ConceptResult {
468
+ if (refusals.length === 0) {
469
+ refusals.push({
470
+ slug: "ksor-frontmatter-invalid",
471
+ path,
472
+ why: "this frontmatter is not a concept the profile can read, and the rule that rejected it did not say which key was at fault — the fault is ksor's, not the document's",
473
+ fix: "check the frontmatter against the profile (record spec §2), and report the file that produced this — a refusal with nothing to print is a bug",
474
+ });
475
+ }
476
+ return { ok: false, refusals: sortRefusals(refusals) };
477
+ }
478
+
479
+ /** Was `at` already refused in the author's own words? Never a guess — see `named`. */
480
+ function suppressed(at: string, named: ReadonlySet<string>): boolean {
481
+ // A refusal naming `ksor.audience` covers every issue raised inside the list.
482
+ return named.has(at) || (at.startsWith("ksor.audience.") && named.has("ksor.audience"));
483
+ }
484
+
485
+ /** What an unusable floor value IS, in an author's words rather than `typeof`'s. */
486
+ function shapeOf(value: unknown): string {
487
+ if (value === null || value === undefined) return "empty";
488
+ if (typeof value === "string") return "blank";
489
+ if (Array.isArray(value)) return "a list";
490
+ if (typeof value === "object") return "a mapping";
491
+ return `a ${typeof value}`;
492
+ }
493
+
494
+ function toConcept(
495
+ path: string,
496
+ fm: Parsed,
497
+ frontmatter: Record<string, unknown>,
498
+ reserved: boolean,
499
+ generatedAt: number | null,
500
+ ): Concept {
501
+ const verified = (fm.verified ?? []).map((v) => ({ by: v.by, at: parseInstant(v.at)! }));
502
+ const trustTier: TrustTier =
503
+ verified.length === 0
504
+ ? "unverified"
505
+ : verified.some((v) => v.by.startsWith("human:"))
506
+ ? "human-reviewed"
507
+ : "machine-confirmed";
508
+ return {
509
+ path,
510
+ id: conceptIdOf(path),
511
+ type: fm.type,
512
+ reserved,
513
+ title: fm.title,
514
+ description: fm.description,
515
+ status: fm.status,
516
+ order: fm.order ?? null,
517
+ audience: fm.ksor.audience,
518
+ owner: fm.ksor.owner ?? null,
519
+ generatedAt,
520
+ approval: fm.ksor.approval
521
+ ? { by: fm.ksor.approval.by, at: parseInstant(fm.ksor.approval.at)! }
522
+ : null,
523
+ deprecated: fm.ksor.deprecated
524
+ ? { by: fm.ksor.deprecated.by, at: parseInstant(fm.ksor.deprecated.at)! }
525
+ : null,
526
+ verified,
527
+ trustTier,
528
+ effectiveFrom:
529
+ fm.ksor.effective_from === undefined ? null : parseInstant(fm.ksor.effective_from),
530
+ staleAfter: fm.stale_after === undefined ? null : parseInstant(fm.stale_after),
531
+ supersededBy: fm.ksor.superseded_by ?? null,
532
+ sourceIds: (fm.sources ?? []).flatMap((s) => (s.id === undefined ? [] : [s.id])),
533
+ frontmatter,
534
+ };
535
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The shape every rule in the record module speaks: what is wrong, where, why
3
+ * the rule exists, and how to fix it — product principle 4 ("errors are
4
+ * documentation"). `slug` is the stable machine-readable name the CLI prints
5
+ * on its first stderr line; the set is enumerated in record spec §6 and
6
+ * `refusal-slugs.integration.test.ts` fails when the two lists differ.
7
+ */
8
+ export const REFUSAL_SLUGS = [
9
+ "ksor-frontmatter-invalid",
10
+ "ksor-missing-key",
11
+ "ksor-status-unknown",
12
+ "ksor-one-line-form",
13
+ "ksor-audience-missing",
14
+ "ksor-audience-unregistered",
15
+ "ksor-stable-ungenerated",
16
+ "ksor-stable-unapproved",
17
+ "ksor-approver-unauthorised",
18
+ "ksor-generated-after-approval",
19
+ "ksor-deprecated-unattributed",
20
+ "ksor-deprecator-unauthorised",
21
+ "ksor-reserved-type-unsourced",
22
+ "ksor-reserved-type-unowned",
23
+ "ksor-source-unresourced",
24
+ "ksor-actor-form",
25
+ "ksor-instant-form",
26
+ "ksor-footnote-unkeyed",
27
+ "ksor-reserved-name",
28
+ "ksor-index-stale",
29
+ "ksor-attachment-frontmatter",
30
+ "ksor-attachment-orphan",
31
+ "ksor-attachment-of-index",
32
+ "ksor-link-widens",
33
+ "ksor-supersession-strands",
34
+ "ksor-takedown-unauthorised",
35
+ "ksor-takedown-dangling",
36
+ "ksor-takedown-readded",
37
+ "ksor-ledger-shrank",
38
+ "ksor-ledger-amended",
39
+ "ksor-ledger-invalid",
40
+ "ksor-ledger-empty",
41
+ "ksor-policy-missing",
42
+ "ksor-policy-invalid",
43
+ "ksor-legacy-key",
44
+ "ksor-ksor-key-unknown",
45
+ "ksor-key-near-miss",
46
+ "ksor-key-misplaced",
47
+ "ksor-derived-key",
48
+ "ksor-instance-format",
49
+ "ksor-migrate-underivable",
50
+ // Hygiene (record spec §6, ported from the scaffold's hand-written checker).
51
+ "ksor-record-empty",
52
+ "ksor-symlink",
53
+ "ksor-name-unportable",
54
+ "ksor-name-collides",
55
+ "ksor-file-type",
56
+ "ksor-asset-corrupt",
57
+ "ksor-attachment-near-miss",
58
+ "ksor-link-dead",
59
+ "ksor-link-escapes",
60
+ // The project around the record (`pnpm check` only).
61
+ "ksor-pointer-changed",
62
+ "ksor-skill-copy-diverged",
63
+ "ksor-site-holds-content",
64
+ ] as const;
65
+
66
+ export type RefusalSlug = (typeof REFUSAL_SLUGS)[number];
67
+
68
+ export interface Refusal {
69
+ readonly slug: RefusalSlug;
70
+ /** Record-relative path (`knowledge/x.md`, `.ksor/governance.yaml`, `instance.md`). */
71
+ readonly path: string;
72
+ readonly why: string;
73
+ readonly fix: string;
74
+ }
75
+
76
+ /** Sorted by path, then slug, then why — so two runs print one order. */
77
+ export function sortRefusals(refusals: readonly Refusal[]): Refusal[] {
78
+ return [...refusals].sort(
79
+ (a, b) => compare(a.path, b.path) || compare(a.slug, b.slug) || compare(a.why, b.why),
80
+ );
81
+ }
82
+
83
+ function compare(a: string, b: string): number {
84
+ return a < b ? -1 : a > b ? 1 : 0;
85
+ }
86
+
87
+ /**
88
+ * The line format the scaffold's checker has always printed — where, what,
89
+ * why, fix — kept byte-stable because adopters' CI logs and skills read it.
90
+ * `problem:` carries the slug so a reader can grep for the rule.
91
+ */
92
+ export function formatRefusal(r: Refusal): string {
93
+ // A remedy is OBEYED, not read — the scaffold's own skill tells the adopter to
94
+ // "obey the printed fix literally", so a `fix:` line is a paste target. A
95
+ // remedy that needs more than one line therefore has to ARRIVE as more than
96
+ // one line: two of them printed the characters `\` and `n` where they meant a
97
+ // break, and obeying either wrote a one-line file that the same rule refused
98
+ // again with the same remedy — a loop whose only advice was the thing that
99
+ // made it (first-hour walkthrough, 2026-08-26).
100
+ //
101
+ // Continuations are indented under the value column so the paste is
102
+ // unambiguous about where the remedy starts and stops. A single-line remedy
103
+ // is byte-identical to what it was, because adopters' CI logs read this shape.
104
+ const fix = r.fix.split("\n").join(`\n${" ".repeat(" fix: ".length)}`);
105
+ return `${r.path}\n problem: ${r.slug}\n why: ${r.why}\n fix: ${fix}`;
106
+ }