@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
@@ -3,384 +3,430 @@ import {
3
3
  existsSync,
4
4
  mkdirSync,
5
5
  readFileSync,
6
+ lstatSync,
6
7
  readdirSync,
7
8
  rmSync,
9
+ // `statSync` is here for ONE caller, `publishSims`, and only because it walks
10
+ // the STAGE — a tree this file wrote, which holds no symlink for stat to
11
+ // follow. Everything that touches the RECORD uses `lstatSync` for the reason
12
+ // recorded at `assetTarget`, and that rule is unchanged.
8
13
  statSync,
9
14
  watch,
10
15
  writeFileSync,
11
16
  } from "node:fs";
12
17
  import path from "node:path";
13
18
 
14
- import { ATTACHMENT_SUFFIXES, isAttachment, parentDocumentOf } from "./attachment-rule";
15
- import { audienceModel, buildAudience, refuse, visibleInBuild } from "./audience";
16
- import { isDenied, recordPathFrom, stableIdFrom, type DenylistManifest } from "./denial-rule";
17
- import { appName, instanceFrontmatter } from "./shared";
18
-
19
- // Both relative to the site directory — the directory every build runs from
20
- // (`pnpm build` is `pnpm -C system/site build`), which is also how fumadocs
21
- // resolves a collection's `dir`.
22
- const RECORD_DIR = "../../knowledge";
23
- const STAGE_DIR = "./.staged-knowledge";
24
-
25
- // ONE frontmatter boundary, the checker's exactly: BOM stripped, CRLF
26
- // normalized, lax close (a `----` line closes — review finding 2026-08-19:
27
- // two boundaries in one file meant a doc one regex saw and the other
28
- // didn't, and the strict one published a restricted document).
29
- function frontmatterBlock(text: string): string {
30
- const normalized = text.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
31
- return /^---\n([\s\S]*?)\n---/.exec(normalized)?.[1] ?? "";
32
- }
33
-
34
- /** Exclusion sentinel: present-but-unreadable ranks below every tier. */
35
- const UNREADABLE = "\u0000ksor-unreadable";
36
-
19
+ import { ATTACHMENT_SUFFIXES, isAttachment } from "./attachment-rule";
20
+ import { publicSimPath, SIM_SUFFIX } from "./sim-rule";
21
+ import { refuse, viewer } from "./audience";
22
+ import { overlaps } from "./audience-rule";
23
+ import { admitsLifecycle, lifecycleBadge } from "./lifecycle-rule";
24
+ import { assertLockCoversTree, readLock } from "./lock";
25
+ import { appDescription, appName, appTitle, projectRoot } from "./shared";
26
+ import {
27
+ STAGE_DIR,
28
+ STAGE_MANIFEST,
29
+ type StageManifest,
30
+ type StagePage,
31
+ type StageStamps,
32
+ } from "./stage-manifest";
33
+ import { checkRecord } from "../record/check";
34
+ import { linkTargets } from "../record/citations";
35
+ import { splitFrontmatter } from "../record/frontmatter";
36
+ import { historicLedger } from "../record/git-ledger";
37
+ import { generateIndexes } from "../record/index-file";
38
+ import {
39
+ inForce,
40
+ denies,
41
+ parseLedger,
42
+ type LedgerBaseline,
43
+ type LedgerBaselineEntry,
44
+ } from "../record/ledger";
45
+ import { loadRecord } from "../record/load";
46
+ import type { Refusal } from "../record/refusal";
47
+
48
+ const KNOWLEDGE = "knowledge/";
49
+ const LEDGER_PATH = ".ksor/takedowns.yaml";
50
+ const POLICY_PATH = ".ksor/governance.yaml";
37
51
  /**
38
- * A document's declared tier: null when the key is absent (default applies),
39
- * UNREADABLE when the key is present but carries no scalar — a block-list
40
- * `visibility:` read as absence took the DEFAULT tier and shipped public
41
- * (review finding, 2026-08-19: the one malformed shape that failed open).
52
+ * Everything this build may publish, as bytes at bundle-relative paths: the
53
+ * admitted concepts (copied), their companions (copied), ONLY the assets those
54
+ * concepts reference (copied an image referenced by nothing published would
55
+ * otherwise ship its bytes into every build, research/visibility.md §7), and
56
+ * every directory's `index.md` REGENERATED from this filtered tree — never the
57
+ * committed one, which lists every status and every audience (record spec §1).
42
58
  */
