@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,694 @@
1
+ /**
2
+ * The takedown ledger, `.ksor/takedowns.yaml` (record spec §5): an
3
+ * append-only list of denials, revocations and amendments, in file order.
4
+ * Only `ksor takedown` writes it, and that is enforced by validation rather
5
+ * than assumed — an entry the record has not yet accepted has its actor
6
+ * checked against the policy's takedown authorities here, so a line
7
+ * hand-appended in a pull request is refused exactly as the verb would refuse
8
+ * it, and every entry's TEXT is
9
+ * checked against the versions history and the committed lock recorded, so a
10
+ * line hand-EDITED is refused too. An entry is only ever superseded by a
11
+ * revocation or an amendment appended after it.
12
+ */
13
+ import { createHash, randomBytes } from "node:crypto";
14
+
15
+ import { z } from "zod";
16
+
17
+ import { isIndividualActor } from "./actor";
18
+ import { parseInstant } from "./instant";
19
+ import type { Refusal } from "./refusal";
20
+ import { parseYamlFile } from "./yaml-file";
21
+
22
+ const SLUG = "ksor-ledger-invalid";
23
+
24
+ const actor = z.custom<string>(isIndividualActor, "not an actor (`human:<id>` or `process:<id>`)");
25
+ const instant = z.custom<string>(
26
+ (v) => parseInstant(v) !== null,
27
+ "not an ISO 8601 instant with an explicit offset",
28
+ );
29
+ const base = { id: z.string().min(1), by: actor, at: instant, reason: z.string().optional() };
30
+
31
+ const denial = z.object({
32
+ ...base,
33
+ stable_id: z.string().min(1),
34
+ scope: z.enum(["node", "subtree"]),
35
+ expected: z.enum(["present", "removed"]),
36
+ });
37
+ const revocation = z.object({ ...base, revokes: z.string().min(1) });
38
+ const amendment = z.object({ ...base, amends: z.string().min(1), expected: z.literal("removed") });
39
+
40
+ export type Scope = "node" | "subtree";
41
+ export type Expected = "present" | "removed";
42
+
43
+ interface Common {
44
+ readonly id: string;
45
+ readonly by: string;
46
+ readonly at: string;
47
+ readonly reason: string | null;
48
+ }
49
+ export interface Denial extends Common {
50
+ readonly kind: "denial";
51
+ readonly stableId: string;
52
+ readonly scope: Scope;
53
+ readonly expected: Expected;
54
+ }
55
+ export interface Revocation extends Common {
56
+ readonly kind: "revocation";
57
+ readonly revokes: string;
58
+ }
59
+ export interface Amendment extends Common {
60
+ readonly kind: "amendment";
61
+ readonly amends: string;
62
+ }
63
+ export type LedgerEntry = Denial | Revocation | Amendment;
64
+
65
+ export interface Ledger {
66
+ readonly entries: readonly LedgerEntry[];
67
+ /** Every id, in file order — the set `ksor-ledger-shrank` compares. */
68
+ readonly ids: readonly string[];
69
+ }
70
+
71
+ export type LedgerResult =
72
+ | { readonly ok: true; readonly ledger: Ledger }
73
+ | { readonly ok: false; readonly refusals: readonly Refusal[] };
74
+
75
+ const FIX =
76
+ "the ledger is written by `ksor takedown`; do not edit it by hand — revert the edit and run the verb";
77
+
78
+ /**
79
+ * `text` is null when the file does not exist: an empty ledger, and the honest
80
+ * way to write "this record has never withdrawn anything".
81
+ *
82
+ * A file that EXISTS and holds nothing is a different claim, and it is one no
83
+ * writer makes: the verb writes the header and the first entry in the same
84
+ * call, so there is no moment at which a real ledger is empty. What produces
85
+ * one is a write that was interrupted, and reading it as "no denials" is how
86
+ * that becomes permanent — the reader believes the record has withdrawn
87
+ * nothing, republishes everything it withdrew, and the next write makes the
88
+ * belief true on disk. Both halves measured (2026-08-25): a `writeFileSync` of
89
+ * a 7 KB ledger left the file at zero bytes for 3.3% of reads under sustained
90
+ * contention, and a sampler watching REAL `ksor takedown` runs was handed the
91
+ * empty file once in 5,177 reads — and the verb, handed exactly that state,
92
+ * wrote a ONE-entry ledger over forty and exited 0. So an empty read is a
93
+ * refusal: the one moment at which the entries are still recoverable.
94
+ */
95
+ export function parseLedger(text: string | null, path: string): LedgerResult {
96
+ if (text === null) return { ok: true, ledger: { entries: [], ids: [] } };
97
+ const refuse = (why: string): LedgerResult => ({
98
+ ok: false,
99
+ refusals: [{ slug: SLUG, path, why, fix: FIX }],
100
+ });
101
+ if (text.trim() === "") {
102
+ return {
103
+ ok: false,
104
+ refusals: [
105
+ {
106
+ slug: "ksor-ledger-empty",
107
+ path,
108
+ why:
109
+ "the file exists and holds nothing. `ksor takedown` writes the header and an entry " +
110
+ "together, so an empty ledger is not a record that has withdrawn nothing — it is one " +
111
+ "whose withdrawals were lost, and reading it as `no denials` republishes every " +
112
+ "document they took down",
113
+ fix:
114
+ "restore the file from version control — it is committed, and every entry it ever " +
115
+ "held is in its history; if this record has genuinely never withdrawn anything, " +
116
+ "delete the file, because ABSENCE is how that is written",
117
+ },
118
+ ],
119
+ };
120
+ }
121
+
122
+ // The file is a list at its root; the shared reader wants a mapping, so wrap it.
123
+ const loaded = parseYamlFile(`entries:\n${indent(text)}`, path, SLUG);
124
+ if (!loaded.ok) return loaded;
125
+ const raw = loaded.value["entries"];
126
+ if (raw === null || raw === undefined) return { ok: true, ledger: { entries: [], ids: [] } };
127
+ if (!Array.isArray(raw)) return refuse("the ledger is a list of entries; the root is not a list");
128
+
129
+ const entries: LedgerEntry[] = [];
130
+ const seen = new Map<string, LedgerEntry>();
131
+ for (const [i, item] of raw.entries()) {
132
+ const parsed = parseEntry(item);
133
+ if (typeof parsed === "string") return refuse(`entry ${i + 1}: ${parsed}`);
134
+ if (seen.has(parsed.id)) return refuse(`entry ${i + 1}: id \`${parsed.id}\` is already used`);
135
+ if (parsed.kind === "revocation" || parsed.kind === "amendment") {
136
+ const ref = parsed.kind === "revocation" ? parsed.revokes : parsed.amends;
137
+ const target = seen.get(ref);
138
+ if (target === undefined) {
139
+ return refuse(
140
+ `entry ${i + 1}: \`${parsed.id}\` names \`${ref}\`, which is no earlier entry`,
141
+ );
142
+ }
143
+ if (target.kind !== "denial") {
144
+ return refuse(
145
+ `entry ${i + 1}: \`${parsed.id}\` names \`${ref}\`, which is a ${target.kind} — only a denial can be revoked or amended`,
146
+ );
147
+ }
148
+ }
149
+ seen.set(parsed.id, parsed);
150
+ entries.push(parsed);
151
+ }
152
+ return { ok: true, ledger: { entries, ids: entries.map((e) => e.id) } };
153
+ }
154
+
155
+ function indent(text: string): string {
156
+ return text
157
+ .replace(/^\uFEFF/, "")
158
+ .replace(/\r\n?/g, "\n")
159
+ .split("\n")
160
+ .map((line) => (line === "" ? line : ` ${line}`))
161
+ .join("\n");
162
+ }
163
+
164
+ /**
165
+ * The key that says which ACT an entry is, and the closed key set that act
166
+ * reads — the policy's discipline (`POLICY_KEYS`) applied to the ledger, and
167
+ * for the same reason.
168
+ *
169
+ * Dispatching on the first key that was PRESENT and letting zod strip the rest
170
+ * read an entry carrying both `stable_id` and `revokes` as a denial and dropped
171
+ * the revocation: the entry it named stayed in force, and no surface said so.
172
+ * An entry is one act, so two act keys is a refusal rather than a precedence
173
+ * rule — the ledger cannot guess which of the two the operator meant, and
174
+ * guessing is what let the other one vanish. An unknown key is the same
175
+ * silence one step out: a `scope:` on a revocation is a constraint its author
176
+ * believes is in force and no reader ever applies.
177
+ */
178
+ const ENTRY_KINDS = [
179
+ {
180
+ act: "stable_id",
181
+ kind: "denial",
182
+ keys: ["id", "by", "at", "reason", "stable_id", "scope", "expected"],
183
+ },
184
+ { act: "revokes", kind: "revocation", keys: ["id", "by", "at", "reason", "revokes"] },
185
+ { act: "amends", kind: "amendment", keys: ["id", "by", "at", "reason", "amends", "expected"] },
186
+ ] as const;
187
+
188
+ function parseEntry(item: unknown): LedgerEntry | string {
189
+ if (typeof item !== "object" || item === null || Array.isArray(item)) return "not a mapping";
190
+ const keys = item as Record<string, unknown>;
191
+ const declared = ENTRY_KINDS.filter((entry) => entry.act in keys);
192
+ if (declared.length > 1) {
193
+ const acts = declared.map((entry) => entry.act).join("` and `");
194
+ const kinds = declared.map((entry) => entry.kind).join(" and a ");
195
+ return `declares \`${acts}\`, so it is both a ${kinds} — an entry is exactly one act, and reading it as one of the two drops the other silently`;
196
+ }
197
+ const only = declared[0];
198
+ if (only !== undefined) {
199
+ const unknown = Object.keys(keys).filter((key) => !only.keys.some((k) => k === key));
200
+ if (unknown.length > 0) {
201
+ return `declares an unknown key: \`${unknown.join("`, `")}\` — a ${only.kind} reads \`${only.keys.join("`, `")}\`, and a key it does not read is a constraint that is not in force`;
202
+ }
203
+ }
204
+ if ("stable_id" in keys) {
205
+ const r = denial.safeParse(item);
206
+ if (!r.success) return issueText(r.error);
207
+ const anchored = r.data.stable_id.endsWith("#section");
208
+ if (!r.data.stable_id.startsWith("knowledge/")) {
209
+ return `\`stable_id\` is \`knowledge/<id>\` (or \`knowledge/<dir>#section\` for a subtree), got \`${r.data.stable_id}\``;
210
+ }
211
+ if (r.data.scope === "subtree" && !anchored) {
212
+ return `a subtree denial names a directory's \`#section\` anchor, got \`${r.data.stable_id}\``;
213
+ }
214
+ if (r.data.scope === "node" && anchored) {
215
+ return `a node denial names a concept, not a \`#section\` anchor (\`${r.data.stable_id}\`) — use \`scope: subtree\``;
216
+ }
217
+ return {
218
+ kind: "denial",
219
+ id: r.data.id,
220
+ by: r.data.by,
221
+ at: r.data.at,
222
+ reason: r.data.reason ?? null,
223
+ stableId: r.data.stable_id,
224
+ scope: r.data.scope,
225
+ expected: r.data.expected,
226
+ };
227
+ }
228
+ if ("revokes" in keys) {
229
+ const r = revocation.safeParse(item);
230
+ if (!r.success) return issueText(r.error);
231
+ return {
232
+ kind: "revocation",
233
+ id: r.data.id,
234
+ by: r.data.by,
235
+ at: r.data.at,
236
+ reason: r.data.reason ?? null,
237
+ revokes: r.data.revokes,
238
+ };
239
+ }
240
+ if ("amends" in keys) {
241
+ const r = amendment.safeParse(item);
242
+ if (!r.success) return issueText(r.error);
243
+ return {
244
+ kind: "amendment",
245
+ id: r.data.id,
246
+ by: r.data.by,
247
+ at: r.data.at,
248
+ reason: r.data.reason ?? null,
249
+ amends: r.data.amends,
250
+ };
251
+ }
252
+ return "neither a denial (`stable_id`), a revocation (`revokes`) nor an amendment (`amends`)";
253
+ }
254
+
255
+ function issueText(error: z.ZodError): string {
256
+ return error.issues
257
+ .map((i) => `\`${i.path.map(String).join(".") || "(entry)"}\`: ${i.message}`)
258
+ .join("; ");
259
+ }
260
+
261
+ /** The denials currently in force, with `expected` as the latest amendment left it. */
262
+ export function inForce(ledger: Ledger): readonly Denial[] {
263
+ const live = new Map<string, Denial>();
264
+ for (const entry of ledger.entries) {
265
+ if (entry.kind === "denial") live.set(entry.id, entry);
266
+ else if (entry.kind === "revocation") live.delete(entry.revokes);
267
+ else {
268
+ const target = live.get(entry.amends);
269
+ if (target !== undefined) live.set(entry.amends, { ...target, expected: "removed" });
270
+ }
271
+ }
272
+ return [...live.values()];
273
+ }
274
+
275
+ /**
276
+ * Every entry — denial, revocation, amendment — must be by a takedown
277
+ * authority, checked where the ACT happens: an entry this record has not yet
278
+ * accepted. History is not re-litigated, and that is the whole of the rule.
279
+ *
280
+ * Judging every entry against the PRESENT roster made a personnel change break
281
+ * the record: remove a departed authority from `.ksor/governance.yaml` and
282
+ * every entry they had ever written refused, while the obvious remedy —
283
+ * deleting those entries — is `ksor-ledger-shrank`. The only escape left was to
284
+ * go on naming a departed person as a takedown authority, which is a lie the
285
+ * policy would then carry forever. An entry was authorised when it was written;
286
+ * the ledger is append-only precisely so the past is not rewritten.
287
+ *
288
+ * `accepted` is what makes this safe, and only a baseline that says so grants
289
+ * it. Git history proves a line was COMMITTED, and anyone with write access can
290
+ * commit — a pull request that hand-appends an entry puts it in history before
291
+ * any check runs — so exempting on history would have retired the guarantee
292
+ * this rule exists for (record spec §5: a line hand-appended in a pull request
293
+ * is refused exactly as the verb would refuse it). The committed lock is
294
+ * different: it is written by a build that PASSED, and this check is what that
295
+ * build had to get past. Acceptance is of TEXT, not of an id, so an entry
296
+ * retargeted under an accepted id is judged again.
297
+ */
298
+ export function checkLedgerActors(
299
+ ledger: Ledger,
300
+ takedownActors: readonly string[],
301
+ // REQUIRED, with no default. It had one (`= []`), and `checkRecord` then
302
+ // called this with two arguments for as long as the rule existed: the
303
+ // accepted set was always empty, every entry was judged against the PRESENT
304
+ // roster, and the whole departed-authority guarantee was dead code whose
305
+ // refusal went on describing it as fact. A caller that means "nothing is
306
+ // accepted" now says so, in the same shape as a caller that means it.
307
+ baselines: readonly LedgerBaseline[],
308
+ ): Refusal[] {
309
+ const accepted = acceptedEntries(baselines);
310
+ return ledger.entries
311
+ .filter((e) => !takedownActors.includes(e.by) && !accepted.has(`${e.id}\t${entryDigest(e)}`))
312
+ .map((e) => ({
313
+ slug: "ksor-takedown-unauthorised",
314
+ path: ".ksor/takedowns.yaml",
315
+ why: `entry \`${e.id}\` is by \`${e.by}\`, whom \`takedown_authorities\` does not name, and no build this record committed has accepted it — so it is judged as an entry being written now`,
316
+ fix: "only an actor the policy names may write the ledger — revert the appended entry, or name the actor in the policy in a reviewed change (an entry an earlier build accepted is history and is never judged again, so a departed authority's committed entries do not hold the policy hostage)",
317
+ }));
318
+ }
319
+
320
+ /** `<id>\t<digest>` for every entry a baseline records as accepted; a digest-less one proves no text. */
321
+ function acceptedEntries(baselines: readonly LedgerBaseline[]): Set<string> {
322
+ const out = new Set<string>();
323
+ for (const b of baselines) {
324
+ if (b.accepted !== true) continue;
325
+ for (const e of b.entries) if (e.digest !== null) out.add(`${e.id}\t${e.digest}`);
326
+ }
327
+ return out;
328
+ }
329
+
330
+ export interface TreeShape {
331
+ /**
332
+ * Every bundle-relative id the tree holds a DOCUMENT for — one that parsed
333
+ * into a concept, and one that did not. Not `conceptIds`, which is what this
334
+ * used to be: a denied document with a frontmatter typo is not a concept, so
335
+ * an in-force denial on it reported `ksor-takedown-dangling` — "this denial
336
+ * names a document that does not exist" — about a file still sitting in the
337
+ * checkout, and its remedy (`--removed`) appends a governance record
338
+ * asserting a removal that never happened (2026-08-25 review). Presence is a
339
+ * question about the TREE; whether a document is readable is the parse
340
+ * refusal's to raise, and it is raised, so nothing is published either way.
341
+ */
342
+ readonly documentIds: ReadonlySet<string>;
343
+ /** Bundle-relative directories. */
344
+ readonly dirs: ReadonlySet<string>;
345
+ }
346
+
347
+ /**
348
+ * Is the thing this denial names in the tree? ONE question, and the only one
349
+ * `expected` is an answer to — asked here for BOTH scopes so the two can never
350
+ * mean different things again. A `node` entry names a document; a `subtree`
351
+ * entry names the directory behind its `#section` anchor (decision 14: the
352
+ * container, so a descendant a later change adds is covered too).
353
+ */
354
+ export function targetPresent(
355
+ denial: Pick<Denial, "stableId" | "scope">,
356
+ tree: TreeShape,
357
+ ): boolean {
358
+ return denial.scope === "subtree"
359
+ ? tree.dirs.has(denial.stableId.slice("knowledge/".length, -"#section".length))
360
+ : tree.documentIds.has(denial.stableId.slice("knowledge/".length));
361
+ }
362
+
363
+ /**
364
+ * The `expected` a denial written against THIS tree carries — what the verb
365
+ * records at the moment of the act, and what anything transcribing a denial
366
+ * into a ledger must write instead of assuming (`ksor migrate` assumed
367
+ * `present` for every subtree denial, so its very first build could refuse).
368
+ */
369
+ export function expectedIn(denial: Pick<Denial, "stableId" | "scope">, tree: TreeShape): Expected {
370
+ return targetPresent(denial, tree) ? "present" : "removed";
371
+ }
372
+
373
+ /**
374
+ * Dangling and re-added entries, evaluated on the in-force denials only.
375
+ *
376
+ * ONE rule for both scopes (decision 18's shape): `expected` is compared with
377
+ * what the tree actually holds, and the scope decides only how the refusal
378
+ * READS. It used to decide the verdict as well — the subtree branch refused on
379
+ * absence alone and never consulted `expected` — and that made an ordinary act
380
+ * unrecordable. `ksor takedown --actor <who> --scope subtree knowledge/embargo` on a
381
+ * directory that does not exist yet is sanctioned (a denial may precede what it
382
+ * names, decision 14); the verb wrote `expected: removed` and exited 0, and the
383
+ * next `ksor build` exited 1 with `ksor-takedown-dangling` — with no honest
384
+ * exit, because the ledger is append-only, `--revoke` records a lift that never
385
+ * happened, and git cannot commit an empty directory back into the tree. The
386
+ * same act at node scope was green. Meanwhile the SERVING half had read
387
+ * `expected` scope-blind all along (`governance-gate.ts`: `d.expected <>
388
+ * 'removed'`), so the two surfaces disagreed about which records are
389
+ * publishable — decision 19's forbidden state, inverted (2026-08-25).
390
+ */
391
+ /**
392
+ * Why the record ROOT can never be the target of a denial, and what to do
393
+ * instead — written ONCE, because two places refuse it and a rule explained
394
+ * twice is a rule that drifts (decision 18's shape, applied to prose).
395
+ *
396
+ * `planTakedown` refuses the ACT, so the entry is never written; this module
397
+ * refuses the ENTRY, for the ones an older verb wrote and for the ones a hand
398
+ * appends in a pull request. Only the second can name a `--revoke` exit, so the
399
+ * entry id is appended there and not carried in the shared text.
400
+ */
401
+ export const RECORD_ROOT_DENIAL = {
402
+ why: "the record root is no node: top-level sections are `knowledge/<section>#section` with no parent, so the serving side's `parent_id` walk seeds EMPTY and denies nothing, while the site's prefix test denies EVERYTHING. A hold that darkens the website and goes on serving every document to every agent is worse than no hold, because the dark website reads as confirmation",
403
+ fix: "deny each top-level section instead — `ksor takedown --actor <who> --scope subtree knowledge/<section>`, one per section",
404
+ } as const;
405
+
406
+ export function checkLedgerAgainstTree(ledger: Ledger, tree: TreeShape): Refusal[] {
407
+ const refusals: Refusal[] = [];
408
+ const path = ".ksor/takedowns.yaml";
409
+ for (const d of inForce(ledger)) {
410
+ /** The directory a subtree entry names, or null for a node entry. */
411
+ const dir =
412
+ d.scope === "subtree" ? d.stableId.slice("knowledge/".length, -"#section".length) : null;
413
+ // The record ROOT, `knowledge/#section`. Only ONE surface can carry it
414
+ // out: `denies()` reads the empty prefix as "everything", so the site
415
+ // goes dark, while the serving side walks `parent_id` from the node the
416
+ // denylist row NAMES (decision 14) and there is no node for the root —
417
+ // top-level sections carry `parent_id IS NULL` — so the seed is empty and
418
+ // the door serves every document. The surfaces INVERT: the visible one
419
+ // goes dark, which reads as confirmation, and the invisible one keeps
420
+ // answering. That is decision 19's forbidden state, so the hold is
421
+ // refused rather than half-performed, whatever `expected` says: the form is
422
+ // unhonourable, not merely out of step with the tree. (Refused here and not
423
+ // in `parseEntry` on purpose: the entry must stay READABLE, because the
424
+ // exit this names — `--revoke` — loads the ledger through `parseLedger`,
425
+ // and append-only means the line cannot simply be deleted.)
426
+ if (dir === "") {
427
+ refusals.push({
428
+ slug: "ksor-takedown-dangling",
429
+ path,
430
+ why: `entry \`${d.id}\` denies the subtree \`${d.stableId}\` — ${RECORD_ROOT_DENIAL.why}`,
431
+ fix: `${RECORD_ROOT_DENIAL.fix} — and then lift this one with \`ksor takedown --actor <who> --revoke ${d.id}\``,
432
+ });
433
+ continue;
434
+ }
435
+ // Presence is read the same way in both directions: an unreadable document
436
+ // is still HERE, so `present` does not dangle — and `removed` is still
437
+ // contradicted by something at that path, which is the direction that must
438
+ // never go quiet.
439
+ if (expectedIn(d, tree) === d.expected) continue;
440
+ const what = dir === null ? `\`${d.stableId}\`` : `the subtree \`${dir}/\``;
441
+ const it = dir === null ? "file" : "directory";
442
+ if (d.expected === "present") {
443
+ refusals.push({
444
+ slug: "ksor-takedown-dangling",
445
+ path,
446
+ why:
447
+ dir === null
448
+ ? `entry \`${d.id}\` denies ${what}, which resolves to no concept — a renamed denied document would otherwise republish under its new path`
449
+ : `entry \`${d.id}\` denies ${what}, which no longer exists — a renamed folder would otherwise republish`,
450
+ // `--removed`, never `--revoke`: a revocation records a lift that never
451
+ // happened, and drops the hold if the path ever comes back.
452
+ fix: `restore the ${it}, or record its removal with \`ksor takedown --actor <who> --removed ${d.id}\` (and deny the new path if it was renamed)`,
453
+ });
454
+ } else {
455
+ refusals.push({
456
+ slug: "ksor-takedown-readded",
457
+ path,
458
+ why: `entry \`${d.id}\` recorded ${what} as removed, and the ${dir === null ? "path" : "directory"} is back`,
459
+ fix: `delete the ${it} again, or revoke the entry with \`ksor takedown --actor <who> --revoke ${d.id}\` in a reviewed change`,
460
+ });
461
+ }
462
+ }
463
+ return refusals;
464
+ }
465
+
466
+ export interface LedgerBaselineEntry {
467
+ readonly id: string;
468
+ /**
469
+ * `entryDigest` of the entry as that baseline recorded it, or null when the
470
+ * baseline could only read ids — a historic version of the file that does not
471
+ * parse today still proves the id existed, which is what shrink needs.
472
+ */
473
+ readonly digest: string | null;
474
+ /** The parsed entry, where the baseline has it, so a refusal can name the fields that moved. */
475
+ readonly entry?: LedgerEntry;
476
+ /** Where this version was seen — a commit sha for history; absent for the lock. */
477
+ readonly where?: string;
478
+ }
479
+
480
+ export interface LedgerBaseline {
481
+ readonly source: string;
482
+ readonly entries: readonly LedgerBaselineEntry[];
483
+ /**
484
+ * Does this baseline prove the RECORD ACCEPTED these entries, or only that
485
+ * their text was committed? The committed lock is written by a build that
486
+ * passed every check in this file, so it says yes; git history says no,
487
+ * because committing is not passing (`checkLedgerActors`). Absent means no —
488
+ * a caller that proves nothing gets the strict rule.
489
+ *
490
+ * Append-only (`checkLedgerAppendOnly`) ignores this: for "was this id ever
491
+ * written" and "is it still the same text", a committed version is exactly
492
+ * the right evidence, and the one a single commit cannot rewrite.
493
+ */
494
+ readonly accepted?: boolean;
495
+ }
496
+
497
+ /**
498
+ * A sha256 over every governing field of one entry. The append-only guarantee
499
+ * is not about the id set: comparing ids alone let a committed denial be
500
+ * RETARGETED in place — same id, same actor, a different `stable_id` — which
501
+ * republished the denied document and denied an innocent one with nothing red
502
+ * on any surface (reproduced end to end, 2026-08-25). `reason` is included
503
+ * because the ledger is written by the verb and never edited by hand: a
504
+ * correction is an appended entry, not a rewritten line.
505
+ */
506
+ export function entryDigest(entry: LedgerEntry): string {
507
+ const common = [entry.kind, entry.id, entry.by, entry.at, entry.reason ?? ""];
508
+ const rest =
509
+ entry.kind === "denial"
510
+ ? [entry.stableId, entry.scope, entry.expected]
511
+ : entry.kind === "revocation"
512
+ ? [entry.revokes]
513
+ : [entry.amends];
514
+ return createHash("sha256")
515
+ .update(JSON.stringify([...common, ...rest]))
516
+ .digest("hex");
517
+ }
518
+
519
+ /** The `(id, digest)` pairs a build records so the next one can compare text, not just ids. */
520
+ export function ledgerDigests(ledger: Ledger): { id: string; digest: string }[] {
521
+ return ledger.entries.map((e) => ({ id: e.id, digest: entryDigest(e) }));
522
+ }
523
+
524
+ /**
525
+ * The ledger is append-only in two senses, and both are checked here: its id
526
+ * set must contain every id any baseline has seen (`ksor-ledger-shrank`), and
527
+ * an id a baseline recorded must still carry the same text
528
+ * (`ksor-ledger-amended`).
529
+ */
530
+ export function checkLedgerAppendOnly(
531
+ ledger: Ledger,
532
+ baselines: readonly LedgerBaseline[],
533
+ ): Refusal[] {
534
+ const path = ".ksor/takedowns.yaml";
535
+ const have = new Map(ledger.entries.map((e) => [e.id, e]));
536
+ const missing = new Map<string, string[]>();
537
+ const refusals: Refusal[] = [];
538
+ for (const b of baselines) {
539
+ for (const seen of b.entries) {
540
+ const current = have.get(seen.id);
541
+ if (current === undefined) {
542
+ missing.set(seen.id, [...(missing.get(seen.id) ?? []), b.source]);
543
+ continue;
544
+ }
545
+ if (seen.digest === null || seen.digest === entryDigest(current)) continue;
546
+ const moved = seen.entry === undefined ? [] : changedFields(seen.entry, current);
547
+ refusals.push({
548
+ slug: "ksor-ledger-amended",
549
+ path,
550
+ why:
551
+ `entry \`${seen.id}\` is not the entry ${b.source}${seen.where === undefined ? "" : ` (${seen.where})`} recorded` +
552
+ `${moved.length === 0 ? "" : ` — ${moved.join(", ")} moved`}; an entry is never edited, only superseded by a revocation or an amendment appended after it`,
553
+ fix: "restore the entry's text; to change what a denial covers, append a new entry with `ksor takedown` (`--revoke <id>`, or a fresh denial)",
554
+ });
555
+ }
556
+ }
557
+ if (missing.size > 0) {
558
+ const list = [...missing]
559
+ .sort()
560
+ .map(([id, sources]) => `\`${id}\` (seen in ${sources.join(", ")})`)
561
+ .join(", ");
562
+ refusals.push({
563
+ slug: "ksor-ledger-shrank",
564
+ path,
565
+ why: `the ledger is append-only and lost ${list}`,
566
+ fix: "restore the deleted entries; lift a denial with a revocation entry, never by removing a line",
567
+ });
568
+ }
569
+ return refusals;
570
+ }
571
+
572
+ /** The field names whose values differ, in the entry's own vocabulary. */
573
+ function changedFields(before: LedgerEntry, after: LedgerEntry): string[] {
574
+ const flat = (e: LedgerEntry): Record<string, string> => ({
575
+ kind: e.kind,
576
+ by: e.by,
577
+ at: e.at,
578
+ reason: e.reason ?? "",
579
+ ...(e.kind === "denial"
580
+ ? { stable_id: e.stableId, scope: e.scope, expected: e.expected }
581
+ : e.kind === "revocation"
582
+ ? { revokes: e.revokes }
583
+ : { amends: e.amends }),
584
+ });
585
+ const a = flat(before);
586
+ const b = flat(after);
587
+ const names = new Set([...Object.keys(a), ...Object.keys(b)]);
588
+ return [...names].filter((k) => a[k] !== b[k]).sort();
589
+ }
590
+
591
+ /**
592
+ * Does any in-force denial cover the concept `id` (bundle-relative)? A `node`
593
+ * entry names exactly `knowledge/<id>`; a `subtree` entry names
594
+ * `knowledge/<dir>#section` and covers every id beneath `dir/`. Resolved at
595
+ * use, never expanded at write time, for the reason decision 14 records: a
596
+ * subtree denial must also cover a descendant a later change adds.
597
+ *
598
+ * The ROOT, `knowledge/#section`, answers true for everything — but that is a
599
+ * backstop, not a feature: `checkLedgerAgainstTree` refuses the form, because
600
+ * the serving side cannot honour it and a hold only the website performs is
601
+ * worse than none. It stays true here because if the refusal were ever lifted,
602
+ * denying too much is the recoverable half and denying too little is a leak.
603
+ *
604
+ * `id === dir` is covered too. In a conformant record it cannot arise — a
605
+ * `policies.md` beside a `policies/` is a refused route collision — but a
606
+ * denial that covers one document too many is recoverable and one that covers
607
+ * one too few is a leak, so the unreachable case denies.
608
+ */
609
+ export function denies(inForceDenials: readonly Denial[], id: string): boolean {
610
+ return inForceDenials.some((d) => {
611
+ if (d.scope === "node") return d.stableId === `knowledge/${id}`;
612
+ const dir = d.stableId.slice("knowledge/".length, -"#section".length);
613
+ return dir === "" || id === dir || id.startsWith(`${dir}/`);
614
+ });
615
+ }
616
+
617
+ // ── writing ───────────────────────────────────────────────────────────────
618
+ // Only `ksor takedown` writes the ledger, and it writes by APPENDING text
619
+ // rather than re-serializing the file: re-emitting a parsed document would
620
+ // rewrite bytes nobody changed, and an append-only file whose earlier lines
621
+ // move on every write is not reviewable in a pull request diff.
622
+
623
+ /** Entry ids are `<at>-<6 random>` (record spec §5) — sortable by the act, unique by the suffix. */
624
+ export function mintLedgerId(at: string, random: () => string = randomSuffix): string {
625
+ return `${at}-${random()}`;
626
+ }
627
+
628
+ function randomSuffix(): string {
629
+ return randomBytes(3).toString("hex");
630
+ }
631
+
632
+ /**
633
+ * One entry's YAML. Every scalar is double-quoted: an id and an instant both
634
+ * contain `:` and would otherwise depend on the reader's resolution rules, and
635
+ * a reason is free text an operator typed. JSON string escapes are exactly
636
+ * YAML's double-quoted escapes, so `JSON.stringify` is the right quoter.
637
+ */
638
+ export function renderEntry(entry: LedgerEntry): string {
639
+ const q = (value: string): string => JSON.stringify(value);
640
+ const lines: string[] = [`- id: ${q(entry.id)}`];
641
+ if (entry.kind === "denial") {
642
+ lines.push(
643
+ ` stable_id: ${q(entry.stableId)}`,
644
+ ` scope: ${entry.scope}`,
645
+ ` expected: ${entry.expected}`,
646
+ );
647
+ } else if (entry.kind === "revocation") {
648
+ lines.push(` revokes: ${q(entry.revokes)}`);
649
+ } else {
650
+ lines.push(` amends: ${q(entry.amends)}`, " expected: removed");
651
+ }
652
+ lines.push(` by: ${q(entry.by)}`, ` at: ${q(entry.at)}`);
653
+ if (entry.reason !== null) lines.push(` reason: ${q(entry.reason)}`);
654
+ return lines.join("\n") + "\n";
655
+ }
656
+
657
+ /**
658
+ * The bytes to ADD to `text` — never the file rewritten around them.
659
+ *
660
+ * The distinction is the whole of it. This used to return the file as it
661
+ * should be after appending, and its one caller wrote that back with
662
+ * `writeFileSync`: a call that opens with `O_TRUNC`, so every earlier entry
663
+ * was deleted and then re-written from whatever the caller happened to have
664
+ * read. Two operators running `ksor takedown` at once destroyed each other's
665
+ * acts and both reported success (measured: five concurrent runs, five claims,
666
+ * three entries), and a reader landing inside the truncation window read an
667
+ * empty ledger and rewrote forty entries down to one.
668
+ *
669
+ * A delta cannot do either. Appended with `O_APPEND` the kernel places the
670
+ * bytes at the end whatever else is happening, so the file only ever grows,
671
+ * a killed writer leaves what was already there, and the worst a LOST lock can
672
+ * do is order two acts differently — not lose one.
673
+ *
674
+ * `text` is null when the ledger does not exist yet, which is the only time
675
+ * the header is written. A file that does not end in a newline gets one first,
676
+ * so an append never joins itself onto somebody else's last line.
677
+ */
678
+ export function bytesToAppend(text: string | null, entry: LedgerEntry): string {
679
+ const rendered = renderEntry(entry);
680
+ if (text === null || text.trim() === "") return LEDGER_HEADER + rendered;
681
+ return (text.endsWith("\n") ? "" : "\n") + rendered;
682
+ }
683
+
684
+ // Manager-NEUTRAL on purpose. This file is written at RUNTIME by `ksor
685
+ // takedown`, so it never passes through init's prose translation (decision 25)
686
+ // — a package-manager command named here landed verbatim in every scaffold,
687
+ // including the npm and bun ones that cannot run it, while the emitted
688
+ // scaffold's own translated copy of this constant said something else beside
689
+ // it. Naming the checker rather than a runner needs no threading and cannot
690
+ // drift.
691
+ const LEDGER_HEADER =
692
+ "# The takedown ledger (record spec §5): append-only, written only by\n" +
693
+ "# `ksor takedown`, and validated by the record checker, `ksor build` and\n" +
694
+ "# ingest. Lift a denial with a revocation entry; never delete a line.\n";