@panaversity/ksor 0.0.40 → 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 (103) hide show
  1. package/CHANGELOG.md +869 -0
  2. package/README.md +11 -7
  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 +234 -113
  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 +13 -8
  38. package/templates/scaffold/instance.md +21 -17
  39. package/templates/scaffold/knowledge/governance-ladder.md +6 -2
  40. package/templates/scaffold/knowledge/index.md +9 -0
  41. package/templates/scaffold/knowledge/surfaces/for-agents.md +7 -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 +126 -91
  53. package/templates/scaffold/system/site/app/global.css +13 -5
  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/footer-mark.tsx +3 -2
  59. package/templates/scaffold/system/site/components/governance.tsx +205 -87
  60. package/templates/scaffold/system/site/components/record-index.tsx +5 -5
  61. package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
  62. package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
  63. package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
  64. package/templates/scaffold/system/site/lib/attachments.ts +0 -28
  65. package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
  66. package/templates/scaffold/system/site/lib/audience.ts +42 -146
  67. package/templates/scaffold/system/site/lib/embed-rule.ts +9 -0
  68. package/templates/scaffold/system/site/lib/governance.ts +339 -225
  69. package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
  70. package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
  71. package/templates/scaffold/system/site/lib/lock.ts +282 -0
  72. package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
  73. package/templates/scaffold/system/site/lib/record-href.ts +68 -0
  74. package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
  75. package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
  76. package/templates/scaffold/system/site/lib/shared.ts +67 -104
  77. package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
  78. package/templates/scaffold/system/site/lib/source.ts +256 -186
  79. package/templates/scaffold/system/site/lib/stage-knowledge.ts +566 -492
  80. package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
  81. package/templates/scaffold/system/site/package.json +1 -0
  82. package/templates/scaffold/system/site/record/actor.ts +23 -0
  83. package/templates/scaffold/system/site/record/check.ts +571 -0
  84. package/templates/scaffold/system/site/record/citations.ts +312 -0
  85. package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
  86. package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
  87. package/templates/scaffold/system/site/record/hygiene.ts +320 -0
  88. package/templates/scaffold/system/site/record/index-file.ts +150 -0
  89. package/templates/scaffold/system/site/record/index.ts +103 -0
  90. package/templates/scaffold/system/site/record/instance.ts +257 -0
  91. package/templates/scaffold/system/site/record/instant.ts +43 -0
  92. package/templates/scaffold/system/site/record/ledger.ts +694 -0
  93. package/templates/scaffold/system/site/record/load.ts +129 -0
  94. package/templates/scaffold/system/site/record/lock.ts +306 -0
  95. package/templates/scaffold/system/site/record/near-miss.ts +37 -0
  96. package/templates/scaffold/system/site/record/policy.ts +414 -0
  97. package/templates/scaffold/system/site/record/profile.ts +535 -0
  98. package/templates/scaffold/system/site/record/refusal.ts +106 -0
  99. package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
  100. package/templates/scaffold/system/site/source.config.ts +77 -22
  101. package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
  102. package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
  103. package/templates/scaffold/system/site/lib/page-order.ts +0 -93
@@ -3,387 +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 { publicSimPath, SIM_SUFFIX } from "./embed-rule";
18
- import { appName, instanceFrontmatter } from "./shared";
19
-
20
- // Both relative to the site directory the directory every build runs from
21
- // (`pnpm build` is `pnpm -C system/site build`), which is also how fumadocs
22
- // resolves a collection's `dir`.
23
- const RECORD_DIR = "../../knowledge";
24
- const STAGE_DIR = "./.staged-knowledge";
25
- // Served, not bundled. Next copies public/ into the export as-is.
26
- const PUBLIC_SIM_DIR = "./public/sims";
27
-
28
- // ONE frontmatter boundary, the checker's exactly: BOM stripped, CRLF
29
- // normalized, lax close (a `----` line closes — review finding 2026-08-19:
30
- // two boundaries in one file meant a doc one regex saw and the other
31
- // didn't, and the strict one published a restricted document).
32
- function frontmatterBlock(text: string): string {
33
- const normalized = text.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
34
- return /^---\n([\s\S]*?)\n---/.exec(normalized)?.[1] ?? "";
35
- }
36
-
37
- /** Exclusion sentinel: present-but-unreadable ranks below every tier. */
38
- const UNREADABLE = "\u0000ksor-unreadable";
39
-
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";
40
51
  /**
41
- * A document's declared tier: null when the key is absent (default applies),
42
- * UNREADABLE when the key is present but carries no scalar — a block-list
43
- * `visibility:` read as absence took the DEFAULT tier and shipped public
44
- * (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).
45
58
  */
