@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.
- package/CHANGELOG.md +965 -0
- package/README.md +29 -9
- package/dist/checker/check-main.mjs +14049 -0
- package/dist/cli.mjs +11528 -5213
- package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
- package/dist/gateway.d.mts +52 -13
- package/dist/gateway.mjs +2 -2
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
- package/docs/authorization.md +8 -6
- package/docs/deploying.md +36 -25
- package/docs/index.md +26 -13
- package/docs/ingesting.md +70 -22
- package/docs/tool-surface.md +69 -16
- package/package.json +4 -3
- package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
- package/schema/schema.sql +77 -14
- package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.github/workflows/validate.yml +9 -1
- package/templates/scaffold/.ksor/governance.yaml +17 -0
- package/templates/scaffold/AGENTS.md +293 -119
- package/templates/scaffold/Dockerfile +5 -1
- package/templates/scaffold/README.md +160 -42
- package/templates/scaffold/env.example +37 -6
- package/templates/scaffold/gitignore +14 -6
- package/templates/scaffold/instance.md +21 -17
- package/templates/scaffold/knowledge/governance-ladder.md +11 -2
- package/templates/scaffold/knowledge/index.md +9 -0
- package/templates/scaffold/knowledge/surfaces/for-agents.md +13 -6
- package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
- package/templates/scaffold/knowledge/surfaces/index.md +4 -20
- package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
- package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
- package/templates/scaffold/package.json +3 -4
- package/templates/scaffold/pnpm-lock.yaml +3 -0
- package/templates/scaffold/system/gateways/content.ts +13 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
- package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +134 -91
- package/templates/scaffold/system/site/app/global.css +198 -6
- package/templates/scaffold/system/site/app/layout.tsx +8 -3
- package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
- package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
- package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
- package/templates/scaffold/system/site/components/code-block.tsx +87 -0
- package/templates/scaffold/system/site/components/embed.tsx +276 -0
- package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
- package/templates/scaffold/system/site/components/governance.tsx +219 -87
- package/templates/scaffold/system/site/components/mdx.tsx +10 -2
- package/templates/scaffold/system/site/components/record-index.tsx +5 -5
- package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
- package/templates/scaffold/system/site/components/record-views.tsx +6 -17
- package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
- package/templates/scaffold/system/site/lib/alert-rule.ts +214 -0
- package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
- package/templates/scaffold/system/site/lib/attachments.ts +0 -28
- package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
- package/templates/scaffold/system/site/lib/audience.ts +42 -146
- package/templates/scaffold/system/site/lib/embed-rule.ts +255 -0
- package/templates/scaffold/system/site/lib/governance.ts +339 -225
- package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
- package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
- package/templates/scaffold/system/site/lib/lock.ts +282 -0
- package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
- package/templates/scaffold/system/site/lib/record-href.ts +68 -0
- package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
- package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
- package/templates/scaffold/system/site/lib/shared.ts +67 -104
- package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
- package/templates/scaffold/system/site/lib/source.ts +256 -186
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +638 -467
- package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
- package/templates/scaffold/system/site/package.json +1 -0
- package/templates/scaffold/system/site/record/actor.ts +23 -0
- package/templates/scaffold/system/site/record/check.ts +571 -0
- package/templates/scaffold/system/site/record/citations.ts +312 -0
- package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
- package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
- package/templates/scaffold/system/site/record/hygiene.ts +320 -0
- package/templates/scaffold/system/site/record/index-file.ts +150 -0
- package/templates/scaffold/system/site/record/index.ts +103 -0
- package/templates/scaffold/system/site/record/instance.ts +257 -0
- package/templates/scaffold/system/site/record/instant.ts +43 -0
- package/templates/scaffold/system/site/record/ledger.ts +694 -0
- package/templates/scaffold/system/site/record/load.ts +129 -0
- package/templates/scaffold/system/site/record/lock.ts +306 -0
- package/templates/scaffold/system/site/record/near-miss.ts +37 -0
- package/templates/scaffold/system/site/record/policy.ts +414 -0
- package/templates/scaffold/system/site/record/profile.ts +535 -0
- package/templates/scaffold/system/site/record/refusal.ts +106 -0
- package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
- package/templates/scaffold/system/site/source.config.ts +109 -23
- package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
- package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
- 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";
|