@panaversity/ksor 0.0.57 → 0.0.59

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.
@@ -423,7 +423,7 @@ governance block every hit now carries:
423
423
  Three edits pay for themselves:
424
424
 
425
425
  - **Delete a tool nothing calls.** Removing `outline` and `read` gives back
426
- ~2,152 tokens for the whole session.
426
+ ~2,310 tokens for the whole session.
427
427
  - **Say what this record covers**, above `FLOOR.search`. It is how an agent with
428
428
  several records attached picks yours; name the subject AND the boundary.
429
429
  - **Set `k`** in the input schema — it is the lever on reply size.
@@ -497,9 +497,14 @@ runs over every entry in the ledger at `pnpm check`, `ksor build` and ingest, so
497
497
  a line appended by hand in a pull request is refused exactly as the verb would
498
498
  refuse it. The read-only modes
499
499
  (`--list`, `--ledger`) need no actor — nobody is performing an act by looking.
500
- They do not need a database either: on a record that declares none they read
501
- the committed `.ksor/takedowns.yaml`, which is the whole record of the act
502
- anyway.
500
+ They do not need a database either: `--ledger` always reads the committed
501
+ `.ksor/takedowns.yaml`, which is the record of every act, and `--list` reads
502
+ the door's denylist rows when `KSOR_DB_URL` is set and otherwise the ledger's
503
+ denials, each labelled `not applied (no database)` because no row exists for
504
+ a door to refuse on. A denial itself, on this record with `KSOR_DB_URL` unset,
505
+ is refused by name (`ksor-takedown-dsn-missing`) unless `--file-only` records
506
+ the entry alone — `--apply` writes its row later, where the database is
507
+ reachable.
503
508
 
504
509
  **The MCP door stops serving it immediately. The SITE stops at its next
505
510
  build** — the site reads the committed ledger (`.ksor/takedowns.yaml`), not
@@ -516,7 +521,11 @@ ROOT, never `system/site/`), and every other host just serves the folder.
516
521
  record checked, `build.lock.json` written (commit it: it is the provenance
517
522
  every machine artefact stamps) — and a refusal stops the build before a byte
518
523
  is written. `ksor build --strict` additionally refuses an uncommitted input
519
- (`ksor-build-dirty`), which is the posture for a release.
524
+ (`ksor-build-dirty`), which is the posture for a release. `ksor build
525
+ --bundles` also writes one OKF bundle per viewer under
526
+ `.ksor/out/bundles/<viewer>/` (gitignored) — only what that viewer's machine
527
+ surfaces publish, readable by any OKF consumer with no ksor in the loop — for
528
+ handing the record to another system.
520
529
  `KSOR_BASE_PATH=/repo pnpm build` targets sub-path hosting. With audiences
521
530
  registered in `.ksor/governance.yaml`, plain `pnpm build` is the `public`
522
531
  viewer; `KSOR_AUDIENCE=public,<audience> pnpm build` — a comma list, always
@@ -607,7 +616,14 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
607
616
  audiences registered in `.ksor/governance.yaml`; never omitted, never
608
617
  inferred). A `stable` document carries `generated: { by, at }` and
609
618
  `ksor.approval: { by, at }` by an actor the policy authorises, with
610
- `generated.at` no later than the approval; a `deprecated` one carries
619
+ `generated.at` no later than the approval and `generated.at` dates the
620
+ TEXT: edit a stable document's body and `ksor build` and `ksor ingest`
621
+ compare it against every committed version that was stable, and refuse
622
+ `ksor-generated-stale` until you move the stamp PAST the edit and re-approve
623
+ — leaving the stamp alone and backdating it are refused alike, because
624
+ neither advances it (a frontmatter-only edit, such as adding a `verified`
625
+ entry, is not a body change; `pnpm check` reads no document history, so it passes an
626
+ edit the build will refuse); a `deprecated` one carries
611
627
  `ksor.deprecated: { by, at }` and usually `ksor.superseded_by: <id>`
612
628
  (a stable document every reader of this one may read). Optional: `order`