46
- function visibilityOf(text: string): string | null {
47
- const block = frontmatterBlock(text);
48
- const match = /^visibility:[ \t]*(.*)$/m.exec(block);
49
- if (match === null) return null;
50
- const raw = (match[1] ?? "").replace(/\s+#.*$/, "").trim();
51
- const value = /^(['"])(.*)\1$/.exec(raw)?.[2] ?? raw;
52
- return value === "" ? UNREADABLE : value;
53
- }
54
-
55
- function walkFiles(dir: string): string[] {
56
- return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
57
- const p = path.join(dir, entry.name);
58
- return entry.isDirectory() ? walkFiles(p) : [p];
59
- });
59
+ interface StageEntry {
60
+ readonly rel: string;
61
+ readonly bytes: () => Buffer;
60
62
  }
61
63
 
62
- /**
63
- * Code is prose about links, never links — the same rule `pnpm check`
64
- * applies, so the checker and the stage agree on what a reference is.
65
- * Strips fenced blocks and inline code spans (per paragraph: CommonMark
66
- * spans may cross lines, and a document-wide strip lets one stray backtick
67
- * pair with another pages later).
68
- */
69
- function stripCode(text: string): string {
70
- const kept: string[] = [];
71
- let fence: { char: string; length: number } | null = null;
72
- let blank = true;
73
- let indented = false;
74
- for (const line of text.replaceAll("\r\n", "\n").split("\n")) {
75
- if (fence) {
76
- const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
77
- if (close && close[1]?.[0] === fence.char && (close[1]?.length ?? 0) >= fence.length) {
78
- fence = null;
79
- }
80
- continue;
81
- }
82
- const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
83
- if (open?.[1]) {
84
- fence = { char: open[1][0] as string, length: open[1].length };
85
- continue;
86
- }
87
- // An indented run opened after a blank line is a code block — unless it
88
- // starts a list item, which sits at exactly this indent and carries real
89
- // links.
90
- if (/^(?: {4}|\t)/.test(line) && !/^[ \t]+(?:[-*+]|\d+[.)])\s/.test(line)) {
91
- if (blank || indented) {
92
- indented = true;
93
- continue;
94
- }
95
- } else if (line.trim() !== "") {
96
- indented = false;
97
- }
98
- blank = line.trim() === "";
99
- kept.push(line);
100
- }
101
- return kept
102
- .join("\n")
103
- .split(/\n{2,}/)
104
- .map((paragraph) => paragraph.replace(/(`+)[^`]*?\1/g, " "))
105
- .join("\n\n");
64
+ interface StagePlan {
65
+ readonly entries: readonly StageEntry[];
66
+ readonly manifest: StageManifest;
106
67
  }
107
68
 
108
- // Every shape CommonMark gives a link destination inline (bare or
109
- // <angle-bracketed>, with a title) and the reference definitions that
110
- // `[text][label]` links point at. `![alt](img.png)` is the same shape.
111
- const INLINE_LINK =
112
- /\[[^\]]*\]\(\s*(<[^<>\n]*>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
113
- const REFERENCE_DEFINITION =
114
- /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(<[^<>\n]*>|\S+)[ \t]*(?:"[^"]*"|'[^']*'|\([^)]*\))?[ \t]*$/gm;
115
-
116
- function linkTargets(body: string): string[] {
117
- const raw: string[] = [];
118
- for (const match of body.matchAll(INLINE_LINK)) if (match[1]) raw.push(match[1]);
119
- for (const match of body.matchAll(REFERENCE_DEFINITION)) if (match[1]) raw.push(match[1]);
120
- // <…> exists so a destination may contain spaces; the brackets are syntax.
121
- 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"));
122
73
  }
123
74
 
124
75
  /**
125
- * The asset a link points at, or null when it points anywhere else: out of
126
- * 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.
127
92
  */
128
- function assetTarget(recordDir: string, documentPath: string, target: string): string | null {
129
- if (target === "" || target.startsWith("#") || target.startsWith("//")) return null;
130
- if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return null;
131
- 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);
132
98
  if (!resolved.startsWith(recordDir + path.sep)) return null;
133
- // .md AND .mdx: both render as pages, so neither may ride in as an
134
- // "asset" — a restricted plan.mdx staged that way published untiered
135
- // (review finding, 2026-08-18). The record bans .mdx, but staging never
136
- // depends on the checker having run.
137
99
  if (/\.mdx?$/i.test(resolved)) return null;
100
+ if (isAttachment(path.basename(resolved))) return null;
138
101
  try {
139
- 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;
140
109
  } catch {
141
110
  return null;
142
111
  }
143
112
  }
144
113
 
145
- /**
146
- * Everything this build may publish: the permitted documents, and ONLY the
147
- * assets those documents reference. An image referenced by nothing published
148
- * ships its filename and its bytes into every build that copies the record
149
- * wholesale (research/visibility.md §7) so the references decide.
150
- */
151
- interface StagePlan {
152
- /** Documents and assets to copy, in that order. */
153
- readonly files: readonly string[];
154
- readonly documents: number;
155
- /** Every document in the record, whatever its tier. */
156
- 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}`);
157
120
  }
158
121
 
159
- /**
160
- * The stable_ids this build must NOT publish.
161
- *
162
- * A takedown lives in the database, and the site compiles `knowledge/` from
163
- * disk so a denied document stayed published on the human surface,
164
- * `llms.txt` included, the file written specifically for AI crawlers. The
165
- * database's answer is EXPORTED to this manifest (`ksor takedown --export`)
166
- * rather than opened here, because `pnpm dev` must keep working without a
167
- * database at all.
168
- *
169
- * It fails CLOSED on the one ambiguity that matters. A manifest saying
170
- * `source: "none"` is a project that declares no database — nothing can be
171
- * denied, publish everything. A manifest that is MISSING means nobody asked
172
- * the database, and this build cannot tell "no takedowns" from "the export
173
- * never ran" so a project that HAS a database refuses rather than guessing.
174
- */
175
- /** No database, or a dev server without an export: nothing is denied. */
176
- const NOTHING_DENIED: DenylistManifest = { source: "none", denied: [], denied_subtrees: [] };
177
-
178
- /** Where `ksor takedown --export` writes, relative to the project root. */
179
- const DENYLIST_FILE = ".ksor-denylist.json";
180
-
181
- /**
182
- * Does this project declare a database at all? A level-0 record does not.
183
- *
184
- * Reads through `instanceFrontmatter()`, which finds instance.md by WALKING UP
185
- * from the cwd, and which THROWS when it cannot find it. Both halves matter:
186
- * this used to join `../../` onto the cwd and answer `false` on any read
187
- * failure, so a build run from anywhere but exactly `system/site` — a host with
188
- * a configured root directory, an adopter who moved the site they own
189
- * (decision 4), a permissions error silently reported "no database". That
190
- * turned the whole fail-closed takedown gate off: a MISSING manifest became
191
- * "nothing denied" instead of a refusal, and a `source: "none"` manifest
192
- * skipped the not-from-database refusal. Both are the fail-open paths this
193
- * function exists to close (round-9 review of #43).
194
- *
195
- * A record whose identity cannot be found is an ERROR, never a `false`.
196
- */
197
- function declaresDatabase(): boolean {
198
- return /^database:/m.test(instanceFrontmatter());
199
- }
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
+ }
200
161
 
201
- function deniedStableIds(recordDir: string): DenylistManifest {
202
- const manifestPath = path.join(recordDir, "..", DENYLIST_FILE);
203
- let raw: string;
204
- try {
205
- raw = readFileSync(manifestPath, "utf8");
206
- } catch {
207
- if (!declaresDatabase()) return NOTHING_DENIED;
208
- // `pnpm dev` never runs the export (it needs a live DSN), so refusing here
209
- // stopped the site running locally at all for any record with a database —
210
- // a governance guard that broke the everyday loop (round-1 review of #43).
211
- // Development warns and shows everything; a BUILD, which is what publishes,
212
- // still refuses.
213
- if (process.env.NODE_ENV === "development") {
214
- console.warn(
215
- `[ksor] ${DENYLIST_FILE} is absent, so this dev server shows the record UNFILTERED by ` +
216
- `takedowns. Run \`ksor takedown --instance instance.md --export ${DENYLIST_FILE}\` ` +
217
- "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 },
218
182
  );
219
- return NOTHING_DENIED;
220
- }
221
- refuse(
222
- "ksor-denylist-missing",
223
- `instance.md declares a database but ${DENYLIST_FILE} is not there`,
224
- "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",
225
- `run: ksor takedown --instance instance.md --export ${DENYLIST_FILE}`,
226
- );
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
+ });
227
203
  }
