@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.
- package/CHANGELOG.md +261 -0
- package/dist/cli.mjs +2960 -823
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-CpDIVudJ.mjs → src-pl4aOpVs.mjs} +1 -0
- package/docs/index.md +8 -4
- package/package.json +2 -2
- package/schema/migrations/2.1-2.2__governance-on-the-node-row.sql +55 -0
- package/schema/migrations/2.2-2.3__takedown-writes-and-a-readable-ledger.sql +70 -0
- package/schema/migrations/2.3-2.4__a-generation-remembers-its-schema.sql +22 -0
- package/schema/schema.sql +61 -8
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +24 -2
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +24 -2
- package/templates/scaffold/AGENTS.md +95 -7
- package/templates/scaffold/README.md +45 -8
- package/templates/scaffold/env.example +82 -2
- package/templates/scaffold/gitignore +4 -0
- package/templates/scaffold/instance.md +14 -0
- package/templates/scaffold/package.json +7 -3
- package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +45 -0
- package/templates/scaffold/system/site/lib/audience-rule.ts +44 -0
- package/templates/scaffold/system/site/lib/audience.ts +4 -14
- package/templates/scaffold/system/site/lib/denial-rule.ts +212 -0
- package/templates/scaffold/system/site/lib/shared.ts +48 -0
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +186 -8
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
+
if (audienceModel === null) refuseVisibilityWithoutAudiences(recordDir);
|
|
476
|
+
fillStage(recordDir, stageDir, denied);
|
|
299
477
|
watchRecord(recordDir, stageDir);
|
|
300
478
|
return STAGE_DIR;
|
|
301
479
|
}
|