613
629
  (reading position), `sources` (`{ id, resource, title }`, cited in the body
@@ -899,8 +915,7 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
899
915
  click is informed.
900
916
 
901
917
  **You do not state a height.** A page carried in the record is measured, so
902
- the frame is exactly as tall as what it holds on this record's own seven,
903
- to the pixel. A number written into a document would be a number some other
918
+ the frame is exactly as tall as what it holds, to the pixel. A number written into a document would be a number some other
904
919
  measure makes wrong.
905
920
 
906
921
  **Carry the page in where you can.** A file named `<name>.sim.html`, sitting
@@ -259,7 +259,9 @@ Never copy a floor from another corpus.
259
259
  It also prints its own caveat, and it is worth reading: the probes it writes are
260
260
  derived from your passages, so they share vocabulary a real question will not.
261
261
  The floor it reports is an upper bound on separation until you check it against
262
- questions the corpus did not write (`--queries-file`).
262
+ questions a person wrote: in-corpus ones with `--queries-file`, and — because
263
+ the built-in out-of-corpus probes are all far-domain — questions just outside
264
+ your scope with `--ooc-file`.
263
265
 
264
266
  A rerun on an unchanged record costs nothing: no new generation, no embedding,
265
267
  no rows. Edit a document and the next run picks up exactly that change.
@@ -698,7 +700,7 @@ different coding agent's way of finding the same working contract.
698
700
  | `.gemini/settings.json` | points Gemini CLI at `AGENTS.md`; Gemini does not read that filename on its own. |
699
701
  | `.github/workflows/validate.yml` | your CI: runs the same checker on every pull request and push to main. |
700
702
  | `.gitattributes` | markdown is checked out byte-stable on every platform, so the same commit hashes the same everywhere. |
701
- | `.gitignore` | keeps build output, `node_modules/`, and `.env` out of the record's history — and negates two paths inside `.ksor/`, because the policy and the ledger ARE the record. |
703
+ | `.gitignore` | keeps build output, `node_modules/`, and `.env` out of the record's history — and negates three paths inside `.ksor/`, because the policy, the ledger and the phone book ARE the record. |
702
704
 
703
705
  `format-checker` deliberately contains a program, `check.mjs`, and not only
704
706
  prose: rules that are only written down cannot refuse anything.
@@ -724,6 +726,7 @@ map rather than a substitute.
724
726
  | the home page and `/llms.txt` are empty | every document is still a draft — correct, not broken | approve one and rebuild |
725
727
  | `ksor-record-empty` | every document was deleted — a record is never empty, so nothing was written | add one document of your own (or restore one from git) before deleting the last starter |
726
728
  | `ksor-approver-unauthorised` | a document is approved by an actor `.ksor/governance.yaml` no longer names — usually `human:you` after the interview | re-attribute the approval to your handle, or restore the actor to `approval_authorities` |
729
+ | `ksor-generated-stale` at `pnpm build` or `pnpm refresh` | a `stable` document's body changed since a commit where it was stable, and `generated.at` was not advanced past that commit's (left alone, or moved backward) — the stamp dates the text, and `pnpm check` reads no document history, so it passed | set `generated.at` to an instant after the edit and re-approve (`ksor.approval.at` may not precede it) |
727
730
  | a new document never appears on the built site | drafts reach no built surface at all | publish it — `status: stable` plus both governance keys |
728
731
  | an expired document still shows on the site but not through the door | the static build evaluated `stale_after` at build time | rebuild and redeploy; schedule a rebuild if you use it |
729
732
  | Vercel: `no services are declared` | Root Directory was auto-filled with `system/site` | set it to `./` |
@@ -66,6 +66,9 @@ KSOR_AUTH=disabled-local
66
66
  # NEXT_PUBLIC_KSOR_SSO_URL=https://your-sso.example.com
67
67
  # NEXT_PUBLIC_KSOR_OAUTH_CLIENT_ID=
68
68
  # NEXT_PUBLIC_KSOR_OAUTH_REDIRECT_URI=https://your-site.example.com/auth/callback
69
+ #
70
+ # Next.js telemetry is already off: the site's build and dev scripts run next
71
+ # through system/site/next-no-telemetry.mjs, which sets NEXT_TELEMETRY_DISABLED=1.
69
72
 
70
73
  # ── Production knobs ────────────────────────────────────────────────────────
71
74
  # Unset is fine for a local run; each one matters once this serves for real.
@@ -41,9 +41,8 @@ record wins.
41
41
 
42
42
  Everything above describes KSoR itself. It ships filled in so that a fresh
43
43
  project has a real governed corpus on the first `pnpm dev` — five approved
44
- documents, three of them inside a folder, and one carrying all four study
45
- attachments (a summary, slides, flashcards and a quiz) — instead of an empty
46
- shelf and a placeholder.
44
+ documents, three of them inside a folder, and one carrying a summary
45
+ companion — instead of an empty shelf and a placeholder.
47
46
  The documents live in `knowledge/`; delete them as your own knowledge arrives.
48
47
 
49
48
  Be deliberate about replacing it, because a starter that describes the wrong
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Run Next.js with its telemetry OFF, so nothing phones home.
3
+ *
4
+ * Next.js ships with anonymous usage telemetry switched on: `next build` and
5
+ * `next dev` each post to telemetry.nextjs.org unless NEXT_TELEMETRY_DISABLED
6
+ * is set in the process environment. This project promises that nothing
7
+ * phones home, so `build` and `dev` run next through here instead of directly.
8
+ *
9
+ * Why the environment and not next.config.mjs: the `next dev` parent process
10
+ * never loads the config, and still records a session event on exit. Why a
11
+ * file and not a `NEXT_TELEMETRY_DISABLED=1 next …` prefix in package.json:
12
+ * that prefix is POSIX shell syntax, which cmd.exe refuses on Windows. This
13
+ * is node:child_process and nothing else — no dependency, works everywhere
14
+ * node does — and every argument passes through to next unchanged.
15
+ */
16
+ import { spawnSync } from "node:child_process";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const next = fileURLToPath(new URL("dist/bin/next", import.meta.resolve("next/package.json")));
20
+
21
+ const child = spawnSync(process.execPath, [next, ...process.argv.slice(2)], {
22
+ stdio: "inherit",
23
+ env: { ...process.env, NEXT_TELEMETRY_DISABLED: "1" },
24
+ });
25
+
26
+ if (child.error) throw child.error;
27
+ process.exit(child.status ?? 1);
@@ -4,8 +4,8 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "build": "next build --webpack",
8
- "dev": "next dev"
7
+ "build": "node next-no-telemetry.mjs build --webpack",
8
+ "dev": "node next-no-telemetry.mjs dev"
9
9
  },