228
- let parsed: DenylistManifest;
229
- try {
230
- parsed = JSON.parse(raw) as typeof parsed;
231
- } 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;
232
225
  refuse(
233
- "ksor-denylist-unreadable",
234
- `${DENYLIST_FILE} is not valid JSON`,
235
- "an unreadable denylist is indistinguishable from an empty one, and the difference is whether a withdrawn document gets published",
236
- `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`,
237
230
  );
238
231
  }
239
- // The `source` field is the manifest's own account of WHO answered, and
240
- // until round 4 of the #43 review nothing read it so file presence was the
241
- // entire fail-closed gate, and any path that created a file defeated it. A
242
- // record that declares a database can only be answered BY that database:
243
- // `source: "none"` here is a contradiction, and it is precisely the shape a
244
- // build host with no DSN used to write before exiting 0.
245
- // WHOSE record is this? The manifest names its corpus and nothing checked
246
- // it, so a file exported against a different instance or copied between two
247
- // records in one repo passed the fail-closed gate and applied the wrong
248
- // denial set: this record's withdrawn documents published while unrelated ids
249
- // were filtered (round-5 review of #43).
250
- const expected = appName;
251
- if (parsed.corpus_id !== undefined && parsed.corpus_id !== expected) {
252
- refuse(
253
- "ksor-denylist-wrong-record",
254
- `${DENYLIST_FILE} was exported for ${JSON.stringify(parsed.corpus_id)}, but this record is ${JSON.stringify(expected)}`,
255
- "denials are identities within ONE record, so another record's list filters the wrong documents and publishes this record's withdrawn ones",
256
- `re-export it for this record: ksor takedown --instance instance.md --export ${DENYLIST_FILE}`,
257
- );
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
+ }
258
304
  }
