@panaversity/ksor 0.0.7 → 0.0.8

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.
@@ -10,6 +10,8 @@ import {
10
10
  import path from "node:path";
11
11
 
12
12
  import { audienceModel, buildAudience, refuse, visibleInBuild } from "./audience";
13
+ import { isDenied, recordPathFrom, stableIdFrom, type DenylistManifest } from "./denial-rule";
14
+ import { appName, instanceFrontmatter } from "./shared";
13
15
 
14
16
  // Both relative to the site directory — the directory every build runs from
15
17
  // (`pnpm build` is `pnpm -C system/site build`), which is also how fumadocs
@@ -148,7 +150,171 @@ interface StagePlan {
148
150
  readonly total: number;
149
151
  }
150
152
 
151
- function planStage(recordDir: string): StagePlan {
153
+ /**
154
+ * The stable_ids this build must NOT publish.
155
+ *
156
+ * A takedown lives in the database, and the site compiles `knowledge/` from
157
+ * disk — so a denied document stayed published on the human surface,
158
+ * `llms.txt` included, the file written specifically for AI crawlers. The
159
+ * database's answer is EXPORTED to this manifest (`ksor takedown --export`)
160
+ * rather than opened here, because `pnpm dev` must keep working without a
161
+ * database at all.
162
+ *
163
+ * It fails CLOSED on the one ambiguity that matters. A manifest saying
164
+ * `source: "none"` is a project that declares no database — nothing can be
165
+ * denied, publish everything. A manifest that is MISSING means nobody asked
166
+ * the database, and this build cannot tell "no takedowns" from "the export
167
+ * never ran" — so a project that HAS a database refuses rather than guessing.
168
+ */
169
+ /** No database, or a dev server without an export: nothing is denied. */
170
+ const NOTHING_DENIED: DenylistManifest = { source: "none", denied: [], denied_subtrees: [] };
171
+
172
+ /** Where `ksor takedown --export` writes, relative to the project root. */
173
+ const DENYLIST_FILE = ".ksor-denylist.json";
174
+
175
+ /**
176
+ * Does this project declare a database at all? A level-0 record does not.
177
+ *
178
+ * Reads through `instanceFrontmatter()`, which finds instance.md by WALKING UP
179
+ * from the cwd, and which THROWS when it cannot find it. Both halves matter:
180
+ * this used to join `../../` onto the cwd and answer `false` on any read
181
+ * failure, so a build run from anywhere but exactly `system/site` — a host with
182
+ * a configured root directory, an adopter who moved the site they own
183
+ * (decision 4), a permissions error — silently reported "no database". That
184
+ * turned the whole fail-closed takedown gate off: a MISSING manifest became
185
+ * "nothing denied" instead of a refusal, and a `source: "none"` manifest
186
+ * skipped the not-from-database refusal. Both are the fail-open paths this
187
+ * function exists to close (round-9 review of #43).
188
+ *
189
+ * A record whose identity cannot be found is an ERROR, never a `false`.
190
+ */
191
+ function declaresDatabase(): boolean {
192
+ return /^database:/m.test(instanceFrontmatter());
193
+ }
194
+
195
+ function deniedStableIds(recordDir: string): DenylistManifest {
196
+ const manifestPath = path.join(recordDir, "..", DENYLIST_FILE);
197
+ let raw: string;
198
+ try {
199
+ raw = readFileSync(manifestPath, "utf8");
200
+ } catch {
201
+ if (!declaresDatabase()) return NOTHING_DENIED;
202
+ // `pnpm dev` never runs the export (it needs a live DSN), so refusing here
203
+ // stopped the site running locally at all for any record with a database —
204
+ // a governance guard that broke the everyday loop (round-1 review of #43).
205
+ // Development warns and shows everything; a BUILD, which is what publishes,
206
+ // still refuses.
207
+ if (process.env.NODE_ENV === "development") {
208
+ console.warn(
209
+ `[ksor] ${DENYLIST_FILE} is absent, so this dev server shows the record UNFILTERED by ` +
210
+ `takedowns. Run \`ksor takedown --instance instance.md --export ${DENYLIST_FILE}\` ` +
211
+ "to see what a build would publish.",
212
+ );
213
+ return NOTHING_DENIED;
214
+ }
215
+ refuse(
216
+ "ksor-denylist-missing",
217
+ `instance.md declares a database but ${DENYLIST_FILE} is not there`,
218
+ "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",
219
+ `run: ksor takedown --instance instance.md --export ${DENYLIST_FILE}`,
220
+ );
221
+ }
222
+ let parsed: DenylistManifest;
223
+ try {
224
+ parsed = JSON.parse(raw) as typeof parsed;
225
+ } catch {
226
+ refuse(
227
+ "ksor-denylist-unreadable",
228
+ `${DENYLIST_FILE} is not valid JSON`,
229
+ "an unreadable denylist is indistinguishable from an empty one, and the difference is whether a withdrawn document gets published",
230
+ `re-export it: ksor takedown --instance instance.md --export ${DENYLIST_FILE}`,
231
+ );
232
+ }
233
+ // The `source` field is the manifest's own account of WHO answered, and
234
+ // until round 4 of the #43 review nothing read it — so file presence was the
235
+ // entire fail-closed gate, and any path that created a file defeated it. A
236
+ // record that declares a database can only be answered BY that database:
237
+ // `source: "none"` here is a contradiction, and it is precisely the shape a
238
+ // build host with no DSN used to write before exiting 0.
239
+ // WHOSE record is this? The manifest names its corpus and nothing checked
240
+ // it, so a file exported against a different instance — or copied between two
241
+ // records in one repo — passed the fail-closed gate and applied the wrong
242
+ // denial set: this record's withdrawn documents published while unrelated ids
243
+ // were filtered (round-5 review of #43).
244
+ const expected = appName;
245
+ if (parsed.corpus_id !== undefined && parsed.corpus_id !== expected) {
246
+ refuse(
247
+ "ksor-denylist-wrong-record",
248
+ `${DENYLIST_FILE} was exported for ${JSON.stringify(parsed.corpus_id)}, but this record is ${JSON.stringify(expected)}`,
249
+ "denials are identities within ONE record, so another record's list filters the wrong documents and publishes this record's withdrawn ones",
250
+ `re-export it for this record: ksor takedown --instance instance.md --export ${DENYLIST_FILE}`,
251
+ );
252
+ }
253
+ if (parsed.format !== undefined && parsed.format !== 1) {
254
+ refuse(
255
+ "ksor-denylist-format",
256
+ `${DENYLIST_FILE} declares format ${JSON.stringify(parsed.format)}, which this site cannot read`,
257
+ "a manifest shape this build does not understand cannot be trusted to say what is withdrawn",
258
+ "upgrade the site, or re-export with a matching ksor version",
259
+ );
260
+ }
261
+ if (parsed.source !== "database" && declaresDatabase()) {
262
+ refuse(
263
+ "ksor-denylist-not-from-database",
264
+ `${DENYLIST_FILE} reports source=${JSON.stringify(parsed.source ?? "(absent)")}, but instance.md declares a database`,
265
+ "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",
266
+ `export the DSN for this build and re-export: ksor takedown --instance instance.md --export ${DENYLIST_FILE}`,
267
+ );
268
+ }
269
+ return parsed;
270
+ }
271
+
272
+ /**
273
+ * Is this document denied? Exact ids, plus the directories a `--subtree`
274
+ * takedown governs.
275
+ *
276
+ * `ksor takedown --export` expands a `--subtree` denial to its actual
277
+ * descendants by walking parent_id, where the tree lives. Interpreting SCOPE
278
+ * here meant prefix-matching stable_ids, and a section's stable_id ends in
279
+ * `/index` (or `#section`), so the prefix never matched its children and every
280
+ * descendant kept publishing — the failure decision 14 records as the reason
281
+ * its own walk uses parent_id rather than a prefix (round-2 review of #43).
282
+ *
283
+ * But an expanded list can only name what the ACTIVE GENERATION contains, and
284
+ * this build reads DISK. A document added under a withdrawn section after the
285
+ * last ingest is on disk and not in the database, so it published to /docs and
286
+ * llms.txt under a section that had been explicitly withdrawn — while decision
287
+ * 14 states outright that a subtree deny must cover descendants a future
288
+ * re-ingest adds (round-5 review of #43).
289
+ *
290
+ * So subtree denials also arrive as DIRECTORIES. That is not the rejected
291
+ * prefix match: these paths come from `sources.origin_path`, so they are real
292
+ * locations on disk, and a document's location cannot be decoupled from itself
293
+ * by a frontmatter `sor_id:` the way its id can.
294
+ */
295
+ /**
296
+ * The record's stable_id and its record-frame path, for the denial check.
297
+ *
298
+ * The RULE itself lives in `./denial-rule`, a leaf with no imports — these
299
+ * wrappers only supply what this module knows: where the record directory is.
300
+ */
301
+ function relativeToRecord(recordDir: string, file: string): string {
302
+ return path.relative(recordDir, file).split(path.sep).join("/");
303
+ }
304
+
305
+ function recordPathOf(recordDir: string, file: string): string {
306
+ return recordPathFrom(path.basename(recordDir), relativeToRecord(recordDir, file));
307
+ }
308
+
309
+ function stableIdOf(recordDir: string, file: string, text: string): string {
310
+ return stableIdFrom(
311
+ path.basename(recordDir),
312
+ relativeToRecord(recordDir, file),
313
+ frontmatterBlock(text),
314
+ );
315
+ }
316
+
317
+ function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
152
318
  const documents: string[] = [];
153
319
  const assets = new Set<string>();
154
320
  let total = 0;
@@ -160,6 +326,9 @@ function planStage(recordDir: string): StagePlan {
160
326
  // no build at all — fail closed here, and `pnpm check` (which CI runs) is
161
327
  // what names the typo.
162
328
  if (!visibleInBuild(visibilityOf(text))) continue;
329
+ // A takedown beats every other consideration, on every surface.
330
+ if (isDenied(denied, stableIdOf(recordDir, file, text), recordPathOf(recordDir, file)))
331
+ continue;
163
332
  documents.push(file);
164
333
  // Body only: frontmatter carries no links in the record grammar, and
165
334
  // scanning it here while the other shell strips it staged different
@@ -175,13 +344,13 @@ function planStage(recordDir: string): StagePlan {
175
344
  }
176
345
 
177
346
  /** Fill a clean stage with exactly the set this build may publish. */
178
- function fillStage(recordDir: string, stageDir: string): void {
347
+ function fillStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
179
348
  // The old stage goes first, before any refusal can throw: a refused build
180
349
  // that leaves the previous, more permissive stage on disk hands the next
181
350
  // careless build a filtered copy nothing governs (review finding,
182
351
  // 2026-08-19).
183
352
  rmSync(stageDir, { recursive: true, force: true });
184
- const plan = planStage(recordDir);
353
+ const plan = planStage(recordDir, denied);
185
354
  // An empty record is its own problem, reported by the page that renders it;
186
355
  // an empty AUDIENCE is a misconfiguration that would otherwise surface as
187
356
  // "the record has no documents" against a record full of them.
@@ -234,8 +403,8 @@ function refuseVisibilityWithoutAudiences(recordDir: string): void {
234
403
  * that to a restart keeps dev honest in the direction that matters: the
235
404
  * published build is always staged from scratch.
236
405
  */
237
- function refreshStage(recordDir: string, stageDir: string): void {
238
- const permitted = new Set(planStage(recordDir).files);
406
+ function refreshStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
407
+ const permitted = new Set(planStage(recordDir, denied).files);
239
408
  for (const staged of walkFiles(stageDir)) {
240
409
  const from = path.join(recordDir, path.relative(stageDir, staged));
241
410
  if (!permitted.has(from)) continue;
@@ -259,7 +428,7 @@ function watchRecord(recordDir: string, stageDir: string): void {
259
428
  // Debounced: one save is several filesystem events.
260
429
  pending = setTimeout(() => {
261
430
  try {
262
- refreshStage(recordDir, stageDir);
431
+ refreshStage(recordDir, stageDir, deniedStableIds(recordDir));
263
432
  } catch {
264
433
  // An editor saving atomically, or a file being moved, is a record
265
434
  // that is briefly incomplete — the next event re-runs this, and a
@@ -287,7 +456,15 @@ function watchRecord(recordDir: string, stageDir: string): void {
287
456
  export function knowledgeSourceDir(): string {
288
457
  const stageDir = path.resolve(process.cwd(), STAGE_DIR);
289
458
  const recordDir = path.resolve(process.cwd(), RECORD_DIR);
290
- if (audienceModel === null) {
459
+ // A TAKEDOWN is not an audience concern: it must be honoured whether or not
460
+ // this record declares `audiences:`, and most records do not. Staging used to
461
+ // run only for an audience model, so putting the denial filter inside it
462
+ // silently skipped it for exactly the common case (found live: a denied
463
+ // document still built into /docs and llms.txt on a record with no
464
+ // audiences).
465
+ const denied = deniedStableIds(recordDir);
466
+ if (audienceModel === null && (denied.denied ?? []).length === 0) {
467
+ // Nothing to filter — serve the record itself, the level-0 fast path.
291
468
  // A stage left behind by an earlier model would be a filtered copy of the
292
469
  // record nothing governs any more — removed before the refusal below can
293
470
  // throw, so a refused build never leaves one behind either.
@@ -295,7 +472,8 @@ export function knowledgeSourceDir(): string {
295
472
  refuseVisibilityWithoutAudiences(recordDir);
296
473
  return RECORD_DIR;
297
474
  }
298
- fillStage(recordDir, stageDir);
475
+ if (audienceModel === null) refuseVisibilityWithoutAudiences(recordDir);
476
+ fillStage(recordDir, stageDir, denied);
299
477
  watchRecord(recordDir, stageDir);
300
478
  return STAGE_DIR;
301
479
  }