10
10
  "dependencies": {
11
11
  "class-variance-authority": "0.7.1",
@@ -0,0 +1,319 @@
1
+ /**
2
+ * KSP R23 — the first verification tooth in approval.
3
+ *
4
+ * A `stable` concept's `generated.at` dates its TEXT, and `ksor.approval.at`
5
+ * ratifies the text of that date. Until now the checker compared the two
6
+ * authored instants and nothing else (`ksor-generated-after-approval`), so
7
+ * whether an edit actually moved `generated.at` was the author's obligation:
8
+ * change a sentence, leave the stamp, and the approval that ratified the old
9
+ * sentence reads as ratifying the new one, with nothing red. This module
10
+ * compares the body in the working tree against every committed version of
11
+ * the same path that was `stable`, and refuses `ksor-generated-stale` when a
12
+ * body differs under a `generated.at` the tree has not ADVANCED past — equal,
13
+ * or moved backward, which changes the stamp without advancing it. Only the
14
+ * body: a frontmatter-only
15
+ * edit — a `verified` entry, a re-approval — is not a change to the text the
16
+ * stamp dates.
17
+ *
18
+ * What it does NOT verify, and says so: who reviewed anything. R22 and R25
19
+ * need an identity the platform can vouch for, and `approval.checked` stays
20
+ * `"policy"` (decision 21). This is the one rule in the record module that
21
+ * reads git, so it sits beside `git-ledger.ts` and is run by the two
22
+ * publishing verbs — `ksor build` and `ksor ingest` — each of which SAYS when
23
+ * history could not be read rather than passing a check that did not run. The
24
+ * emitted `check.mjs` does not run it: that is the format gate an agent runs
25
+ * after every edit, and a stamp is verified where the record is published.
26
+ */
27
+ import { spawnSync } from "node:child_process";
28
+
29
+ import { normalizeText, splitFrontmatter } from "./frontmatter";
30
+ import { git } from "./git-ledger";
31
+ import { parseInstant } from "./instant";
32
+ import type { Concept } from "./profile";
33
+ import type { Refusal } from "./refusal";
34
+
35
+ export interface CommittedVersion {
36
+ readonly sha: string;
37
+ /** The committer instant, ISO 8601 with an offset (`%cI`). */
38
+ readonly committedAt: string;
39
+ readonly text: string;
40
+ }
41
+
42
+ export interface CommittedHistory {
43
+ /** False outside any repository, and false without a git binary. */
44
+ readonly repository: boolean;
45
+ /** Whether HEAD exists: a fresh `ksor init` is a repository with no commit. */
46
+ readonly born: boolean;
47
+ readonly shallow: boolean;
48
+ /** Record-relative path → its committed versions, newest first; null when history could not be read. */
49
+ readonly versions: ReadonlyMap<string, readonly CommittedVersion[]> | null;
50
+ }
51
+
52
+ /** The record's bundle, and the only pathspec the walk needs (relative to the cwd, which is the record root). */
53
+ const BUNDLE = "knowledge/";
54
+
55
+ /**
56
+ * Objects per `git cat-file --batch` call. One process per object was measured
57
+ * at ~53 ms a call on the machine this was built on against ~0.6 ms an object
58
+ * batched (2026-09-02), so a record with a few hundred committed versions is
59
+ * the difference between a build that finishes and one that does not. Chunked
60
+ * so the reply stays under the buffer ceiling `git-ledger.ts` explains.
61
+ */
62
+ const BATCH = 256;
63
+ const MAX_BUFFER = 64 * 1024 * 1024;
64
+
65
+ /**
66
+ * Every committed version of each path, read in three git calls plus one
67
+ * `cat-file --batch` per chunk. `--full-history`, for the reason the ledger
68
+ * walk gives: default simplification follows a merge through the parent it is
69
+ * TREESAME to, so a version that lived only on a branch never enters the set.
70
+ * `-m`, because without a diff-merges option a merge commit lists NO paths at
71
+ * all — verified against git 2.50 — and a conflict resolved by hand is a body
72
+ * that is in neither parent; the same commit then prints once per parent, so
73
+ * versions are keyed by (commit, path). That flag is held by the merge case in
74
+ * `build.integration.test.ts`'s KSP R23 describe: drop `-m` here and the build
75
+ * it expects to refuse exits 0 instead.
76
+ */
77
+ export function committedVersions(root: string, paths: readonly string[]): CommittedHistory {
78
+ const inside = git(root, ["rev-parse", "--is-inside-work-tree"]);
79
+ if (inside === null || inside.trim() !== "true") {
80
+ return { repository: false, born: false, shallow: false, versions: null };
81
+ }
82
+ const shallow = (git(root, ["rev-parse", "--is-shallow-repository"]) ?? "").trim() === "true";
83
+ const born = git(root, ["rev-parse", "--verify", "--quiet", "HEAD"]) !== null;
84
+ const versions = new Map<string, CommittedVersion[]>();
85
+ if (!born || paths.length === 0) return { repository: true, born, shallow, versions };
86
+
87
+ // `--name-only` prints paths from the REPOSITORY root, and the record may
88
+ // sit below it (`docs-sor/knowledge/x.md`) — the same two meanings of "path"
89
+ // `git-ledger.ts` records. The pathspec is cwd-relative; the names are not.
90
+ const prefix = (git(root, ["rev-parse", "--show-prefix"]) ?? "").trim();
91
+ const log = git(root, [
92
+ "log",
93
+ "--full-history",
94
+ "-m",
95
+ "--format=%x1e%H %cI",
96
+ "--name-only",
97
+ "--",
98
+ BUNDLE,
99
+ ]);
100
+ if (log === null) return { repository: true, born, shallow, versions: null };
101
+
102
+ const wanted = new Set(paths);
103
+ const refs: { readonly sha: string; readonly committedAt: string; readonly path: string }[] = [];
104
+ const seen = new Set<string>();
105
+ for (const block of log.split("\x1e")) {
106
+ const [header = "", ...names] = block.split("\n");
107
+ const [sha = "", committedAt = ""] = header.trim().split(" ");
108
+ if (sha === "") continue;
109
+ for (const name of names) {
110
+ // Names the profile admits are plain ASCII (`ksor-name-unportable`), so
111
+ // git never C-quotes one here; a quoted stray matches no wanted path.
112
+ const trimmed = name.trim();
113
+ if (trimmed === "" || !trimmed.startsWith(prefix)) continue;
114
+ const rel = trimmed.slice(prefix.length);
115
+ if (!wanted.has(rel) || seen.has(`${sha} ${rel}`)) continue;
116
+ seen.add(`${sha} ${rel}`);
117
+ refs.push({ sha, committedAt, path: rel });
118
+ }
119
+ }
120
+ const blobs = readBlobs(
121
+ root,
122
+ refs.map((r) => `${r.sha}:${prefix}${r.path}`),
123
+ );
124
+ if (blobs === null) return { repository: true, born, shallow, versions: null };
125
+ refs.forEach((ref, i) => {
126
+ const text = blobs[i];
127
+ // Absent in that commit: the commit DELETED the path (a rename's old name).
128
+ if (text === null || text === undefined) return;
129
+ const list = versions.get(ref.path) ?? [];
130
+ list.push({ sha: ref.sha, committedAt: ref.committedAt, text });
131
+ versions.set(ref.path, list);
132
+ });
133
+ return { repository: true, born, shallow, versions };
134
+ }
135
+
136
+ /**
137
+ * `<rev>:<path>` specs → text, in order; null for a spec that names nothing,
138
+ * and null for the WHOLE read when git failed — a version dropped in silence
139
+ * would read as verified.
140
+ */
141
+ function readBlobs(root: string, specs: readonly string[]): readonly (string | null)[] | null {
142
+ const out: (string | null)[] = [];
143
+ for (let i = 0; i < specs.length; i += BATCH) {
144
+ const chunk = specs.slice(i, i + BATCH);
145
+ const r = spawnSync("git", ["cat-file", "--batch"], {
146
+ cwd: root,
147
+ input: `${chunk.join("\n")}\n`,
148
+ maxBuffer: MAX_BUFFER,
149
+ });
150
+ if (r.status !== 0) return null;
151
+ const buf: Buffer = r.stdout;
152
+ let at = 0;
153
+ for (let k = 0; k < chunk.length; k += 1) {
154
+ const nl = buf.indexOf(0x0a, at);
155
+ if (nl === -1) return null;
156
+ const header = buf.subarray(at, nl).toString("utf8");
157
+ at = nl + 1;
158
+ if (header.endsWith(" missing")) {
159
+ out.push(null);
160
+ continue;
161
+ }
162
+ const [, type, sizeText] = header.split(" ");
163
+ const size = Number(sizeText);
164
+ if (type !== "blob" || !Number.isInteger(size) || at + size > buf.length) return null;
165
+ out.push(buf.subarray(at, at + size).toString("utf8"));
166
+ at += size + 1;
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+
172
+ interface StableVersion {
173
+ readonly sha: string;
174
+ readonly committedAt: string;
175
+ readonly generatedAt: number;
176
+ /** The stamp as that version spelled it, so the refusal quotes the file rather than a re-rendering. */
177
+ readonly stamp: string;
178
+ readonly body: string;
179
+ }
180
+
181
+ /** Line endings are the checkout's and a trailing blank line is nobody's edit; everything else is the text. */
182
+ function comparable(body: string): string {
183
+ return normalizeText(body).trimEnd();
184
+ }
185
+
186
+ /**
187
+ * A committed version as a stable, stamped text — or null: a draft's body is
188
+ * free, and a version the profile cannot read (no fence, broken YAML, no
189
+ * `generated.at`) was never a stable version under the profile.
190
+ */
191
+ function stableVersionOf(version: CommittedVersion, path: string): StableVersion | null {
192
+ const split = splitFrontmatter(version.text, path);
193
+ if (!split.ok || split.frontmatter === null || split.frontmatter["status"] !== "stable") {
194
+ return null;
195
+ }
196
+ const generated = split.frontmatter["generated"];
197
+ if (typeof generated !== "object" || generated === null) return null;
198
+ const at = (generated as Readonly<Record<string, unknown>>)["at"];
199
+ if (typeof at !== "string") return null;
200
+ const generatedAt = parseInstant(at);
201
+ if (generatedAt === null) return null;
202
+ return {
203
+ sha: version.sha,
204
+ committedAt: version.committedAt,
205
+ generatedAt,
206
+ stamp: at,
207
+ body: comparable(split.body),
208
+ };
209
+ }
210
+
211
+ /**
212
+ * The rule, pure: for every `stable` concept whose body differs from a
213
+ * committed version that was `stable`, the tree's `generated.at` must be
214
+ * strictly LATER than that version's. Equal is the defect this rule exists to
215
+ * catch — edit the sentence, leave the stamp — and EARLIER is the same defect
216
+ * with one more keystroke: backdating the stamp changes it without advancing
217
+ * it, so the approval that ratified the old text still post-dates the new one.
218
+ * Only strictly-later clears a version, which is exactly what the refusal's
219
+ * own `fix` prints. All of history, not only HEAD's version — an edit
220
+ * committed without a bump matches HEAD exactly, and it is the version BEHIND
221
+ * it that tells (the shape CI sees). A path with no committed stable version
222
+ * passes: stable for the first time, or renamed, since path is identity.
223
+ * Instants are compared as instants, so two spellings of one moment are one
224
+ * stamp.
225
+ */
226
+ export function checkGeneratedStale(
227
+ concepts: readonly Concept[],
228
+ files: ReadonlyMap<string, string>,
229
+ versions: ReadonlyMap<string, readonly CommittedVersion[]>,
230
+ ): Refusal[] {
231
+ const refusals: Refusal[] = [];
232
+ for (const concept of concepts) {
233
+ if (concept.status !== "stable" || concept.generatedAt === null) continue;
234
+ const text = files.get(concept.path);
235
+ if (text === undefined) continue;
236
+ const split = splitFrontmatter(text, concept.path);
237
+ if (!split.ok) continue;
238
+ const body = comparable(split.body);
239
+ for (const version of versions.get(concept.path) ?? []) {
240
+ const stable = stableVersionOf(version, concept.path);
241
+ if (stable === null || stable.generatedAt < concept.generatedAt || stable.body === body) {
242
+ continue;
243
+ }
244
+ const authored = (concept.frontmatter["generated"] as { readonly at?: unknown } | undefined)
245
+ ?.at;
246
+ const stamp =
247
+ typeof authored === "string" ? authored : new Date(concept.generatedAt).toISOString();
248
+ const under =
249
+ stable.generatedAt === concept.generatedAt
250
+ ? `under the same \`generated.at\` (${stamp})`
251
+ : `under \`generated.at\` ${stable.stamp}, LATER than the ${stamp} this file now carries ` +
252
+ "(the stamp was moved backward, which changes it without advancing it)";
253
+ refusals.push({
254
+ slug: "ksor-generated-stale",
255
+ path: concept.path,
256
+ why:
257
+ `the body differs from the one committed at ${stable.sha.slice(0, 7)} (${stable.committedAt}), ` +
258
+ `where this concept was \`stable\` ${under} — that instant dates ` +
259
+ "the text, so an edit to a stable concept must advance it, or the approval that ratified the old " +
260
+ "text reads as ratifying the new one (KSP R23)",
261
+ fix:
262
+ "set `generated.at` to an instant after this edit, then re-approve: `ksor.approval.at` must not " +
263
+ "precede the new `generated.at` (`ksor-generated-after-approval`)",
264
+ });
265
+ break;
266
+ }
267
+ }
268
+ return refusals;
269
+ }
270
+
271
+ export interface ChangeControl {
272
+ readonly refusals: readonly Refusal[];
273
+ /**
274
+ * Why the check could not run, or ran short — printed beside the verdict,
275
+ * never swallowed. Null when every committed version was read.
276
+ */
277
+ readonly notice: string | null;
278
+ }
279
+
280
+ /** The two callers' one entry: read history for the stable concepts, judge, and say what could not be read. */
281
+ export function checkChangeControl(
282
+ root: string,
283
+ concepts: readonly Concept[],
284
+ files: ReadonlyMap<string, string>,
285
+ ): ChangeControl {
286
+ const paths = concepts.filter((c) => c.status === "stable").map((c) => c.path);
287
+ const history = committedVersions(root, paths);
288
+ const refusals =
289
+ history.versions === null ? [] : checkGeneratedStale(concepts, files, history.versions);
290
+ return { refusals, notice: changeControlNotice(history) };
291
+ }
292
+
293
+ /**
294
+ * Honest absence in the build's own idiom — the `source: unspecified` line
295
+ * already says the commit is unknown; this says the same of the check that
296
+ * needs one. A check that could not run is never a check that passed.
297
+ */
298
+ function changeControlNotice(history: CommittedHistory): string | null {
299
+ const what =
300
+ "so whether a stable concept's body changed under its `generated.at` (KSP R23) was not checked";
301
+ if (!history.repository) {
302
+ return `change-control: not checked — knowledge/ is not in a git repository (or git is not installed), ${what}`;
303
+ }
304
+ if (!history.born) {
305
+ return `change-control: not checked — the repository has no commits yet, ${what}`;
306
+ }
307
+ if (history.versions === null) {
308
+ return `change-control: not checked — git could not read the history of knowledge/ (\`git log -- knowledge/\` or \`git cat-file --batch\` failed), ${what}`;
309
+ }
310
+ if (history.shallow) {
311
+ const read = [...history.versions.values()].reduce((n, list) => n + list.length, 0);
312
+ return (
313
+ `change-control: checked against the ${read} committed version(s) this shallow clone holds — a stable ` +
314
+ "version beyond the shallow boundary was not read; fetch full history (`git fetch --unshallow`; in CI, " +
315
+ "`fetch-depth: 0`) to check all of it"
316
+ );
317
+ }
318
+ return null;
319
+ }
@@ -72,6 +72,14 @@ export {
72
72
  type Drafts,
73
73
  } from "./lock";
74
74
  export { git, historicLedger, type HistoricLedger } from "./git-ledger";
75
+ export {
76
+ checkChangeControl,
77
+ checkGeneratedStale,
78
+ committedVersions,
79
+ type ChangeControl,
80
+ type CommittedHistory,
81
+ type CommittedVersion,
82
+ } from "./change-control";
75
83
  export { generateIndexes, parseIndex, humanise, type IndexInput } from "./index-file";
76
84
  export { actorKind, isIndividualActor } from "./actor";
77
85
  export { checkFootnotes, linkTargets, resolveLink } from "./citations";
@@ -33,6 +33,9 @@ export type Drafts = "hidden" | "shown";
33
33
 
34
34
  const hex64 = z.string().regex(/^[0-9a-f]{64}$/, "a sha256 hex digest");
35
35
  const viewerList = z.array(z.string().min(1));
36
+ const bundleEntry = z
37
+ .object({ viewer: z.string().min(1), sha256: hex64, files: z.number().int().nonnegative() })
38
+ .strict();
36
39
 
37
40
  const lockSchema = z
38
41
  .object({
@@ -66,9 +69,22 @@ const lockSchema = z
66
69
  companions: z.array(z.object({ path: z.string().min(1), sha256: hex64 }).strict()),
67
70
  assets: z.array(z.object({ path: z.string().min(1), sha256: hex64 }).strict()),
68
71
  indexes: z.array(z.object({ path: z.string().min(1), sha256: hex64 }).strict()),
72
+ bundles: z.array(bundleEntry),
69
73
  })
70
74
  .strict();
71
75
 
76
+ /**
77
+ * One OKF bundle, as `ksor build --bundles` writes it for a canonical viewer
78
+ * (build spec §1 step 4): the digest is sha256 over the JSON of the bundle's
79
+ * sorted `[path, sha256]` pairs, so a recipient holding only the directory can
80
+ * recompute it and find the publication it came from.
81
+ */
82
+ export interface LockBundle {
83
+ readonly viewer: string;
84
+ readonly sha256: string;
85
+ readonly files: number;
86
+ }
87
+
72
88
  export interface LockDocument {
73
89
  /** Bundle-relative, with `.md`. */
74
90
  readonly path: string;
@@ -116,6 +132,24 @@ export interface Lock {
116
132
  * stopped short of the file that lists what was published.
117
133
  */
118
134
  readonly indexes: readonly { readonly path: string; readonly sha256: string }[];
135
+ /**
136
+ * One digest per canonical viewer, recorded on EVERY build and not only when
137
+ * `--bundles` wrote the directories: the bundle set is a function of what
138
+ * `build_id` already hashes, so the lock is the same lock either way, and a
139
+ * `pnpm build` on a host that never passes the flag records the same digests
140
+ * the owner's `--bundles` run did. Outside `build_id` for the same reason —
141
+ * the documents, their admitted sets, the companions, the assets and the
142
+ * instance title are already in it, so hashing the bundles again could not
143
+ * move it (build spec §2). Listed so a directory can be MATCHED to a
144
+ * publication, not to widen what the id covers. Required on read like every
145
+ * other field, and NOT read around: a lock an older ksor wrote lacks the key,
146
+ * so `ksor build` refuses it as `ksor-lock-invalid` and says to delete it —
147
+ * it does not regenerate one it cannot read, because the lock is also a
148
+ * takedown baseline and a lock nothing can read is a baseline that quietly
149
+ * holds nothing. `ksor migrate` offers that deletion, which is the migration
150
+ * decision 28 pairs the removal with.
151
+ */
152
+ readonly bundles: readonly LockBundle[];
119
153
  }
120
154
 
121
155
  export type LockResult =
@@ -254,6 +288,8 @@ export interface LockInput {
254
288
  /** Bundle-relative path → the §8 index text this build generated (`index.md`, `policies/index.md`). */
255
289
  readonly indexes: readonly { readonly path: string; readonly text: string }[];
256
290
  readonly denials: readonly Denial[];
291
+ /** The digest of each canonical viewer's bundle, in the order `canonicalViewers` lists them. */
292
+ readonly bundles: readonly LockBundle[];
257
293
  }
258
294
 
259
295
  export function composeLock(input: LockInput): Lock {
@@ -310,6 +346,7 @@ export function composeLock(input: LockInput): Lock {
310
346
  companions,
311
347
  assets,
312
348
  indexes,
349
+ bundles: input.bundles.map((b) => ({ viewer: b.viewer, sha256: b.sha256, files: b.files })),
313
350
  };
314
351
  }
315
352
 
@@ -16,6 +16,7 @@ export const REFUSAL_SLUGS = [
16
16
  "ksor-stable-unapproved",
17
17
  "ksor-approver-unauthorised",
18
18
  "ksor-generated-after-approval",
19
+ "ksor-generated-stale",
19
20
  "ksor-deprecated-unattributed",
20
21
  "ksor-deprecator-unauthorised",
21
22
  "ksor-reserved-type-unsourced",