259
- if (parsed.format !== undefined && parsed.format !== 1) {
260
- refuse(
261
- "ksor-denylist-format",
262
- `${DENYLIST_FILE} declares format ${JSON.stringify(parsed.format)}, which this site cannot read`,
263
- "a manifest shape this build does not understand cannot be trusted to say what is withdrawn",
264
- "upgrade the site, or re-export with a matching ksor version",
265
- );
305
+ for (const asset of assets) {
306
+ copy(path.relative(recordDir, asset).split(path.sep).join("/"), asset);
266
307
  }
267
- 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) {
268
320
  refuse(
269
- "ksor-denylist-not-from-database",
270
- `${DENYLIST_FILE} reports source=${JSON.stringify(parsed.source ?? "(absent)")}, but instance.md declares a database`,
271
- "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",
272
- `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",
273
325
  );
274
326
  }
275
- 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
+ };
276
349
  }
277
350
 
278
351
  /**
279
- * Is this document denied? Exact ids, plus the directories a `--subtree`
280
- * takedown governs.
281
- *
282
- * `ksor takedown --export` expands a `--subtree` denial to its actual
283
- * descendants by walking parent_id, where the tree lives. Interpreting SCOPE
284
- * here meant prefix-matching stable_ids, and a section's stable_id ends in
285
- * `/index` (or `#section`), so the prefix never matched its children and every
286
- * descendant kept publishing — the failure decision 14 records as the reason
287
- * its own walk uses parent_id rather than a prefix (round-2 review of #43).
288
- *
289
- * But an expanded list can only name what the ACTIVE GENERATION contains, and
290
- * this build reads DISK. A document added under a withdrawn section after the
291
- * last ingest is on disk and not in the database, so it published to /docs and
292
- * llms.txt under a section that had been explicitly withdrawn — while decision
293
- * 14 states outright that a subtree deny must cover descendants a future
294
- * re-ingest adds (round-5 review of #43).
352
+ * BOTH baselines the ledger is judged against the lock's, and git history's.
295
353
  *
296
- * So subtree denials also arrive as DIRECTORIES. That is not the rejected
297
- * prefix match: these paths come from `sources.origin_path`, so they are real
298
- * locations on disk, and a document's location cannot be decoupled from itself
299
- * by a frontmatter `sor_id:` the way its id can.
300
- */
301
- /**
302
- * The record's stable_id and its record-frame path, for the denial check.
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.
303
359
  *
304
- * The RULE itself lives in `./denial-rule`, a leaf with no imports these
305
- * wrappers only supply what this module knows: where the record directory is.
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.
306
363
  */
307
- function relativeToRecord(recordDir: string, file: string): string {
308
- return path.relative(recordDir, file).split(path.sep).join("/");
309
- }
310
-
311
- function recordPathOf(recordDir: string, file: string): string {
312
- return recordPathFrom(path.basename(recordDir), relativeToRecord(recordDir, file));
313
- }
314
-
315
- function stableIdOf(recordDir: string, file: string, text: string): string {
316
- return stableIdFrom(
317
- path.basename(recordDir),
318
- relativeToRecord(recordDir, file),
319
- frontmatterBlock(text),
320
- );
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];
321
385
  }
322
386
 
323
- /**
324
- * The suffixes staging probes for — DERIVED from the shared rule, so a new
325
- * attachment kind cannot be added there and forgotten here.
326
- */
327
- const ATTACHMENT_SUFFIXES_FOR_STAGE: readonly string[] = ATTACHMENT_SUFFIXES.map((e) => e.suffix);
328
-
329
- function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
330
- const documents: string[] = [];
331
- const assets = new Set<string>();
332
- let total = 0;
333
- for (const file of walkFiles(recordDir)) {
334
- // An attachment is not a document: it is neither counted nor filtered on
335
- // its own terms. It rides in below, with the parent that survived — which
336
- // is the whole of governance inheritance, obtained by POSITION rather than
337
- // by a second rule that could disagree with this one.
338
- if (isAttachment(path.basename(file))) continue;
339
- if (!file.toLowerCase().endsWith(".md")) continue;
340
- total += 1;
341
- const text = readFileSync(file, "utf8");
342
- // An undeclared tier reads as a restriction and the document appears in
343
- // no build at all — fail closed here, and `pnpm check` (which CI runs) is
344
- // what names the typo.
345
- if (!visibleInBuild(visibilityOf(text))) continue;
346
- // A takedown beats every other consideration, on every surface.
347
- if (isDenied(denied, stableIdOf(recordDir, file, text), recordPathOf(recordDir, file)))
348
- continue;
349
- documents.push(file);
350
- // The parent survived BOTH filters, so its attachments may be published.
351
- // Reached only here: a filtered or denied parent never gets this far, so
352
- // there is no path on which an attachment is staged without its parent.
353
- for (const suffix of ATTACHMENT_SUFFIXES_FOR_STAGE) {
354
- const attachment = file.replace(/\.mdx?$/i, "") + suffix;
355
- if (existsSync(attachment)) assets.add(attachment);
356
- }
357
- // Body only: frontmatter carries no links in the record grammar, and
358
- // scanning it here while the other shell strips it staged different
359
- // asset sets from one record (review finding, 2026-08-18).
360
- const block = frontmatterBlock(text);
361
- const body = block === "" ? text : text.slice(text.indexOf(block) + block.length);
362
- for (const target of linkTargets(stripCode(body))) {
363
- const asset = assetTarget(recordDir, file, target);
364
- if (asset !== null) assets.add(asset);
365
- }
366
- }
367
- 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
+ });
368
392
  }