43
- function visibilityOf(text: string): string | null {
44
- const block = frontmatterBlock(text);
45
- const match = /^visibility:[ \t]*(.*)$/m.exec(block);
46
- if (match === null) return null;
47
- const raw = (match[1] ?? "").replace(/\s+#.*$/, "").trim();
48
- const value = /^(['"])(.*)\1$/.exec(raw)?.[2] ?? raw;
49
- return value === "" ? UNREADABLE : value;
50
- }
51
-
52
- function walkFiles(dir: string): string[] {
53
- return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
54
- const p = path.join(dir, entry.name);
55
- return entry.isDirectory() ? walkFiles(p) : [p];
56
- });
59
+ interface StageEntry {
60
+ readonly rel: string;
61
+ readonly bytes: () => Buffer;
57
62
  }
58
63
 
59
- /**
60
- * Code is prose about links, never links — the same rule `pnpm check`
61
- * applies, so the checker and the stage agree on what a reference is.
62
- * Strips fenced blocks and inline code spans (per paragraph: CommonMark
63
- * spans may cross lines, and a document-wide strip lets one stray backtick
64
- * pair with another pages later).
65
- */
66
- function stripCode(text: string): string {
67
- const kept: string[] = [];
68
- let fence: { char: string; length: number } | null = null;
69
- let blank = true;
70
- let indented = false;
71
- for (const line of text.replaceAll("\r\n", "\n").split("\n")) {
72
- if (fence) {
73
- const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
74
- if (close && close[1]?.[0] === fence.char && (close[1]?.length ?? 0) >= fence.length) {
75
- fence = null;
76
- }
77
- continue;
78
- }
79
- const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
80
- if (open?.[1]) {
81
- fence = { char: open[1][0] as string, length: open[1].length };
82
- continue;
83
- }
84
- // An indented run opened after a blank line is a code block — unless it
85
- // starts a list item, which sits at exactly this indent and carries real
86
- // links.
87
- if (/^(?: {4}|\t)/.test(line) && !/^[ \t]+(?:[-*+]|\d+[.)])\s/.test(line)) {
88
- if (blank || indented) {
89
- indented = true;
90
- continue;
91
- }
92
- } else if (line.trim() !== "") {
93
- indented = false;
94
- }
95
- blank = line.trim() === "";
96
- kept.push(line);
97
- }
98
- return kept
99
- .join("\n")
100
- .split(/\n{2,}/)
101
- .map((paragraph) => paragraph.replace(/(`+)[^`]*?\1/g, " "))
102
- .join("\n\n");
64
+ interface StagePlan {
65
+ readonly entries: readonly StageEntry[];
66
+ readonly manifest: StageManifest;
103
67
  }
104
68
 
105
- // Every shape CommonMark gives a link destination inline (bare or
106
- // <angle-bracketed>, with a title) and the reference definitions that
107
- // `[text][label]` links point at. `![alt](img.png)` is the same shape.
108
- const INLINE_LINK =
109
- /\[[^\]]*\]\(\s*(<[^<>\n]*>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
110
- const REFERENCE_DEFINITION =
111
- /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(<[^<>\n]*>|\S+)[ \t]*(?:"[^"]*"|'[^']*'|\([^)]*\))?[ \t]*$/gm;
112
-
113
- function linkTargets(body: string): string[] {
114
- const raw: string[] = [];
115
- for (const match of body.matchAll(INLINE_LINK)) if (match[1]) raw.push(match[1]);
116
- for (const match of body.matchAll(REFERENCE_DEFINITION)) if (match[1]) raw.push(match[1]);
117
- // <…> exists so a destination may contain spaces; the brackets are syntax.
118
- return raw.map((t) => (t.startsWith("<") && t.endsWith(">") ? t.slice(1, -1).trim() : t));
69
+ /** The checker's refusals, printed the way every refusal here is: slug first, then the remedy. */
70
+ function refuseRecord(refusals: readonly Refusal[]): never {
71
+ const lines = refusals.map((r) => `${r.slug}: ${r.path} — ${r.why}\n fix: ${r.fix}`);
72
+ throw new Error(lines.join("\n"));
119
73
  }
120
74
 
121
75
  /**
122
- * The asset a link points at, or null when it points anywhere else: out of
123
- * the record, at another document, at a heading, or off the web entirely.
76
+ * The asset a link points at, or null when it points anywhere else. Both OKF
77
+ * §6.1 link forms: bundle-absolute against `knowledge/`, relative against the
78
+ * document's directory. `.md`/`.mdx` never ride in as assets — both render as
79
+ * pages, and a restricted `plan.mdx` staged that way once published untiered
80
+ * (review finding, 2026-08-18).
81
+ *
82
+ * Neither does an ATTACHMENT, for the same reason one level down. An attachment is
83
+ * staged with its parent or not at all — that is how it inherits its parent's
84
+ * audience, lifecycle and takedown (decision 24) — and this function probes the
85
+ * FILESYSTEM, which knows nothing about any of them. So a document linking
86
+ * `./x.flashcards.yaml` reached the deck by a second path: harmless when `x`
87
+ * was published too (the same bytes, staged twice, which is what made
88
+ * `stageHolds` answer false forever), and a governance escape when it was not —
89
+ * a link to a TAKEN-DOWN document's deck staged the deck, because the link
90
+ * rules judge a companion by its parent's AUDIENCE and the ledger is not an
91
+ * audience. Both reproduced, 2026-08-25.
124
92
  */
125
- function assetTarget(recordDir: string, documentPath: string, target: string): string | null {
126
- if (target === "" || target.startsWith("#") || target.startsWith("//")) return null;
127
- if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return null;
128
- const resolved = path.resolve(path.dirname(documentPath), target.split("#")[0] as string);
93
+ function assetTarget(recordDir: string, documentRel: string, target: string): string | null {
94
+ const clean = target.split("#")[0] ?? "";
95
+ const resolved = clean.startsWith("/")
96
+ ? path.resolve(recordDir, clean.slice(1))
97
+ : path.resolve(recordDir, path.dirname(documentRel), clean);
129
98
  if (!resolved.startsWith(recordDir + path.sep)) return null;
130
- // .md AND .mdx: both render as pages, so neither may ride in as an
131
- // "asset" — a restricted plan.mdx staged that way published untiered
132
- // (review finding, 2026-08-18). The record bans .mdx, but staging never
133
- // depends on the checker having run.
134
99
  if (/\.mdx?$/i.test(resolved)) return null;
100
+ if (isAttachment(path.basename(resolved))) return null;
135
101
  try {
136
- return statSync(resolved).isFile() ? resolved : null;
102
+ // lstat, never stat: `statSync` FOLLOWS a symlink, and `readFileSync` below
103
+ // follows it too, so `knowledge/guides/leak.png -> /etc/secret` published
104
+ // whatever the build could read, under the record's own name. The checker
105
+ // refuses a symlink under knowledge/ by name (`ksor-symlink`) and runs
106
+ // before this, so the state is unreachable — this is the second lock on the
107
+ // same door, and the one that is local to the code that would publish it.
108
+ return lstatSync(resolved).isFile() ? resolved : null;
137
109
  } catch {
138
110
  return null;
139
111
  }
140
112
  }
141
113
 
142
- /**
143
- * Everything this build may publish: the permitted documents, and ONLY the
144
- * assets those documents reference. An image referenced by nothing published
145
- * ships its filename and its bytes into every build that copies the record
146
- * wholesale (research/visibility.md §7) so the references decide.
147
- */
148
- interface StagePlan {
149
- /** Documents and assets to copy, in that order. */
150
- readonly files: readonly string[];
151
- readonly documents: number;
152
- /** Every document in the record, whatever its tier. */
153
- readonly total: number;
114
+ /** The record-relative paths of every companion of `conceptPath` the record holds. */
115
+ function companionPathsOf(conceptPath: string, companions: ReadonlyMap<string, string>): string[] {
116
+ const rel = conceptPath.slice(KNOWLEDGE.length);
117
+ return ATTACHMENT_SUFFIXES.map(({ suffix }) => rel.replace(/\.md$/, "") + suffix)
118
+ .filter((c) => companions.has(c))
119
+ .map((c) => `${KNOWLEDGE}${c}`);
154
120
  }
155
121
 
156
- /**
157
- * The stable_ids this build must NOT publish.
158
- *
159
- * A takedown lives in the database, and the site compiles `knowledge/` from
160
- * disk so a denied document stayed published on the human surface,
161
- * `llms.txt` included, the file written specifically for AI crawlers. The
162
- * database's answer is EXPORTED to this manifest (`ksor takedown --export`)
163
- * rather than opened here, because `pnpm dev` must keep working without a
164
- * database at all.
165
- *
166
- * It fails CLOSED on the one ambiguity that matters. A manifest saying
167
- * `source: "none"` is a project that declares no database — nothing can be
168
- * denied, publish everything. A manifest that is MISSING means nobody asked
169
- * the database, and this build cannot tell "no takedowns" from "the export
170
- * never ran" so a project that HAS a database refuses rather than guessing.
171
- */
172
- /** No database, or a dev server without an export: nothing is denied. */
173
- const NOTHING_DENIED: DenylistManifest = { source: "none", denied: [], denied_subtrees: [] };
174
-
175
- /** Where `ksor takedown --export` writes, relative to the project root. */
176
- const DENYLIST_FILE = ".ksor-denylist.json";
177
-
178
- /**
179
- * Does this project declare a database at all? A level-0 record does not.
180
- *
181
- * Reads through `instanceFrontmatter()`, which finds instance.md by WALKING UP
182
- * from the cwd, and which THROWS when it cannot find it. Both halves matter:
183
- * this used to join `../../` onto the cwd and answer `false` on any read
184
- * failure, so a build run from anywhere but exactly `system/site` — a host with
185
- * a configured root directory, an adopter who moved the site they own
186
- * (decision 4), a permissions error silently reported "no database". That
187
- * turned the whole fail-closed takedown gate off: a MISSING manifest became
188
- * "nothing denied" instead of a refusal, and a `source: "none"` manifest
189
- * skipped the not-from-database refusal. Both are the fail-open paths this
190
- * function exists to close (round-9 review of #43).
191
- *
192
- * A record whose identity cannot be found is an ERROR, never a `false`.
193
- */
194
- function declaresDatabase(): boolean {
195
- return /^database:/m.test(instanceFrontmatter());
196
- }
122
+ function planStage(recordDir: string, development: boolean): StagePlan {
123
+ const record = loadRecord(projectRoot);
124
+
125
+ const documents = new Map<string, string>();
126
+ const companions = new Map<string, string>();
127
+ // The COMMITTED indexes. Nothing else here reads them the stage regenerates
128
+ // its own so they exist in this map for one reason: the lock says which
129
+ // bytes `ksor build` wrote into them, and a build looking at different ones is
130
+ // looking at a record nothing checked.
131
+ const indexFiles = new Map<string, string>();
132
+ for (const file of record.files.keys()) {
133
+ if (!file.startsWith(KNOWLEDGE)) continue;
134
+ const rel = file.slice(KNOWLEDGE.length);
135
+ const name = path.basename(rel);
136
+ // The CANONICAL rule, never a copy of it (decision 18). The regex that
137
+ // used to be here was the fifth hand copy of the suffix list: it claimed
138
+ // byte-identity with the checker, had stopped being that when the checker
139
+ // moved to `attachmentKindOf`, and was missing `.summary.mdx` so the
140
+ // stage and the lock writer disagreed about the companion list.
141
+ if (isAttachment(name)) companions.set(rel, path.join(recordDir, rel));
142
+ else if (name === "index.md") indexFiles.set(rel, path.join(recordDir, rel));
143
+ else if (name.endsWith(".md")) documents.set(rel, path.join(recordDir, rel));
144
+ }
145
+ // The lock covers every asset, because this build publishes its bytes so
146
+ // the stage's asset set must be the SET THE LOCK WAS BUILT FROM, not a second
147
+ // opinion about it. It is therefore taken from the record the loader already
148
+ // read, the same one `composeLock` reads. Re-walking the directory instead
149
+ // was two walkers with two answers, and both differences were live bugs: the
150
+ // loader skips OS junk, so a `.DS_Store` that Finder writes the first time an
151
+ // adopter opens `knowledge/` was in the tree, never in the lock, and refused
152
+ // `ksor-lock-stale` unfixable, because the remedy that refusal prescribes
153
+ // writes the identical lock. And the loader reads no symlink as bytes, so a
154
+ // symlinked asset read stale here before ever reaching its own `ksor-symlink`.
155
+ const assetFiles = new Map<string, string>();
156
+ for (const file of record.assets.keys()) {
157
+ if (!file.startsWith(KNOWLEDGE)) continue;
158
+ const rel = file.slice(KNOWLEDGE.length);
159
+ assetFiles.set(rel, path.join(recordDir, rel));
160
+ }
197
161
 
198
- function deniedStableIds(recordDir: string): DenylistManifest {
199
- const manifestPath = path.join(recordDir, "..", DENYLIST_FILE);
200
- let raw: string;
201
- try {
202
- raw = readFileSync(manifestPath, "utf8");
203
- } catch {
204
- if (!declaresDatabase()) return NOTHING_DENIED;
205
- // `pnpm dev` never runs the export (it needs a live DSN), so refusing here
206
- // stopped the site running locally at all for any record with a database —
207
- // a governance guard that broke the everyday loop (round-1 review of #43).
208
- // Development warns and shows everything; a BUILD, which is what publishes,
209
- // still refuses.
210
- if (process.env.NODE_ENV === "development") {
211
- console.warn(
212
- `[ksor] ${DENYLIST_FILE} is absent, so this dev server shows the record UNFILTERED by ` +
213
- `takedowns. Run \`ksor takedown --instance instance.md --export ${DENYLIST_FILE}\` ` +
214
- "to see what a build would publish.",
162
+ const draftsRequested = process.env.KSOR_DRAFTS === "show";
163
+ // Three questions, in the order that makes each one answerable.
164
+ //
165
+ // FIRST the lock itself: is there one, can it be read, does it still describe
166
+ // this instance's governance and this build's switches? It is read before the
167
+ // checker because the checker needs one of the two `ksor-ledger-amended`
168
+ // baselines out of it — the lock records each ledger entry's DIGEST, which is
169
+ // the only thing that can see an entry retargeted in place (same id, same
170
+ // actor, a different `stable_id`) and `readLock` has already refused a
171
+ // ledger the lock never saw, so the baseline is one the lock stands behind.
172
+ const lock = development
173
+ ? null
174
+ : readLock(
175
+ projectRoot,
176
+ {
177
+ instance: record.files.get("instance.md") ?? "",
178
+ policy: record.files.get(POLICY_PATH) ?? "",
179
+ ledger: record.files.get(LEDGER_PATH) ?? null,
180
+ },
181
+ { draftsRequested },
215
182
  );
216
- return NOTHING_DENIED;
217
- }
218
- refuse(
219
- "ksor-denylist-missing",
220
- `instance.md declares a database but ${DENYLIST_FILE} is not there`,
221
- "a takedown is recorded in the database and the site builds from disk, so without the export this build cannot tell 'nothing is denied' from 'nobody asked' — and publishing a withdrawn document is the failure that matters",
222
- `run: ksor takedown --instance instance.md --export ${DENYLIST_FILE}`,
223
- );
183
+
184
+ // THEN the record, by its own rules — ONE rule set: the same checker
185
+ // `ksor build` and `ksor ingest` run, over the same in-memory tree. Staging
186
+ // never depends on the checker having run elsewhere — a red record refuses
187
+ // HERE, by its slug, before any byte moves.
188
+ const checked = checkRecord(record, {
189
+ mode: "build",
190
+ ledgerBaselines: lock === null ? [] : ledgerBaselines(lock.ledger_entries),
191
+ });
192
+ if (checked.refusals.length > 0 || checked.policy === null) refuseRecord(checked.refusals);
193
+ // LAST, whether the lock describes this tree file by file. A tree that is not
194
+ // a legal record is not eligible for that question: it was refused above by
195
+ // the rule it actually breaks (see `assertLockCoversTree`).
196
+ if (lock !== null) {
197
+ assertLockCoversTree(lock, {
198
+ documents,
199
+ companions,
200
+ assets: assetFiles,
201
+ indexes: indexFiles,
202
+ });
224
203
  }
225
- let parsed: DenylistManifest;
226
- try {
227
- parsed = JSON.parse(raw) as typeof parsed;
228
- } catch {
204
+ const policy = checked.policy;
205
+ // Lifecycle is evaluated at the lock's `as_of` for a build (staleness leaves
206
+ // the open web on the next build; a scheduled rebuild is the operator's
207
+ // obligation) and at now in development, where nothing is published.
208
+ const asOf = lock === null ? Date.now() : Date.parse(lock.as_of);
209
+ const drafts: "hidden" | "shown" = lock === null ? "shown" : lock.drafts;
210
+ const registry = lock === null ? policy.audiences : lock.audiences.registry;
211
+ const stamps: StageStamps =
212
+ lock === null
213
+ ? { build_id: null, source_commit: null, dirty: false, ksor_version: null, unstamped: true }
214
+ : {
215
+ build_id: lock.build_id,
216
+ source_commit: lock.source_commit,
217
+ dirty: lock.dirty,
218
+ ksor_version: lock.ksor_version,
219
+ unstamped: false,
220
+ };
221
+
222
+ const audiences = viewer();
223
+ for (const id of audiences) {
224
+ if (id === "public" || registry.includes(id)) continue;
229
225
  refuse(
230
- "ksor-denylist-unreadable",
231
- `${DENYLIST_FILE} is not valid JSON`,
232
- "an unreadable denylist is indistinguishable from an empty one, and the difference is whether a withdrawn document gets published",
233
- `re-export it: ksor takedown --instance instance.md --export ${DENYLIST_FILE}`,
226
+ "ksor-viewer-unregistered",
227
+ `KSOR_AUDIENCE names "${id}", which the record's registry does not declare (registered: ${registry.join(", ") || "none"})`,
228
+ "an unknown identifier is a typo, and a typo in a viewer would silently build the public site under a name that promised more",
229
+ `build with public and registered audiences only, or register "${id}" in .ksor/governance.yaml and run ksor build`,
234
230
  );
235
231
  }
236
- // The `source` field is the manifest's own account of WHO answered, and
237
- // until round 4 of the #43 review nothing read it so file presence was the
238
- // entire fail-closed gate, and any path that created a file defeated it. A
239
- // record that declares a database can only be answered BY that database:
240
- // `source: "none"` here is a contradiction, and it is precisely the shape a
241
- // build host with no DSN used to write before exiting 0.
242
- // WHOSE record is this? The manifest names its corpus and nothing checked
243
- // it, so a file exported against a different instance or copied between two
244
- // records in one repo passed the fail-closed gate and applied the wrong
245
- // denial set: this record's withdrawn documents published while unrelated ids
246
- // were filtered (round-5 review of #43).
247
- const expected = appName;
248
- if (parsed.corpus_id !== undefined && parsed.corpus_id !== expected) {
249
- refuse(
250
- "ksor-denylist-wrong-record",
251
- `${DENYLIST_FILE} was exported for ${JSON.stringify(parsed.corpus_id)}, but this record is ${JSON.stringify(expected)}`,
252
- "denials are identities within ONE record, so another record's list filters the wrong documents and publishes this record's withdrawn ones",
253
- `re-export it for this record: ksor takedown --instance instance.md --export ${DENYLIST_FILE}`,
254
- );
232
+
233
+ // Denials from the ledger, in ledger order: in force and unrevoked. The
234
+ // checker already validated every entry's actor against the policy.
235
+ const ledger = parseLedger(record.files.get(LEDGER_PATH) ?? null, LEDGER_PATH);
236
+ if (!ledger.ok) refuseRecord(ledger.refusals);
237
+ const denials = inForce(ledger.ledger);
238
+
239
+ // Keyed by the staged path, so ONE entry exists per file the stage holds
240
+ // however many rules asked for it. `stageHolds` compares the plan's LENGTH
241
+ // to the file count before it compares any bytes, so a path emitted twice
242
+ // made it answer false forever — and that is not a lost optimisation, it is
243
+ // the freshness check that stands between a build and the half-written stage
244
+ // `withStageLock` records (27 of 48 runs, published short and silent). A rel
245
+ // determines its own bytes, so collapsing by it can never pick a side.
246
+ const entries = new Map<string, StageEntry>();
247
+ const assets = new Set<string>();
248
+ const pages: Record<string, StagePage> = {};
249
+ const admitted: { id: string; title: string; description: string; order: number | null }[] = [];
250
+ const copy = (rel: string, from: string): void => {
251
+ entries.set(rel, { rel, bytes: () => readFileSync(from) });
252
+ };
253
+
254
+ for (const concept of checked.concepts) {
255
+ // A takedown beats every other consideration, on every surface and for
256
+ // every viewer; then the overlap rule; then the §2.5 table.
257
+ if (denies(denials, concept.id)) continue;
258
+ if (!overlaps(audiences, concept.audience)) continue;
259
+ const doc = {
260
+ status: concept.status,
261
+ effectiveFrom: concept.effectiveFrom,
262
+ staleAfter: concept.staleAfter,
263
+ };
264
+ if (!admitsLifecycle(doc, "human", asOf, drafts)) continue;
265
+
266
+ const rel = concept.path.slice(KNOWLEDGE.length);
267
+ copy(rel, path.join(recordDir, rel));
268
+ pages[rel] = {
269
+ machine: admitsLifecycle(doc, "machine", asOf, drafts),
270
+ badge: lifecycleBadge(doc, asOf),
271
+ status: concept.status,
272
+ supersededBy: concept.supersededBy,
273
+ audience: concept.audience,
274
+ };
275
+ admitted.push({
276
+ id: concept.id,
277
+ title: concept.title,
278
+ description: concept.description,
279
+ order: concept.order,
280
+ });
281
+ // The parent survived every filter, so its companions may be published.
282
+ // Reached only here: there is no path on which a companion is staged
283
+ // without its parent — governance inheritance obtained by POSITION.
284
+ for (const { suffix } of ATTACHMENT_SUFFIXES) {
285
+ const companion = rel.replace(/\.md$/, "") + suffix;
286
+ if (companions.has(companion)) copy(companion, companions.get(companion)!);
287
+ }
288
+ // The concept's own links AND its companions': the checker validates a
289
+ // summary's links against the parent's audience (record/check.ts), so an
290
+ // image referenced only from `<doc>.summary.md` is in the lock, inside
291
+ // `build_id`, and demanded to exist — while the stage never copied it and
292
+ // the export died with "Module not found" against a generated
293
+ // `.staged-knowledge/*.js` path, naming no record file. The checker's link
294
+ // set and the stage's copy set are the same set.
295
+ const bodies = [concept.path, ...companionPathsOf(concept.path, companions)];
296
+ for (const file of bodies) {
297
+ const text = record.files.get(file) ?? "";
298
+ const split = splitFrontmatter(text, file);
299
+ for (const target of linkTargets(split.ok ? split.body : text)) {
300
+ const asset = assetTarget(recordDir, rel, target);
301
+ if (asset !== null) assets.add(asset);
302
+ }
303
+ }
255
304
  }
256
- if (parsed.format !== undefined && parsed.format !== 1) {
257
- refuse(
258
- "ksor-denylist-format",
259
- `${DENYLIST_FILE} declares format ${JSON.stringify(parsed.format)}, which this site cannot read`,
260
- "a manifest shape this build does not understand cannot be trusted to say what is withdrawn",
261
- "upgrade the site, or re-export with a matching ksor version",
262
- );
305
+ for (const asset of assets) {
306
+ copy(path.relative(recordDir, asset).split(path.sep).join("/"), asset);
263
307
  }
264
- if (parsed.source !== "database" && declaresDatabase()) {
308
+
309
+ // Three states, and only the middle one is a mistake. An empty RECORD is
310
+ // refused upstream (`ksor-record-empty`). A record nobody has approved yet —
311
+ // every concept a draft, which is where an owner lands after replacing the
312
+ // samples with their own first documents — BUILDS, publishing nothing (build
313
+ // spec §4, acceptance 4): the first governance act is one conversational turn
314
+ // away and a wall here would meet the adopter before the record does. An
315
+ // empty VIEWER over a record that HAS approved knowledge is the
316
+ // misconfiguration, and it would otherwise surface as "the record has no
317
+ // documents" against a record full of them.
318
+ const approved = checked.concepts.some((c) => c.status === "stable");
319
+ if (admitted.length === 0 && checked.concepts.length > 0 && approved) {
265
320
  refuse(
266
- "ksor-denylist-not-from-database",
267
- `${DENYLIST_FILE} reports source=${JSON.stringify(parsed.source ?? "(absent)")}, but instance.md declares a database`,
268
- "a takedown lives in that database, so a manifest that did not come from it cannot say what is withdrawn — and a manifest claiming nothing is denied is exactly what a build host with no DSN would write",
269
- `export the DSN for this build and re-export: ksor takedown --instance instance.md --export ${DENYLIST_FILE}`,
321
+ "ksor-audience-empty",
322
+ `no concept in the record is admitted for the [${audiences.join(", ")}] viewer at ${new Date(asOf).toISOString()} (${checked.concepts.length} concept${checked.concepts.length === 1 ? "" : "s"}, none stable, effective, in-audience and undenied)`,
323
+ "a site with nothing on it is a deploy that looks successful and serves nobody — and the record is not empty, this viewer's slice of it is",
324
+ "build a wider viewer with KSOR_AUDIENCE, approve a draft, or check the ledger",
270
325
  );
271
326
  }
272
- return parsed;
327
+
328
+ const indexes = generateIndexes({
329
+ title: appTitle,
330
+ concepts: admitted,
331
+ dirs: record.dirs.filter((d) => d.startsWith(KNOWLEDGE)).map((d) => d.slice(KNOWLEDGE.length)),
332
+ });
333
+ for (const [rel, text] of indexes) entries.set(rel, { rel, bytes: () => Buffer.from(text) });
334
+
335
+ return {
336
+ entries: [...entries.values()],
337
+ manifest: {
338
+ format: 1,
339
+ name: appName,
340
+ title: appTitle,
341
+ description: appDescription,
342
+ viewer: [...audiences],
343
+ asOf: lock === null ? new Date(asOf).toISOString() : lock.as_of,
344
+ drafts,
345
+ stamps,
346
+ pages,
347
+ },
348
+ };
273
349
  }
274
350
 
275
351
  /**
276
- * Is this document denied? Exact ids, plus the directories a `--subtree`
277
- * takedown governs.
352
+ * BOTH baselines the ledger is judged against the lock's, and git history's.
278
353
  *
279
- * `ksor takedown --export` expands a `--subtree` denial to its actual
280
- * descendants by walking parent_id, where the tree lives. Interpreting SCOPE
281
- * here meant prefix-matching stable_ids, and a section's stable_id ends in
282
- * `/index` (or `#section`), so the prefix never matched its children and every
283
- * descendant kept publishing — the failure decision 14 records as the reason
284
- * its own walk uses parent_id rather than a prefix (round-2 review of #43).
354
+ * The lock alone is not enough here, and the reason is the reason the emitted
355
+ * checker reads history too: the lock is hand-editable and travels in the SAME
356
+ * change as the ledger, so deleting an entry, recomputing `ledger_sha256` and
357
+ * emptying `ledger_entries` leaves the two agreeing about a denial that is
358
+ * gone. Walked: the denied document was staged again, exit 0.
285
359
  *
286
- * But an expanded list can only name what the ACTIVE GENERATION contains, and
287
- * this build reads DISK. A document added under a withdrawn section after the
288
- * last ingest is on disk and not in the database, so it published to /docs and
289
- * llms.txt under a section that had been explicitly withdrawn — while decision
290
- * 14 states outright that a subtree deny must cover descendants a future
291
- * re-ingest adds (round-5 review of #43).
292
- *
293
- * So subtree denials also arrive as DIRECTORIES. That is not the rejected
294
- * prefix match: these paths come from `sources.origin_path`, so they are real
295
- * locations on disk, and a document's location cannot be decoupled from itself
296
- * by a frontmatter `sor_id:` the way its id can.
360
+ * History it cannot read is SAID, not assumed away. A build that refused every
361
+ * shallow CI checkout would be turned off, and `ksor build` refuses that state
362
+ * outright so this is a note beside the verdict, not a second refusal.
297
363
  */
298
- /**
299
- * The record's stable_id and its record-frame path, for the denial check.
300
- *
301
- * The RULE itself lives in `./denial-rule`, a leaf with no imports — these
302
- * wrappers only supply what this module knows: where the record directory is.
303
- */
304
- function relativeToRecord(recordDir: string, file: string): string {
305
- return path.relative(recordDir, file).split(path.sep).join("/");
306
- }
307
-
308
- function recordPathOf(recordDir: string, file: string): string {
309
- return recordPathFrom(path.basename(recordDir), relativeToRecord(recordDir, file));
310
- }
311
-
312
- function stableIdOf(recordDir: string, file: string, text: string): string {
313
- return stableIdFrom(
314
- path.basename(recordDir),
315
- relativeToRecord(recordDir, file),
316
- frontmatterBlock(text),
317
- );
364
+ function ledgerBaselines(fromLock: readonly LedgerBaselineEntry[]): LedgerBaseline[] {
365
+ // Accepted: a passing `ksor build` wrote this lock, so its entries were
366
+ // judged against the policy of the day and are history now. The git-history
367
+ // baseline below stays unaccepted committing is not passing.
368
+ const lockBaseline: LedgerBaseline = {
369
+ source: "build.lock.json",
370
+ entries: fromLock,
371
+ accepted: true,
372
+ };
373
+ const history = historicLedger(projectRoot);
374
+ if (!history.repository) return [lockBaseline];
375
+ if (history.entries === null) {
376
+ console.error(
377
+ "ksor-ledger-unverifiable: .ksor/takedowns.yaml — the ledger's history could not be read " +
378
+ `(${history.unreadable === "shallow" ? "this is a shallow clone" : "git could not read the file's log"}), ` +
379
+ "so this build checked the ledger against the committed lock alone — an artefact that travels in the same change.\n" +
380
+ " fix: `git fetch --unshallow` (or check out with fetch-depth: 0) and build again; `ksor build` refuses this state outright",
381
+ );
382
+ return [lockBaseline];
383
+ }
384
+ return [{ source: "git history", entries: history.entries }, lockBaseline];
318
385
  }
319
386
 
320
- /**
321
- * The suffixes staging probes for — DERIVED from the shared rule, so a new
322
- * attachment kind cannot be added there and forgotten here.
323
- */
324
- const ATTACHMENT_SUFFIXES_FOR_STAGE: readonly string[] = ATTACHMENT_SUFFIXES.map((e) => e.suffix);
325
-
326
- function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
327
- const documents: string[] = [];
328
- const assets = new Set<string>();
329
- let total = 0;
330
- for (const file of walkFiles(recordDir)) {
331
- // An attachment is not a document: it is neither counted nor filtered on
332
- // its own terms. It rides in below, with the parent that survived — which
333
- // is the whole of governance inheritance, obtained by POSITION rather than
334
- // by a second rule that could disagree with this one.
335
- if (isAttachment(path.basename(file))) continue;
336
- if (!file.toLowerCase().endsWith(".md")) continue;
337
- total += 1;
338
- const text = readFileSync(file, "utf8");
339
- // An undeclared tier reads as a restriction and the document appears in
340
- // no build at all — fail closed here, and `pnpm check` (which CI runs) is
341
- // what names the typo.
342
- if (!visibleInBuild(visibilityOf(text))) continue;
343
- // A takedown beats every other consideration, on every surface.
344
- if (isDenied(denied, stableIdOf(recordDir, file, text), recordPathOf(recordDir, file)))
345
- continue;
346
- documents.push(file);
347
- // The parent survived BOTH filters, so its attachments may be published.
348
- // Reached only here: a filtered or denied parent never gets this far, so
349
- // there is no path on which an attachment is staged without its parent.
350
- for (const suffix of ATTACHMENT_SUFFIXES_FOR_STAGE) {
351
- const attachment = file.replace(/\.mdx?$/i, "") + suffix;
352
- if (existsSync(attachment)) assets.add(attachment);
353
- }
354
- // Body only: frontmatter carries no links in the record grammar, and
355
- // scanning it here while the other shell strips it staged different
356
- // asset sets from one record (review finding, 2026-08-18).
357
- const block = frontmatterBlock(text);
358
- const body = block === "" ? text : text.slice(text.indexOf(block) + block.length);
359
- for (const target of linkTargets(stripCode(body))) {
360
- const asset = assetTarget(recordDir, file, target);
361
- if (asset !== null) assets.add(asset);
362
- }
363
- }
364
- return { files: [...documents, ...assets], documents: documents.length, total };
387
+ function walkFiles(dir: string): string[] {
388
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
389
+ const p = path.join(dir, entry.name);
390
+ return entry.isDirectory() ? walkFiles(p) : [p];
391
+ });
365
392
  }
366
393
 
367
394
  /** How often a waiter looks again. */
368
395
  const LOCK_POLL_MS = 25;
369
396
  /** How long a wait goes unexplained. A build that looks hung must say why. */
370
397
  const LOCK_ANNOUNCE_MS = 10_000;
398
+ /**
399
+ * How long a lock may be held before a waiter stops believing in its holder.
400
+ *
401
+ * Enormously generous against the real contended case, which is what this bound
402
+ * has to clear: the other holder is another evaluation of the SAME build,
403
+ * staging the SAME record, and that is milliseconds for the records measured
404
+ * here — seven overlapping evaluations of a 150-document record still finish
405
+ * inside a second. Two minutes is not a guess at how long staging takes; it is
406
+ * long enough that reaching it means the holder is not staging at all.
407
+ */
408
+ const LOCK_GIVE_UP_MS = 120_000;
371
409
 
372
410
  /** Synchronous, because everything on this path is: a bundler cannot await. */
373
411
  function sleepSync(ms: number): void {
374
412
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
375
413
  }
376
414
 
377
- function isAlive(pid: number): boolean {
415
+ /**
416
+ * What a signal-0 probe can honestly say about a pid. Three answers, because
417
+ * the middle one is not proof of anything: EPERM says something with that id
418
+ * exists and is not ours to signal, which is exactly what a RECYCLED pid owned
419
+ * by another user looks like. Folding it into "alive" is what made a waiter
420
+ * believe in a holder that had been dead for hours.
421
+ */
422
+ type Liveness = "alive" | "not-ours" | "gone";
423
+
424
+ function probePid(pid: number): Liveness {
378
425
  try {
379
426
  process.kill(pid, 0);
380
- return true;
427
+ return "alive";
381
428
  } catch (error) {
382
- // EPERM is a process that exists and is not ours to signal.
383
- return (error as NodeJS.ErrnoException).code === "EPERM";
429
+ return (error as NodeJS.ErrnoException).code === "EPERM" ? "not-ours" : "gone";
384
430
  }
385
431
  }
386
432
 
@@ -402,12 +448,57 @@ function lockIsAbandoned(lockFile: string): boolean {
402
448
  return false;
403
449
  }
404
450
  const pid = Number(stamp);
405
- if (Number.isInteger(pid) && pid > 0) return !isAlive(pid);
451
+ if (Number.isInteger(pid) && pid > 0) return probePid(pid) === "gone";
406
452
  if (look === 0) sleepSync(LOCK_POLL_MS * 2);
407
453
  }
408
454
  return true;
409
455
  }
410
456
 
457
+ /** How long this lock file says it has been held; 0 once it is gone. */
458
+ function lockHeldForMs(lockFile: string): number {
459
+ try {
460
+ return Math.max(0, Date.now() - statSync(lockFile).mtimeMs);
461
+ } catch {
462
+ // Released while we looked: the next acquire attempt takes it.
463
+ return 0;
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Stop waiting, and say everything that is known about why.
469
+ *
470
+ * NOT "break the lock and carry on", which is the obvious alternative and is
471
+ * unsafe here: `fillStage` removes the stage and refills it IN PLACE, so a lock
472
+ * broken under a holder that IS still working hands the next reader a stage
473
+ * that is neither the old set nor the new one — the silent, published,
474
+ * 27-of-48 partial stage this file exists to prevent. A refusal an operator can
475
+ * act on is the honest end of an unbounded wait; publishing a short record is
476
+ * not.
477
+ */
478
+ function refuseStuckLock(lockFile: string, heldMs: number): never {
479
+ let stamp = "";
480
+ try {
481
+ stamp = readFileSync(lockFile, "utf8").trim();
482
+ } catch {
483
+ // Released as we read it — say so rather than inventing a holder.
484
+ }
485
+ const pid = Number(stamp);
486
+ const evidence =
487
+ Number.isInteger(pid) && pid > 0
488
+ ? {
489
+ alive: `process ${pid} is alive to a signal-0 probe — but a RECYCLED pid is alive too, so that is not proof this holder is the one that took the lock`,
490
+ "not-ours": `signalling process ${pid} raised EPERM: something with that id exists and is not ours to signal, which is also what a RECYCLED pid owned by another user produces`,
491
+ gone: `process ${pid} is gone, and this lock should already have been broken`,
492
+ }[probePid(pid)]
493
+ : `the file records no usable pid (${stamp === "" ? "it is empty" : JSON.stringify(stamp)})`;
494
+ refuse(
495
+ "ksor-stage-locked",
496
+ `${path.basename(lockFile)} has been held for ${Math.round(heldMs / 1000)}s`,
497
+ `one evaluation writes the stage at a time, and a holder still holding after ${Math.round(LOCK_GIVE_UP_MS / 1000)}s is not staging — it was killed before it could release (Ctrl-C, a cancelled job, an OOM: none of them run the code that removes this file). The lock is not broken automatically because the stage is removed and refilled IN PLACE, so breaking one a live holder still holds would publish a half-written record. Evidence: ${evidence}`,
498
+ `if no build is running, delete ${lockFile} and build again`,
499
+ );
500
+ }
501
+
411
502
  /**
412
503
  * Hold the stage lock for the duration of `work`: ONE evaluation writes the
413
504
  * stage at a time, and this file says which.
@@ -417,10 +508,11 @@ function lockIsAbandoned(lockFile: string): boolean {
417
508
  * (2026-08-23) — and staging was destructive on every evaluation: delete the
418
509
  * whole stage, refill it. Two of those overlapping is not a rare interleaving,
419
510
  * it is what seven of them do — six concurrent evaluations of a 150-document
420
- * record failed 42 of 48 runs, in four shapes: `ENOENT` and `EINVAL` out of `copyFileSync` (the reported one,
421
- * issue #100), `ENOTEMPTY` out of `rmSync` *with* its retries already in
422
- * place, and — 27 of the 48, the majority — no error at all: staging returned
423
- * success and handed the build a stage a third of the record short.
511
+ * record failed 42 of 48 runs, in four shapes: `ENOENT` and `EINVAL` out of
512
+ * `copyFileSync` (the reported one, issue #100), `ENOTEMPTY` out of `rmSync`
513
+ * *with* its retries already in place, and — 27 of the 48, the majority — no
514
+ * error at all: staging returned success and handed the build a stage a third
515
+ * of the record short.
424
516
  *
425
517
  * The silent shape is why this is a lock and not another retry. A crash fails
426
518
  * a build; a short stage PUBLISHES one, with documents missing from /docs,
@@ -428,12 +520,17 @@ function lockIsAbandoned(lockFile: string): boolean {
428
520
  *
429
521
  * `wx` is the whole primitive: create-if-absent, atomically, on every
430
522
  * filesystem Node supports — and it stamps the holder's pid in the same call,
431
- * so a waiter can tell a live holder from a killed one.
523
+ * so a waiter can tell a live holder from a killed one. Only from a KILLED one,
524
+ * though: a pid says nothing once it has been recycled, which is why the pid
525
+ * decides whether to break the lock and the CLOCK decides when to give up.
432
526
  *
433
- * Waiting on a LIVE holder is unbounded on purpose: it is another evaluation
434
- * of the same build, staging the same bytes from the same record, and this
435
- * build is not finished until it has. Unbounded is not silent, though a wait
436
- * long enough to look like a hang names what it is waiting for.
527
+ * Waiting on a live holder is the point: it is another evaluation of the same
528
+ * build, staging the same bytes from the same record, and this build is not
529
+ * finished until it has. The wait is BOUNDED all the same, because a build tool
530
+ * may not hang and this one did, live, on `pnpm dev` against a lock whose
531
+ * holder had been dead for hours (`LOCK_GIVE_UP_MS`, `refuseStuckLock`). A wait
532
+ * long enough to look like a hang names what it is waiting for; a wait long
533
+ * enough to BE one refuses and says what it knows.
437
534
  */
438
535
  function withStageLock<T>(stageDir: string, work: () => T): T {
439
536
  const lockFile = `${stageDir}.lock`;
@@ -444,11 +541,31 @@ function withStageLock<T>(stageDir: string, work: () => T): T {
444
541
  writeFileSync(lockFile, String(process.pid), { flag: "wx" });
445
542
  break;
446
543
  } catch (error) {
447
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
544
+ // EEXIST is "someone holds it". EPERM is the SAME THING on Windows: a
545
+ // create against a path whose file is in the pending-delete state — the
546
+ // window between another process calling `rmSync` and the filesystem
547
+ // actually releasing the name — raises EPERM, not EEXIST. Rethrowing it
548
+ // failed the build for the ordinary contended case, and only on Windows,
549
+ // and only sometimes: green on five CI runs of this same code and red on
550
+ // the next two, because it depends on landing inside a window a few
551
+ // milliseconds wide (2026-08-25, `Init acceptance (Windows)`).
552
+ //
553
+ // Waiting is safe for both: a holder that has died leaves a lock
554
+ // `lockIsAbandoned` breaks, so neither code can wait forever on a
555
+ // process that is gone.
556
+ const code = (error as NodeJS.ErrnoException).code;
557
+ if (code !== "EEXIST" && code !== "EPERM") throw error;
448
558
  if (lockIsAbandoned(lockFile)) {
449
559
  rmSync(lockFile, { force: true });
450
560
  continue;
451
561
  }
562
+ // Two ways to have waited too long, and both are real: THIS build has
563
+ // waited past the bound, or the lock has been held past it by a holder
564
+ // that may have been gone before this build started.
565
+ const held = lockHeldForMs(lockFile);
566
+ if (waited >= LOCK_GIVE_UP_MS || held >= LOCK_GIVE_UP_MS) {
567
+ refuseStuckLock(lockFile, Math.max(held, waited));
568
+ }
452
569
  sleepSync(LOCK_POLL_MS);
453
570
  waited += LOCK_POLL_MS;
454
571
  if (waited >= LOCK_ANNOUNCE_MS && !announced) {
@@ -476,11 +593,12 @@ function withStageLock<T>(stageDir: string, work: () => T): T {
476
593
  * is set (it defaults to zero). Those are what a Windows indexer or an
477
594
  * antivirus scanner holding a handle looks like — not ksor, and not something
478
595
  * the lock can serialise. Losing that race is safe: the stage is a
479
- * deterministic function of the record and the denylist, so redoing it
596
+ * deterministic function of the record, the ledger and the lock, so redoing it
480
597
  * produces the same bytes.
481
598
  */
482
599
  function removeStage(stageDir: string): void {
483
600
  rmSync(stageDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
601
+ rmSync(path.resolve(path.dirname(stageDir), STAGE_MANIFEST), { force: true });
484
602
  }
485
603
 
486
604
  /**
@@ -488,180 +606,132 @@ function removeStage(stageDir: string): void {
488
606
  *
489
607
  * The wipe-and-refill is the destructive half of staging, and it is pure waste
490
608
  * whenever the answer is yes — which is every evaluation after the first in
491
- * one build, since the plan is a deterministic function of the record and the
492
- * denylist. Skipping it is not an optimisation: while a wipe is running there
493
- * is a window in which the stage is not the record, and an evaluation that has
609
+ * one build, since the plan is a deterministic function of its inputs.
610
+ * Skipping it is not an optimisation: while a wipe is running there is a
611
+ * window in which the stage is not the record, and an evaluation that has
494
612
  * already returned is reading it. The lock stops two writers colliding; this
495
613
  * stops the second writer existing at all.
496
614
  *
497
615
  * Bytes, not names and not timestamps: the alternative is serving a previous
498
616
  * build's copy of a document that has since been edited.
499
617
  */
500
- function stageHolds(recordDir: string, stageDir: string, plan: StagePlan): boolean {
618
+ function stageHolds(stageDir: string, plan: StagePlan): boolean {
501
619
  let staged: string[];
502
620
  try {
503
621
  staged = walkFiles(stageDir);
504
622
  } catch {
505
623
  return false;
506
624
  }
507
- if (staged.length !== plan.files.length) return false;
508
- const expected = new Map(
509
- plan.files.map((from) => [path.join(stageDir, path.relative(recordDir, from)), from]),
510
- );
625
+ if (staged.length !== plan.entries.length) return false;
626
+ const expected = new Map(plan.entries.map((e) => [path.join(stageDir, e.rel), e] as const));
511
627
  for (const file of staged) {
512
- const from = expected.get(file);
513
- if (from === undefined) return false;
514
- if (!readFileSync(from).equals(readFileSync(file))) return false;
628
+ const entry = expected.get(file);
629
+ if (entry === undefined) return false;
630
+ if (!entry.bytes().equals(readFileSync(file))) return false;
515
631
  }
516
632
  return true;
517
633
  }
518
634
 
635
+ function writeManifest(stageDir: string, manifest: StageManifest): void {
636
+ writeFileSync(
637
+ path.resolve(path.dirname(stageDir), STAGE_MANIFEST),
638
+ `${JSON.stringify(manifest, null, 2)}\n`,
639
+ );
640
+ }
641
+
519
642
  /** Fill a clean stage with exactly the set this build may publish. */
520
- function fillStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
643
+ function fillStage(recordDir: string, stageDir: string, development: boolean): void {
521
644
  withStageLock(stageDir, () => {
522
645
  let plan: StagePlan;
523
646
  try {
524
- plan = planStage(recordDir, denied);
525
- // An empty record is its own problem, reported by the page that renders
526
- // it; an empty AUDIENCE is a misconfiguration that would otherwise
527
- // surface as "the record has no documents" against a record full of them.
528
- if (plan.documents === 0 && plan.total > 0) {
529
- refuse(
530
- "ksor-audience-empty",
531
- `no document in the record is visible to the ${buildAudience} build (${plan.total} document${plan.total === 1 ? "" : "s"}, all above that tier)`,
532
- "a site with nothing on it is a deploy that looks successful and serves nobody — and the record is not empty, this audience's slice of it is",
533
- "build a wider audience with KSOR_AUDIENCE, lower default_visibility in instance.md, or give at least one document this tier",
534
- );
535
- }
647
+ plan = planStage(recordDir, development);
536
648
  } catch (error) {
537
649
  // No refusal may leave the previous, more permissive stage on disk: it
538
650
  // hands the next careless build a filtered copy nothing governs (review
539
- // finding, 2026-08-19). The removal used to lead this function, which is
540
- // why nothing could ask whether the stage was already correct.
651
+ // finding, 2026-08-19).
541
652
  removeStage(stageDir);
542
653
  throw error;
543
654
  }
544
- if (stageHolds(recordDir, stageDir, plan)) return;
545
- removeStage(stageDir);
546
- for (const from of plan.files) {
547
- const to = path.join(stageDir, path.relative(recordDir, from));
548
- mkdirSync(path.dirname(to), { recursive: true });
549
- copyFileSync(from, to);
550
- }
551
- });
552
- }
553
-
554
- /**
555
- * With no audience model, `visibility:` is a promise nothing keeps: every
556
- * document publishes, including one whose author marked it restricted. The
557
- * checker refuses this record-wide; the build refuses it too, because a
558
- * deleted or mistyped `audiences:` block would otherwise publish every
559
- * restricted document on a green build (vis-docusaurus, 2026-08-18).
560
- */
561
- /**
562
- * Attachments the record cannot publish, refused at the BUILD.
563
- *
564
- * Staging never depends on the checker having run, so both rules need a home
565
- * here as well as in `pnpm check` — the checker is where they get a good
566
- * message, this is where they are guaranteed.
567
- *
568
- * Runs on every path, including the level-0 fast path that stages nothing:
569
- * an orphan is a governance hole whether or not this record declares
570
- * audiences.
571
- */
572
- function assertAttachmentsWellFormed(recordDir: string): void {
573
- for (const file of walkFiles(recordDir)) {
574
- const base = path.basename(file);
575
- if (!isAttachment(base)) continue;
576
- const rel = path.relative(recordDir, file);
577
-
578
- const parent = parentDocumentOf(base);
579
- if (parent !== null && !existsSync(path.join(path.dirname(file), parent))) {
580
- refuse(
581
- "ksor-attachment-orphan",
582
- `${rel} is an attachment of ${parent}, which is not in the record`,
583
- "an attachment inherits its parent's governance — with no parent there is nothing to inherit, so it would be published under no tier and covered by no takedown",
584
- `add ${path.join(path.dirname(rel), parent)}, or remove ${rel}`,
585
- );
586
- }
587
-
588
- // No frontmatter, at all. One rule kills the whole widening class:
589
- // no `visibility:` claiming a tier the parent does not have, no `sor_id:`
590
- // escaping the parent's takedown, no `status:`/`owner:` claiming
591
- // governance a thing with no id cannot carry.
592
- if (base.toLowerCase().endsWith(".md") || base.toLowerCase().endsWith(".mdx")) {
593
- const text = readFileSync(file, "utf8")
594
- .replace(/^\uFEFF/, "")
595
- .replaceAll("\r\n", "\n");
596
- if (text.startsWith("---\n")) {
597
- refuse(
598
- "ksor-attachment-frontmatter",
599
- `${rel} declares frontmatter`,
600
- "an attachment is part of its parent and carries none of its own governance — a key here would look like it governs something and would govern nothing",
601
- `remove the frontmatter block from ${rel}; ${parent ?? "its parent"} is what carries the governance`,
602
- );
655
+ if (!stageHolds(stageDir, plan)) {
656
+ removeStage(stageDir);
657
+ for (const entry of plan.entries) {
658
+ const to = path.join(stageDir, entry.rel);
659
+ mkdirSync(path.dirname(to), { recursive: true });
660
+ writeFileSync(to, entry.bytes());
603
661
  }
604
662
  }
605
- }
606
- }
607
-
608
- function refuseVisibilityWithoutAudiences(recordDir: string): void {
609
- for (const file of walkFiles(recordDir)) {
610
- if (!file.toLowerCase().endsWith(".md")) continue;
611
- const visibility = visibilityOf(readFileSync(file, "utf8"));
612
- if (visibility === null) continue;
613
- refuse(
614
- "ksor-visibility-without-audiences",
615
- `${path.relative(recordDir, file)} declares visibility: ${visibility}, but instance.md declares no audiences`,
616
- "without a model every document is published — this build would publish a document its author restricted, and the key saying otherwise would be the only trace",
617
- "declare the model in instance.md (audiences: least-restricted first, plus default_visibility:), or remove the visibility: key",
618
- );
619
- }
663
+ // The manifest carries `as_of`, which moves in development, so it is
664
+ // written on every evaluation — cheap, and never the reason a stage is.
665
+ writeManifest(stageDir, plan.manifest);
666
+ // A sim is SERVED, not bundled: `public/` is where a framed page needs a
667
+ // url. Published on both paths — a held stage still has to have them.
668
+ publishSims(stageDir);
669
+ });
620
670
  }
621
671
 
622
672
  /**
623
- * Dev only: carry edits into the documents the stage already holds, so
673
+ * Dev only: carry edits into the files the stage already holds, so
624
674
  * `pnpm dev` shows the record as the owner is writing it rather than as it
625
- * stood when the server started.
675
+ * stood when the server started — the regenerated indexes included, so a
676
+ * retitled document is retitled in its folder's listing too.
626
677
  *
627
678
  * Edits only — never adds, never removals. fumadocs' own watcher cannot see
628
679
  * a dot-prefixed collection directory (measured 2026-08-18: adding a file to
629
680
  * the stage regenerated nothing, and removing one left the generated imports
630
681
  * pointing at a file that was gone), so a document that ARRIVES or changes
631
- * tier needs the restart `pnpm dev` already needs for instance.md. Leaving
682
+ * audience needs the restart `pnpm dev` already needs for instance.md. Leaving
632
683
  * that to a restart keeps dev honest in the direction that matters: the
633
684
  * published build is always staged from scratch.
634
685
  */
635
- function refreshStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
686
+ function refreshStage(recordDir: string, stageDir: string): void {
636
687
  // Under the lock like every other write here: a save landing while another
637
688
  // evaluation is refilling the stage is the same race from the other side.
638
689
  withStageLock(stageDir, () => {
639
- const permitted = new Set(planStage(recordDir, denied).files);
690
+ const plan = planStage(recordDir, true);
691
+ const permitted = new Map(plan.entries.map((e) => [path.join(stageDir, e.rel), e] as const));
640
692
  for (const staged of walkFiles(stageDir)) {
641
- const from = path.join(recordDir, path.relative(stageDir, staged));
642
- if (!permitted.has(from)) continue;
643
- if (readFileSync(from).equals(readFileSync(staged))) continue;
644
- copyFileSync(from, staged);
693
+ const entry = permitted.get(staged);
694
+ if (entry === undefined) continue;
695
+ const bytes = entry.bytes();
696
+ if (bytes.equals(readFileSync(staged))) continue;
697
+ writeFileSync(staged, bytes);
645
698
  }
699
+ writeManifest(stageDir, plan.manifest);
646
700
  });
647
701
  }
648
702
 
649
703
  let watching = false;
650
704
 
651
705
  /**
652
- * Watch the record in development, never in a build — and unref'd, so this
653
- * can never be the reason a process refuses to exit.
706
+ * Watch the record in development, never in a build — and NEVER the reason a
707
+ * process refuses to exit.
708
+ *
709
+ * `persistent: false` is what makes that true, and `unref()` alone did not.
710
+ * On macOS and Windows a recursive watch is native and `unref()` unrefs the
711
+ * one handle behind it; everywhere else — Linux, so every container and every
712
+ * CI runner — Node substitutes a JS implementation
713
+ * (`internal/fs/recursive_watch`) that opens one watcher PER DIRECTORY and
714
+ * whose `unref()` walks a map of `Stats` objects unrefing anything that is
715
+ * `instanceof StatWatcher`. Nothing in that map ever is, so `unref()` is a
716
+ * silent no-op there and every one of those watchers — created `persistent`,
717
+ * because that is `fs.watch`'s default — holds the event loop open forever.
718
+ *
719
+ * Measured as a build that never ends: an evaluation with NODE_ENV=development
720
+ * exits in milliseconds on macOS and never exits on Linux, so `spawnSync`
721
+ * waited on it and one CI job died at its 15-minute timeout with no file named
722
+ * (2026-08-25). A non-persistent watcher still delivers every event while the
723
+ * dev server holds the process open, which is the only time this runs.
654
724
  */
655
725
  function watchRecord(recordDir: string, stageDir: string): void {
656
726
  if (process.env.NODE_ENV !== "development" || watching) return;
657
727
  watching = true;
658
728
  let pending: ReturnType<typeof setTimeout> | null = null;
659
- const watcher = watch(recordDir, { recursive: true }, () => {
729
+ const watcher = watch(recordDir, { recursive: true, persistent: false }, () => {
660
730
  if (pending !== null) clearTimeout(pending);
661
731
  // Debounced: one save is several filesystem events.
662
732
  pending = setTimeout(() => {
663
733
  try {
664
- refreshStage(recordDir, stageDir, deniedStableIds(recordDir));
734
+ refreshStage(recordDir, stageDir);
665
735
  } catch {
666
736
  // An editor saving atomically, or a file being moved, is a record
667
737
  // that is briefly incomplete — the next event re-runs this, and a
@@ -674,9 +744,11 @@ function watchRecord(recordDir: string, stageDir: string): void {
674
744
  }
675
745
 
676
746
  /**
677
- * The directory the docs collection reads: the record itself when this
678
- * instance declares no audiences (exactly the behaviour of every instance
679
- * written before the key existed), a staged per-audience copy when it does.
747
+ * The directory the docs collection reads: ALWAYS a staged projection of the
748
+ * record for this build's viewer, never the record itself. The level-0 fast
749
+ * path that served `knowledge/` raw is gone, because no record is safe to
750
+ * serve raw any more: every one has drafts, a ledger, and indexes that list
751
+ * what this viewer may not see (build spec §3).
680
752
  *
681
753
  * Every surface reads the record through that one collection, so filtering
682
754
  * the directory behind it filters all of them at once — pages, page tree,
@@ -686,32 +758,131 @@ function watchRecord(recordDir: string, stageDir: string): void {
686
758
  * per-request filter leaked on the fifth and sixth consumer of the record
687
759
  * its own author had not enumerated (research/visibility.md §4–§5).
688
760
  */
761
+
762
+ /**
763
+ * A sim is the one asset that has to be SERVED rather than bundled: it is a
764
+ * page, and a page needs a url before anything can frame it. Next copies
765
+ * `public/` into the export as-is, so that is where it goes.
766
+ *
767
+ * A PASS OF ITS OWN, over the directory the collection actually reads — not a
768
+ * rider on staging. Staging runs only for a record that declares `audiences:`
769
+ * or carries a takedown, and most records declare neither, so a sim hung off
770
+ * it published for the rare record and silently vanished for the common one
771
+ * (found live 2026-08-24: nothing reached `public/` on the level-0 path).
772
+ *
773
+ * That fast path is gone (decision 27: no record is safe to serve raw once
774
+ * drafts and lifecycle decide what publishes), so this now always walks the
775
+ * STAGED directory — which means it inherits the audience filter AND the
776
+ * staging plan's rule that only a REFERENCED asset ships. The level-0 caveat
777
+ * this comment used to carry no longer has a case that reaches it.
778
+ */
779
+ const PUBLIC_SIM_DIR = "./public/sims";
780
+
781
+ function publishSims(sourceDir: string): void {
782
+ const target = path.resolve(process.cwd(), PUBLIC_SIM_DIR);
783
+ /** Absolute paths this build publishes — everything else under `target` is last build's. */
784
+ const published = new Set<string>();
785
+
786
+ const walk = (dir: string, rel: string): void => {
787
+ let entries;
788
+ try {
789
+ entries = readdirSync(dir, { withFileTypes: true });
790
+ } catch {
791
+ return;
792
+ }
793
+ for (const entry of entries) {
794
+ const from = path.join(dir, entry.name);
795
+ const next = rel === "" ? entry.name : `${rel}/${entry.name}`;
796
+ if (entry.isDirectory()) {
797
+ walk(from, next);
798
+ continue;
799
+ }
800
+ if (!entry.name.endsWith(SIM_SUFFIX)) continue;
801
+ const to = path.join(target, publicSimPath(next));
802
+ published.add(path.resolve(to));
803
+ // Same size AND same mtime is this file's own definition of unchanged
804
+ // (see `stageHolds`). Skipping the write is what keeps the common
805
+ // build from touching the tree at all.
806
+ try {
807
+ const source = statSync(from);
808
+ const published = statSync(to);
809
+ if (published.size === source.size && published.mtimeMs >= source.mtimeMs) continue;
810
+ } catch {
811
+ // Not published yet, which is the ordinary first-build case.
812
+ }
813
+ mkdirSync(path.dirname(to), { recursive: true });
814
+ copyFileSync(from, to);
815
+ }
816
+ };
817
+
818
+ // The CALLER holds the stage lock, and this takes none of its own — for the
819
+ // reason `withStageLock` records at length, plus one this change learned on
820
+ // Windows: taking it a SECOND time per evaluation doubles the create/delete
821
+ // churn on one lock file, and `wx` create against a file in Windows'
822
+ // pending-delete state fails as `EPERM`, which is not `EEXIST` and so is
823
+ // rethrown. Green on macOS and Linux, red on Windows CI, from a pass that
824
+ // was correct about needing the lock and wrong about taking it again.
825
+ walk(sourceDir, "");
826
+ pruneSims(target, published);
827
+ }
828
+
829
+ /**
830
+ * Everything under `public/sims/` that THIS build did not publish, removed.
831
+ *
832
+ * Copying without pruning made the directory cumulative, and it is the one
833
+ * place where that is a governance leak rather than stale bytes: a build with
834
+ * `KSOR_AUDIENCE=public,internal` publishes an internal document's sim, the
835
+ * next plain `pnpm build` stages only public documents — correctly — and the
836
+ * internal sim is still sitting in `public/`, which static export ships
837
+ * verbatim, at a live URL. `.gitignore` hides the directory, so it accumulates
838
+ * unseen, and every existing assertion read the STAGE, which was right in both
839
+ * builds. Same shape for takedown: deny a document, rebuild, its published sim
840
+ * survives. Found by the 2026-08-25 review and reproduced before this was
841
+ * written.
842
+ *
843
+ * The directory is build-owned (`system/site/public/sims/` is gitignored, and
844
+ * nothing else writes it), so what is not published now does not belong.
845
+ */
846
+ function pruneSims(target: string, published: ReadonlySet<string>): void {
847
+ const walk = (dir: string): boolean => {
848
+ let entries;
849
+ try {
850
+ entries = readdirSync(dir, { withFileTypes: true });
851
+ } catch {
852
+ return true;
853
+ }
854
+ let empty = true;
855
+ for (const entry of entries) {
856
+ const here = path.join(dir, entry.name);
857
+ if (entry.isDirectory()) {
858
+ if (walk(here)) rmSync(here, { recursive: true, force: true });
859
+ else empty = false;
860
+ continue;
861
+ }
862
+ if (published.has(path.resolve(here))) {
863
+ empty = false;
864
+ continue;
865
+ }
866
+ rmSync(here, { force: true });
867
+ }
868
+ return empty;
869
+ };
870
+ walk(target);
871
+ }
872
+
689
873
  export function knowledgeSourceDir(): string {
690
874
  const stageDir = path.resolve(process.cwd(), STAGE_DIR);
691
- const recordDir = path.resolve(process.cwd(), RECORD_DIR);
692
- // A TAKEDOWN is not an audience concern: it must be honoured whether or not
693
- // this record declares `audiences:`, and most records do not. Staging used to
694
- // run only for an audience model, so putting the denial filter inside it
695
- // silently skipped it for exactly the common case (found live: a denied
696
- // document still built into /docs and llms.txt on a record with no
697
- // audiences).
698
- const denied = deniedStableIds(recordDir);
699
- if (audienceModel === null && (denied.denied ?? []).length === 0) {
700
- // Nothing to filter — serve the record itself, the level-0 fast path.
701
- // A stage left behind by an earlier model would be a filtered copy of the
702
- // record nothing governs any more — removed before the refusal below can
703
- // throw, so a refused build never leaves one behind either. Under the lock,
704
- // because two evaluations removing one tree is the `ENOTEMPTY` shape of the
705
- // same race; the existence check keeps a record that never stages from
706
- // taking a lock on every build.
707
- if (existsSync(stageDir)) withStageLock(stageDir, () => removeStage(stageDir));
708
- refuseVisibilityWithoutAudiences(recordDir);
709
- assertAttachmentsWellFormed(recordDir);
710
- return RECORD_DIR;
875
+ const recordDir = path.join(projectRoot, "knowledge");
876
+ if (!existsSync(recordDir)) {
877
+ refuse(
878
+ "ksor-record-missing",
879
+ `${recordDir} does not exist`,
880
+ "the record is the bundle under knowledge/; a site with nothing to project has nothing to build",
881
+ "restore knowledge/ from git history, or add the first document",
882
+ );
711
883
  }
712
- if (audienceModel === null) refuseVisibilityWithoutAudiences(recordDir);
713
- assertAttachmentsWellFormed(recordDir);
714
- fillStage(recordDir, stageDir, denied);
884
+ const development = process.env.NODE_ENV === "development";
885
+ fillStage(recordDir, stageDir, development);
715
886
  watchRecord(recordDir, stageDir);
716
887
  return STAGE_DIR;
717
888
  }