369
393
 
370
394
  /** How often a waiter looks again. */
371
395
  const LOCK_POLL_MS = 25;
372
396
  /** How long a wait goes unexplained. A build that looks hung must say why. */
373
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;
374
409
 
375
410
  /** Synchronous, because everything on this path is: a bundler cannot await. */
376
411
  function sleepSync(ms: number): void {
377
412
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
378
413
  }
379
414
 
380
- 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 {
381
425
  try {
382
426
  process.kill(pid, 0);
383
- return true;
427
+ return "alive";
384
428
  } catch (error) {
385
- // EPERM is a process that exists and is not ours to signal.
386
- return (error as NodeJS.ErrnoException).code === "EPERM";
429
+ return (error as NodeJS.ErrnoException).code === "EPERM" ? "not-ours" : "gone";
387
430
  }
388
431
  }
389
432
 
@@ -405,12 +448,57 @@ function lockIsAbandoned(lockFile: string): boolean {
405
448
  return false;
406
449
  }
407
450
  const pid = Number(stamp);
408
- if (Number.isInteger(pid) && pid > 0) return !isAlive(pid);
451
+ if (Number.isInteger(pid) && pid > 0) return probePid(pid) === "gone";
409
452
  if (look === 0) sleepSync(LOCK_POLL_MS * 2);
410
453
  }
411
454
  return true;
412
455
  }
413
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
+
414
502
  /**
415
503
  * Hold the stage lock for the duration of `work`: ONE evaluation writes the
416
504
  * stage at a time, and this file says which.
@@ -420,10 +508,11 @@ function lockIsAbandoned(lockFile: string): boolean {
420
508
  * (2026-08-23) — and staging was destructive on every evaluation: delete the
421
509
  * whole stage, refill it. Two of those overlapping is not a rare interleaving,
422
510
  * it is what seven of them do — six concurrent evaluations of a 150-document
423
- * record failed 42 of 48 runs, in four shapes: `ENOENT` and `EINVAL` out of `copyFileSync` (the reported one,
424
- * issue #100), `ENOTEMPTY` out of `rmSync` *with* its retries already in
425
- * place, and — 27 of the 48, the majority — no error at all: staging returned
426
- * 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.
427
516
  *
428
517
  * The silent shape is why this is a lock and not another retry. A crash fails
429
518
  * a build; a short stage PUBLISHES one, with documents missing from /docs,
@@ -431,12 +520,17 @@ function lockIsAbandoned(lockFile: string): boolean {
431
520
  *
432
521
  * `wx` is the whole primitive: create-if-absent, atomically, on every
433
522
  * filesystem Node supports — and it stamps the holder's pid in the same call,
434
- * 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.
435
526
  *
436
- * Waiting on a LIVE holder is unbounded on purpose: it is another evaluation
437
- * of the same build, staging the same bytes from the same record, and this
438
- * build is not finished until it has. Unbounded is not silent, though a wait
439
- * 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.
440
534
  */
441
535
  function withStageLock<T>(stageDir: string, work: () => T): T {
442
536
  const lockFile = `${stageDir}.lock`;
@@ -465,6 +559,13 @@ function withStageLock<T>(stageDir: string, work: () => T): T {
465
559
  rmSync(lockFile, { force: true });
466
560
  continue;
467
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
+ }
468
569
  sleepSync(LOCK_POLL_MS);
469
570
  waited += LOCK_POLL_MS;
470
571
  if (waited >= LOCK_ANNOUNCE_MS && !announced) {
@@ -492,11 +593,12 @@ function withStageLock<T>(stageDir: string, work: () => T): T {
492
593
  * is set (it defaults to zero). Those are what a Windows indexer or an
493
594
  * antivirus scanner holding a handle looks like — not ksor, and not something
494
595
  * the lock can serialise. Losing that race is safe: the stage is a
495
- * 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
496
597
  * produces the same bytes.
497
598
  */
498
599
  function removeStage(stageDir: string): void {
499
600
  rmSync(stageDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
601
+ rmSync(path.resolve(path.dirname(stageDir), STAGE_MANIFEST), { force: true });
500
602
  }
501
603
 
502
604
  /**
@@ -504,184 +606,132 @@ function removeStage(stageDir: string): void {
504
606
  *
505
607
  * The wipe-and-refill is the destructive half of staging, and it is pure waste
506
608
  * whenever the answer is yes — which is every evaluation after the first in
507
- * one build, since the plan is a deterministic function of the record and the
508
- * denylist. Skipping it is not an optimisation: while a wipe is running there
509
- * 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
510
612
  * already returned is reading it. The lock stops two writers colliding; this
511
613
  * stops the second writer existing at all.
512
614
  *
513
615
  * Bytes, not names and not timestamps: the alternative is serving a previous
514
616
  * build's copy of a document that has since been edited.
515
617
  */
516
- function stageHolds(recordDir: string, stageDir: string, plan: StagePlan): boolean {
618
+ function stageHolds(stageDir: string, plan: StagePlan): boolean {
517
619
  let staged: string[];
518
620
  try {
519
621
  staged = walkFiles(stageDir);
520
622
  } catch {
521
623
  return false;
522
624
  }
523
- if (staged.length !== plan.files.length) return false;
524
- const expected = new Map(
525
- plan.files.map((from) => [path.join(stageDir, path.relative(recordDir, from)), from]),
526
- );
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));
527
627
  for (const file of staged) {
528
- const from = expected.get(file);
529
- if (from === undefined) return false;
530
- 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;
531
631
  }
532
632
  return true;
533
633
  }
534
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
+
535
642
  /** Fill a clean stage with exactly the set this build may publish. */
536
- function fillStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
643
+ function fillStage(recordDir: string, stageDir: string, development: boolean): void {
537
644
  withStageLock(stageDir, () => {
538
645
  let plan: StagePlan;
539
646
  try {
540
- plan = planStage(recordDir, denied);
541
- // An empty record is its own problem, reported by the page that renders
542
- // it; an empty AUDIENCE is a misconfiguration that would otherwise
543
- // surface as "the record has no documents" against a record full of them.
544
- if (plan.documents === 0 && plan.total > 0) {
545
- refuse(
546
- "ksor-audience-empty",
547
- `no document in the record is visible to the ${buildAudience} build (${plan.total} document${plan.total === 1 ? "" : "s"}, all above that tier)`,
548
- "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",
549
- "build a wider audience with KSOR_AUDIENCE, lower default_visibility in instance.md, or give at least one document this tier",
550
- );
551
- }
647
+ plan = planStage(recordDir, development);
552
648
  } catch (error) {
553
649
  // No refusal may leave the previous, more permissive stage on disk: it
554
650
  // hands the next careless build a filtered copy nothing governs (review
555
- // finding, 2026-08-19). The removal used to lead this function, which is
556
- // why nothing could ask whether the stage was already correct.
651
+ // finding, 2026-08-19).
557
652
  removeStage(stageDir);
558
653
  throw error;
559
654
  }
560
- if (stageHolds(recordDir, stageDir, plan)) {
561
- publishSims(stageDir);
562
- return;
563
- }
564
- removeStage(stageDir);
565
- for (const from of plan.files) {
566
- const to = path.join(stageDir, path.relative(recordDir, from));
567
- mkdirSync(path.dirname(to), { recursive: true });
568
- copyFileSync(from, to);
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());
661
+ }
569
662
  }
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.
570
668
  publishSims(stageDir);
571
669
  });
572
670
  }
573
671
 
574
672
  /**
575
- * With no audience model, `visibility:` is a promise nothing keeps: every
576
- * document publishes, including one whose author marked it restricted. The
577
- * checker refuses this record-wide; the build refuses it too, because a
578
- * deleted or mistyped `audiences:` block would otherwise publish every
579
- * restricted document on a green build (vis-docusaurus, 2026-08-18).
580
- */
581
- /**
582
- * Attachments the record cannot publish, refused at the BUILD.
583
- *
584
- * Staging never depends on the checker having run, so both rules need a home
585
- * here as well as in `pnpm check` — the checker is where they get a good
586
- * message, this is where they are guaranteed.
587
- *
588
- * Runs on every path, including the level-0 fast path that stages nothing:
589
- * an orphan is a governance hole whether or not this record declares
590
- * audiences.
591
- */
592
- function assertAttachmentsWellFormed(recordDir: string): void {
593
- for (const file of walkFiles(recordDir)) {
594
- const base = path.basename(file);
595
- if (!isAttachment(base)) continue;
596
- const rel = path.relative(recordDir, file);
597
-
598
- const parent = parentDocumentOf(base);
599
- if (parent !== null && !existsSync(path.join(path.dirname(file), parent))) {
600
- refuse(
601
- "ksor-attachment-orphan",
602
- `${rel} is an attachment of ${parent}, which is not in the record`,
603
- "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",
604
- `add ${path.join(path.dirname(rel), parent)}, or remove ${rel}`,
605
- );
606
- }
607
-
608
- // No frontmatter, at all. One rule kills the whole widening class:
609
- // no `visibility:` claiming a tier the parent does not have, no `sor_id:`
610
- // escaping the parent's takedown, no `status:`/`owner:` claiming
611
- // governance a thing with no id cannot carry.
612
- if (base.toLowerCase().endsWith(".md") || base.toLowerCase().endsWith(".mdx")) {
613
- const text = readFileSync(file, "utf8")
614
- .replace(/^\uFEFF/, "")
615
- .replaceAll("\r\n", "\n");
616
- if (text.startsWith("---\n")) {
617
- refuse(
618
- "ksor-attachment-frontmatter",
619
- `${rel} declares frontmatter`,
620
- "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",
621
- `remove the frontmatter block from ${rel}; ${parent ?? "its parent"} is what carries the governance`,
622
- );
623
- }
624
- }
625
- }
626
- }
627
-
628
- function refuseVisibilityWithoutAudiences(recordDir: string): void {
629
- for (const file of walkFiles(recordDir)) {
630
- if (!file.toLowerCase().endsWith(".md")) continue;
631
- const visibility = visibilityOf(readFileSync(file, "utf8"));
632
- if (visibility === null) continue;
633
- refuse(
634
- "ksor-visibility-without-audiences",
635
- `${path.relative(recordDir, file)} declares visibility: ${visibility}, but instance.md declares no audiences`,
636
- "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",
637
- "declare the model in instance.md (audiences: least-restricted first, plus default_visibility:), or remove the visibility: key",
638
- );
639
- }
640
- }
641
-
642
- /**
643
- * 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
644
674
  * `pnpm dev` shows the record as the owner is writing it rather than as it
645
- * 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.
646
677
  *
647
678
  * Edits only — never adds, never removals. fumadocs' own watcher cannot see
648
679
  * a dot-prefixed collection directory (measured 2026-08-18: adding a file to
649
680
  * the stage regenerated nothing, and removing one left the generated imports
650
681
  * pointing at a file that was gone), so a document that ARRIVES or changes
651
- * 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
652
683
  * that to a restart keeps dev honest in the direction that matters: the
653
684
  * published build is always staged from scratch.
654
685
  */
655
- function refreshStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
686
+ function refreshStage(recordDir: string, stageDir: string): void {
656
687
  // Under the lock like every other write here: a save landing while another
657
688
  // evaluation is refilling the stage is the same race from the other side.
658
689
  withStageLock(stageDir, () => {
659
- 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));
660
692
  for (const staged of walkFiles(stageDir)) {
661
- const from = path.join(recordDir, path.relative(stageDir, staged));
662
- if (!permitted.has(from)) continue;
663
- if (readFileSync(from).equals(readFileSync(staged))) continue;
664
- 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);
665
698
  }
699
+ writeManifest(stageDir, plan.manifest);
666
700
  });
667
701
  }
668
702
 
669
703
  let watching = false;
670
704
 
671
705
  /**
672
- * Watch the record in development, never in a build — and unref'd, so this
673
- * 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.
674
724
  */
675
725
  function watchRecord(recordDir: string, stageDir: string): void {
676
726
  if (process.env.NODE_ENV !== "development" || watching) return;
677
727
  watching = true;
678
728
  let pending: ReturnType<typeof setTimeout> | null = null;
679
- const watcher = watch(recordDir, { recursive: true }, () => {
729
+ const watcher = watch(recordDir, { recursive: true, persistent: false }, () => {
680
730
  if (pending !== null) clearTimeout(pending);
681
731
  // Debounced: one save is several filesystem events.
682
732
  pending = setTimeout(() => {
683
733
  try {
684
- refreshStage(recordDir, stageDir, deniedStableIds(recordDir));
734
+ refreshStage(recordDir, stageDir);
685
735
  } catch {
686
736
  // An editor saving atomically, or a file being moved, is a record
687
737
  // that is briefly incomplete — the next event re-runs this, and a
@@ -694,9 +744,11 @@ function watchRecord(recordDir: string, stageDir: string): void {
694
744
  }
695
745
 
696
746
  /**
697
- * The directory the docs collection reads: the record itself when this
698
- * instance declares no audiences (exactly the behaviour of every instance
699
- * 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).
700
752
  *
701
753
  * Every surface reads the record through that one collection, so filtering
702
754
  * the directory behind it filters all of them at once — pages, page tree,
@@ -717,20 +769,19 @@ function watchRecord(recordDir: string, stageDir: string): void {
717
769
  * or carries a takedown, and most records declare neither, so a sim hung off
718
770
  * it published for the rare record and silently vanished for the common one
719
771
  * (found live 2026-08-24: nothing reached `public/` on the level-0 path).
720
- * Reading the SOURCE dir inherits the filtering when there is any, and works
721
- * when there is none. What it does NOT inherit at level 0 is the staging
722
- * plan's rule that only a REFERENCED asset ships: a record with no audiences
723
- * and no takedowns publishes every document anyway, so an unreferenced sim
724
- * ships its bytes there. Said rather than fixed, because the moment either
725
- * governance exists the staged dir is what this walks and the rule applies.
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.
726
778
  */
727
- /** Whether the record carries a sim at all — a lock nobody needs is churn. */
728
- function hasSims(dir: string): boolean {
729
- return walkFiles(dir).some((file) => file.endsWith(SIM_SUFFIX));
730
- }
779
+ const PUBLIC_SIM_DIR = "./public/sims";
731
780
 
732
781
  function publishSims(sourceDir: string): void {
733
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>();
734
785
 
735
786
  const walk = (dir: string, rel: string): void => {
736
787
  let entries;
@@ -748,6 +799,7 @@ function publishSims(sourceDir: string): void {
748
799
  }
749
800
  if (!entry.name.endsWith(SIM_SUFFIX)) continue;
750
801
  const to = path.join(target, publicSimPath(next));
802
+ published.add(path.resolve(to));
751
803
  // Same size AND same mtime is this file's own definition of unchanged
752
804
  // (see `stageHolds`). Skipping the write is what keeps the common
753
805
  // build from touching the tree at all.
@@ -771,44 +823,66 @@ function publishSims(sourceDir: string): void {
771
823
  // rethrown. Green on macOS and Linux, red on Windows CI, from a pass that
772
824
  // was correct about needing the lock and wrong about taking it again.
773
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);
774
871
  }
775
872
 
776
873
  export function knowledgeSourceDir(): string {
777
874
  const stageDir = path.resolve(process.cwd(), STAGE_DIR);
778
- const recordDir = path.resolve(process.cwd(), RECORD_DIR);
779
- // A TAKEDOWN is not an audience concern: it must be honoured whether or not
780
- // this record declares `audiences:`, and most records do not. Staging used to
781
- // run only for an audience model, so putting the denial filter inside it
782
- // silently skipped it for exactly the common case (found live: a denied
783
- // document still built into /docs and llms.txt on a record with no
784
- // audiences).
785
- const denied = deniedStableIds(recordDir);
786
- if (audienceModel === null && (denied.denied ?? []).length === 0) {
787
- // Nothing to filter — serve the record itself, the level-0 fast path.
788
- // A stage left behind by an earlier model would be a filtered copy of the
789
- // record nothing governs any more — removed before the refusal below can
790
- // throw, so a refused build never leaves one behind either. Under the lock,
791
- // because two evaluations removing one tree is the `ENOTEMPTY` shape of the
792
- // same race; the existence check keeps a record that never stages from
793
- // taking a lock on every build.
794
- refuseVisibilityWithoutAudiences(recordDir);
795
- assertAttachmentsWellFormed(recordDir);
796
- // ONE acquisition on this path too, doing both jobs — and keyed on the
797
- // stage path rather than the record's, because `${recordDir}.lock` would
798
- // drop a lock file beside `knowledge/`, in the adopter's repo, for a build
799
- // that never stages anything.
800
- const stale = existsSync(stageDir);
801
- if (stale || hasSims(recordDir)) {
802
- withStageLock(stageDir, () => {
803
- if (stale) removeStage(stageDir);
804
- publishSims(recordDir);
805
- });
806
- }
807
- 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
+ );
808
883
  }
809
- if (audienceModel === null) refuseVisibilityWithoutAudiences(recordDir);
810
- assertAttachmentsWellFormed(recordDir);
811
- fillStage(recordDir, stageDir, denied);
884
+ const development = process.env.NODE_ENV === "development";
885
+ fillStage(recordDir, stageDir, development);
812
886
  watchRecord(recordDir, stageDir);
813
887
  return STAGE_DIR;
814
888
  }