@panaversity/ksor 0.0.57 → 0.0.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { n as resolveCommand, r as verbs, t as exitCodes } from "./src-dqpI-p1a.mjs";
3
- import { A as parseViewer, B as tallyHandlers, C as contentPoolMin, D as outlineHandler, E as keyRingFromEnv, F as recordIsUndescribed, G as z$1, H as validateViewer, I as runProbe$1, L as searchHandler, M as prewarmPool, N as providerKeyEnv$1, O as parseInstanceText$1, P as readHandler, R as servingPolicy$1, S as contentPool$1, T as instancePathOf$1, U as withPgRetry$1, V as tlsPosture, W as withProbeDeadline$1, _ as assertGovernanceServable$1, a as GovernanceGateError$1, b as checkEmbeddingSpace$1, c as McpServer$1, d as READ_ONLY, f as READ_OUTPUT, g as TextSearchConfigMismatch, h as TRUST_TIERS$1, i as FLOOR, j as pooledEndpointFor, k as parseTrustFloor, l as MissingProviderKeyError$1, m as SchemaVersionError, n as ContentStoreError$1, o as MAX_OUTLINE_LIMIT, p as SEARCH_OUTPUT, r as EmbeddingSpaceMismatch$1, t as AudienceError$1, u as OUTLINE_OUTPUT, v as assertSchemaCompatible, w as embedQueryVlit, x as composeInstructions, y as buildShippedProvider$1, z as storedTextSearchConfig } from "./gateway-api-C0vL3oOK-D24n786A.mjs";
3
+ import { A as parseViewer, B as servingPolicy$1, C as contentPoolMin, D as outlineHandler, E as keyRingFromEnv, F as readHandler, G as withPgRetry$1, H as tallyHandlers, I as readPublished, K as withProbeDeadline$1, L as recordIsUndescribed, M as prewarmPool, N as providerKeyEnv$1, O as parseInstanceText$1, P as publishedGeneration, R as runProbe$1, S as contentPool$1, T as instancePathOf$1, U as tlsPosture, V as storedTextSearchConfig, W as validateViewer, _ as assertGovernanceServable$1, a as GovernanceGateError$1, b as checkEmbeddingSpace$1, c as McpServer$1, d as READ_ONLY, f as READ_OUTPUT, g as TextSearchConfigMismatch, h as TRUST_TIERS$1, i as FLOOR, j as pooledEndpointFor, k as parseTrustFloor, l as MissingProviderKeyError$1, m as SchemaVersionError, n as ContentStoreError$1, o as MAX_OUTLINE_LIMIT, p as SEARCH_OUTPUT, q as z$1, r as EmbeddingSpaceMismatch$1, t as AudienceError$1, u as OUTLINE_OUTPUT, v as assertSchemaCompatible, w as embedQueryVlit, x as composeInstructions, y as buildShippedProvider$1, z as searchHandler } from "./gateway-api-CEsb-e8z-Ch6oMaq2.mjs";
4
4
  import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { InMemoryTransport, LATEST_PROTOCOL_VERSION, createMcpHandler } from "@modelcontextprotocol/server";
@@ -14,10 +14,10 @@ import { serve } from "@hono/node-server";
14
14
  import { Hono } from "hono";
15
15
  import { bodyLimit } from "hono/body-limit";
16
16
  import { Document, YAMLParseError, isCollection, isMap, isPair, isSeq, parseAllDocuments, parseDocument } from "yaml";
17
+ import { execFileSync, spawnSync } from "node:child_process";
17
18
  import { parseArgs } from "node:util";
18
19
  import { readFile } from "node:fs/promises";
19
- import { execFileSync, spawnSync } from "node:child_process";
20
- //#region ../content-gateway/dist/main-BoKhKcP0.mjs
20
+ //#region ../content-gateway/dist/main-BDFsC03h.mjs
21
21
  /**
22
22
  * The default registration — and the ORIGINAL of the file `ksor init` emits.
23
23
  *
@@ -461,10 +461,22 @@ function currentActor() {
461
461
  /**
462
462
  * A required env var is missing — thrown with the operator message. Like
463
463
  * AuthConfigError, the gateway maps this distinct type to a clean stderr line
464
- * + exit 2; it must never half-boot past it.
464
+ * + exit 3 (environment); it must never half-boot past it.
465
+ *
466
+ * `slug` is the stable first stderr line the gateway prints when the refusal
467
+ * names a rule (`bootErrorLines`). Optional, because most of these are one
468
+ * sentence about one variable and the sentence IS the name; a missing provider
469
+ * key is the one with a rule behind it, and it exited 3 with no line an agent
470
+ * could branch on while every exit-1 refusal had one (product principle 4;
471
+ * found live, 2026-09-02).
465
472
  */
466
473
  var RequiredEnvError = class extends Error {
467
474
  name = "RequiredEnvError";
475
+ slug;
476
+ constructor(message, slug = null) {
477
+ super(message);
478
+ this.slug = slug;
479
+ }
468
480
  };
469
481
  /**
470
482
  * Host: KSOR_MCP_HOST, else 0.0.0.0 only when $PORT is set, else loopback.
@@ -527,12 +539,45 @@ function transportSecurityFromEnv(env = process.env) {
527
539
  * the code's own terms and visibly crooked in the block (review finding 63).
528
540
  */
529
541
  function bootLine(label, text) {
530
- const padded = label.padEnd(10);
542
+ const padded = label.padEnd(12);
531
543
  return ` ${padded === label ? `${label} ` : padded}${text}`;
532
544
  }
533
545
  function bootHeader(corpusId) {
534
546
  return `ksor serve · ${corpusId}`;
535
547
  }
548
+ /**
549
+ * What this door is SERVING — or that it is serving nothing.
550
+ *
551
+ * The block said db, audience, trust, auth, abstain, serving and came up green
552
+ * on a provisioned record nobody had ever ingested, which is exactly where
553
+ * `ksor init`'s own next steps leave an adopter who skipped the publish step.
554
+ * Every search then answered `reason: "unpublished"`, correctly, to an agent —
555
+ * and nothing had told the operator (found live, 2026-09-02). The absence is
556
+ * stated in capitals like the other two lines that decide trust, and the
557
+ * remedy travels with it.
558
+ */
559
+ function generationPosture(published, refresh) {
560
+ if (published === null) return `NONE — nothing published; run ${refresh}`;
561
+ const { generation, nodes, sourceCommit } = published;
562
+ return `${generation} · ${nodes} ${nodes === 1 ? "node" : "nodes"} · source ${sourceCommit}`;
563
+ }
564
+ /**
565
+ * The publish step, in the words of the manager that spawned this process.
566
+ *
567
+ * `npm_config_user_agent` is the same signal `ksor init` reads to emit the
568
+ * adopter's scaffold (#28): every manager sets it for the scripts it runs, so
569
+ * `npm run serve` is told `npm run refresh` rather than a pnpm spelling its
570
+ * scaffold does not carry. No manager in the loop — `ksor serve` typed
571
+ * directly, a container entrypoint — names the VERB, which is the name every
572
+ * runbook here uses for the individual step (AGENTS.md → Vocabulary).
573
+ */
574
+ function refreshCommand(userAgent) {
575
+ const head = (userAgent ?? "").split("/")[0]?.trim();
576
+ if (head === "pnpm") return "pnpm refresh";
577
+ if (head === "npm") return "npm run refresh";
578
+ if (head === "bun") return "bun run refresh";
579
+ return "ksor ingest --flip";
580
+ }
536
581
  function withoutSdkResponseModeWarning(body) {
537
582
  const warn = console.warn;
538
583
  console.warn = (...args) => {
@@ -1013,7 +1058,7 @@ async function compose(rawInstancePath, version) {
1013
1058
  dim: instance.embeddingDim
1014
1059
  });
1015
1060
  } catch (error) {
1016
- if (error instanceof MissingProviderKeyError$1) throw new RequiredEnvError(error.message);
1061
+ if (error instanceof MissingProviderKeyError$1) throw new RequiredEnvError(error.message, error.slug);
1017
1062
  throw error;
1018
1063
  }
1019
1064
  console.error(bootHeader(instance.corpusId));
@@ -1026,6 +1071,8 @@ async function compose(rawInstancePath, version) {
1026
1071
  const requestedViewer = parseViewer(process.env["KSOR_AUDIENCE"]);
1027
1072
  let viewer = ["public"];
1028
1073
  let spaceSkipReason = "not yet verified — boot checks have not passed";
1074
+ let published = null;
1075
+ const refreshHint = refreshCommand(process.env["npm_config_user_agent"]);
1029
1076
  const bootChecks = async () => {
1030
1077
  await assertSchemaCompatible(pool);
1031
1078
  const stored = await storedTextSearchConfig(pool);
@@ -1034,6 +1081,8 @@ async function compose(rawInstancePath, version) {
1034
1081
  const policy = await servingPolicy$1(pool, instance);
1035
1082
  viewer = validateViewer(policy?.registry ?? [], requestedViewer);
1036
1083
  console.error(bootLine("audience", viewer.join(",")));
1084
+ published = await publishedGeneration(pool, instance);
1085
+ console.error(bootLine("generation", generationPosture(published, refreshHint)));
1037
1086
  const space = await checkEmbeddingSpace$1(pool, instance.tenantId, instance.embeddingModel, instance.embeddingDim);
1038
1087
  if (space.storeUnreachable) throw new ContentStoreError$1(`embedding-space guard: ${space.reason ?? "unknown"}`);
1039
1088
  spaceSkipReason = space.reason;
@@ -1048,6 +1097,7 @@ async function compose(rawInstancePath, version) {
1048
1097
  console.error(`boot checks DEFERRED: content store unreachable${classSuffix(error)} — this instance reports NOT READY until schema AND governance both verify`);
1049
1098
  console.error(` cause: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
1050
1099
  console.error(bootLine("audience", `not resolved — requested ${requestedViewer.join(",")}; this door refuses every request until the boot checks pass`));
1100
+ console.error(bootLine("generation", "not resolved — read once the boot checks pass"));
1051
1101
  let verified = false;
1052
1102
  bootVerified = () => verified;
1053
1103
  let inFlight = null;
@@ -1095,6 +1145,13 @@ async function compose(rawInstancePath, version) {
1095
1145
  get spaceSkipReason() {
1096
1146
  return spaceSkipReason;
1097
1147
  },
1148
+ get published() {
1149
+ return published;
1150
+ },
1151
+ refreshHint,
1152
+ probePublished: async (client) => {
1153
+ published = await readPublished(client, instance);
1154
+ },
1098
1155
  requestedViewer,
1099
1156
  version,
1100
1157
  verifyBoot,
@@ -1279,7 +1336,7 @@ async function runHttp(composition) {
1279
1336
  }
1280
1337
  const entry = {
1281
1338
  settledAt: null,
1282
- verdict: withProbeDeadline$1((verifyBoot === null ? Promise.resolve() : verifyBoot()).then(() => runProbe$1(pool, instance.tenantId, (client) => client.query("SELECT 1 FROM corpora LIMIT 1")))).then(() => ({ ok: true }), (error) => ({
1339
+ verdict: withProbeDeadline$1((verifyBoot === null ? Promise.resolve() : verifyBoot()).then(() => runProbe$1(pool, instance.tenantId, (client) => composition.probePublished(client)))).then(() => ({ ok: true }), (error) => ({
1283
1340
  ok: false,
1284
1341
  error
1285
1342
  }))
@@ -1325,6 +1382,7 @@ async function runHttp(composition) {
1325
1382
  corpus_id: instance.corpusId,
1326
1383
  store: verdict.ok ? "reachable" : notReadyReason(verdict.error),
1327
1384
  boot_checks: bootVerified() ? "passed" : "NOT PASSED — every request is being refused until schema, governance, the audience list and the embedding space all verify; the reason is in this server's logs",
1385
+ generation: bootVerified() ? generationPosture(composition.published, composition.refreshHint) : "not resolved — boot checks have not passed",
1328
1386
  abstain_gate: abstainPosture(instance.abstain.vectorFloor, instance.abstain.floorDigest),
1329
1387
  embedding_space: composition.spaceSkipReason === null ? `${instance.embeddingModel}/d${instance.embeddingDim} ok` : `${instance.embeddingModel}/d${instance.embeddingDim} unverified (check skipped: ${composition.spaceSkipReason})`,
1330
1388
  auth: auth.mode
@@ -1533,7 +1591,7 @@ async function main$1(version = GATEWAY_VERSION) {
1533
1591
  }
1534
1592
  }
1535
1593
  //#endregion
1536
- //#region ../content/dist/check-6fB_kR4G.mjs
1594
+ //#region ../content/dist/check-Nd8zkMvH.mjs
1537
1595
  /**
1538
1596
  * One reader for the control files beside the bundle (`.ksor/*.yaml`): the
1539
1597
  * same parser posture as a concept's frontmatter — one document, unique keys,
@@ -3523,6 +3581,394 @@ function nearMissOf(baseName) {
3523
3581
  return null;
3524
3582
  }
3525
3583
  /**
3584
+ * The takedown ledger's OTHER baseline: every entry any COMMITTED version of
3585
+ * `.ksor/takedowns.yaml` has ever carried.
3586
+ *
3587
+ * The committed lock is a baseline too, and a good one — it holds each entry's
3588
+ * digest, so an entry retargeted in place is caught. What it cannot do is prove
3589
+ * that an entry was never deleted, because the lock travels in the SAME change
3590
+ * as the ledger: delete the row, recompute `ledger_sha256`, empty
3591
+ * `ledger_entries`, and the two agree with each other about a denial that is
3592
+ * gone. Only history remembers.
3593
+ *
3594
+ * The baseline may be INCOMPLETE only if it SAYS so: every version history
3595
+ * holds is read, or `entries` comes back null and the caller reports that it
3596
+ * could not verify. A version silently skipped would contribute neither
3597
+ * digests nor ids while the answer still read "verified"
3598
+ * (`git-ledger.integration.test.ts`).
3599
+ *
3600
+ * This lives in the record module because THREE surfaces need the same answer —
3601
+ * `ksor build`, the emitted checker, and the site's stage (decision 19: a
3602
+ * surface that refuses must refuse on both surfaces). Plain `git log` / `git
3603
+ * show`, so nothing here needs installing.
3604
+ */
3605
+ const LEDGER = ".ksor/takedowns.yaml";
3606
+ /**
3607
+ * `spawnSync` defaults to a 1 MB stdout buffer, and past it the child is KILLED
3608
+ * — `status` comes back null, so the query reads as a failure. A ledger with a
3609
+ * few thousand entries, or one entry carrying a long reason, clears 1 MB
3610
+ * easily, and the version was then dropped from the baseline while the caller
3611
+ * was still told history had been verified. The ceiling stays finite on
3612
+ * purpose: past it this returns null, which is a state the caller SAYS.
3613
+ */
3614
+ const MAX_BUFFER$1 = 67108864;
3615
+ /** One git query, read-only. Null on any non-zero exit, including no git at all. */
3616
+ function git(root, args) {
3617
+ const r = spawnSync("git", [...args], {
3618
+ cwd: root,
3619
+ encoding: "utf8",
3620
+ maxBuffer: MAX_BUFFER$1
3621
+ });
3622
+ return r.status === 0 ? r.stdout : null;
3623
+ }
3624
+ function historicLedger(root) {
3625
+ const inside = git(root, ["rev-parse", "--is-inside-work-tree"]);
3626
+ if (inside === null || inside.trim() !== "true") return {
3627
+ repository: false,
3628
+ shallow: false,
3629
+ entries: null,
3630
+ unreadable: null
3631
+ };
3632
+ const shallow = (git(root, ["rev-parse", "--is-shallow-repository"]) ?? "").trim() === "true";
3633
+ const born = git(root, [
3634
+ "rev-parse",
3635
+ "--verify",
3636
+ "--quiet",
3637
+ "HEAD"
3638
+ ]) !== null;
3639
+ const entries = shallow ? null : born ? historicEntries(root) : [];
3640
+ return {
3641
+ repository: true,
3642
+ shallow,
3643
+ entries,
3644
+ unreadable: entries !== null ? null : shallow ? "shallow" : "unreadable"
3645
+ };
3646
+ }
3647
+ /**
3648
+ * Every id history has ever recorded, each with the text it carried the FIRST
3649
+ * time it was written. A version that parses contributes each entry's digest,
3650
+ * so an entry EDITED in place is caught, not only one deleted; a version that
3651
+ * no longer parses still contributes its ids, read permissively — the point
3652
+ * there is that an id once written never disappears.
3653
+ *
3654
+ * FIRST, not every: keying this by `id\tdigest` kept one baseline entry per
3655
+ * version an id ever had, so a tamper that was COMMITTED and then UNDONE left
3656
+ * two digests for one id, the restored entry matched only one of them, and
3657
+ * `ksor-ledger-amended` fired for good. The record became permanently
3658
+ * unbuildable — by a tamper that had already been put right — and the only
3659
+ * escape was rewriting git history, which is not a remedy a refusal may
3660
+ * demand (found in review, 2026-08-25).
3661
+ *
3662
+ * Taking the OLDEST is what makes the guarantee both enforceable and
3663
+ * escapable. It still refuses a committed tamper (the baseline is what the
3664
+ * entry said when it was written, so committing the edit does not launder
3665
+ * it), and the remedy it names — put the entry back — now actually clears
3666
+ * it. Taking the NEWEST would have done the opposite on both counts.
3667
+ */
3668
+ function historicEntries(root) {
3669
+ const atRoot = `${(git(root, ["rev-parse", "--show-prefix"]) ?? "").trim()}${LEDGER}`;
3670
+ const commits = git(root, [
3671
+ "log",
3672
+ "--full-history",
3673
+ "--topo-order",
3674
+ "--format=%H",
3675
+ "--",
3676
+ LEDGER
3677
+ ]);
3678
+ if (commits === null) return null;
3679
+ const seen = /* @__PURE__ */ new Map();
3680
+ for (const sha of commits.split("\n").filter((s) => s !== "")) {
3681
+ const text = git(root, ["show", `${sha}:${atRoot}`]);
3682
+ if (text === null) {
3683
+ const listed = git(root, [
3684
+ "ls-tree",
3685
+ "--full-tree",
3686
+ "--name-only",
3687
+ sha,
3688
+ "--",
3689
+ atRoot
3690
+ ]);
3691
+ if (listed !== null && listed.trim() === "") continue;
3692
+ return null;
3693
+ }
3694
+ const where = sha.slice(0, 7);
3695
+ const parsed = parseLedger(text, LEDGER);
3696
+ if (parsed.ok) {
3697
+ for (const entry of parsed.ledger.entries) seen.set(entry.id, {
3698
+ id: entry.id,
3699
+ digest: entryDigest(entry),
3700
+ entry,
3701
+ where
3702
+ });
3703
+ continue;
3704
+ }
3705
+ for (const m of text.matchAll(/^\s*(?:-\s+)?id:\s*["']?([^\s"']+)/gm)) {
3706
+ const id = m[1] ?? "";
3707
+ if (!seen.has(id)) seen.set(id, {
3708
+ id,
3709
+ digest: null,
3710
+ where
3711
+ });
3712
+ }
3713
+ }
3714
+ return [...seen.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
3715
+ }
3716
+ /**
3717
+ * KSP R23 — the first verification tooth in approval.
3718
+ *
3719
+ * A `stable` concept's `generated.at` dates its TEXT, and `ksor.approval.at`
3720
+ * ratifies the text of that date. Until now the checker compared the two
3721
+ * authored instants and nothing else (`ksor-generated-after-approval`), so
3722
+ * whether an edit actually moved `generated.at` was the author's obligation:
3723
+ * change a sentence, leave the stamp, and the approval that ratified the old
3724
+ * sentence reads as ratifying the new one, with nothing red. This module
3725
+ * compares the body in the working tree against every committed version of
3726
+ * the same path that was `stable`, and refuses `ksor-generated-stale` when a
3727
+ * body differs under a `generated.at` the tree has not ADVANCED past — equal,
3728
+ * or moved backward, which changes the stamp without advancing it. Only the
3729
+ * body: a frontmatter-only
3730
+ * edit — a `verified` entry, a re-approval — is not a change to the text the
3731
+ * stamp dates.
3732
+ *
3733
+ * What it does NOT verify, and says so: who reviewed anything. R22 and R25
3734
+ * need an identity the platform can vouch for, and `approval.checked` stays
3735
+ * `"policy"` (decision 21). This is the one rule in the record module that
3736
+ * reads git, so it sits beside `git-ledger.ts` and is run by the two
3737
+ * publishing verbs — `ksor build` and `ksor ingest` — each of which SAYS when
3738
+ * history could not be read rather than passing a check that did not run. The
3739
+ * emitted `check.mjs` does not run it: that is the format gate an agent runs
3740
+ * after every edit, and a stamp is verified where the record is published.
3741
+ */
3742
+ /** The record's bundle, and the only pathspec the walk needs (relative to the cwd, which is the record root). */
3743
+ const BUNDLE$2 = "knowledge/";
3744
+ /**
3745
+ * Objects per `git cat-file --batch` call. One process per object was measured
3746
+ * at ~53 ms a call on the machine this was built on against ~0.6 ms an object
3747
+ * batched (2026-09-02), so a record with a few hundred committed versions is
3748
+ * the difference between a build that finishes and one that does not. Chunked
3749
+ * so the reply stays under the buffer ceiling `git-ledger.ts` explains.
3750
+ */
3751
+ const BATCH = 256;
3752
+ const MAX_BUFFER = 67108864;
3753
+ /**
3754
+ * Every committed version of each path, read in three git calls plus one
3755
+ * `cat-file --batch` per chunk. `--full-history`, for the reason the ledger
3756
+ * walk gives: default simplification follows a merge through the parent it is
3757
+ * TREESAME to, so a version that lived only on a branch never enters the set.
3758
+ * `-m`, because without a diff-merges option a merge commit lists NO paths at
3759
+ * all — verified against git 2.50 — and a conflict resolved by hand is a body
3760
+ * that is in neither parent; the same commit then prints once per parent, so
3761
+ * versions are keyed by (commit, path). That flag is held by the merge case in
3762
+ * `build.integration.test.ts`'s KSP R23 describe: drop `-m` here and the build
3763
+ * it expects to refuse exits 0 instead.
3764
+ */
3765
+ function committedVersions(root, paths) {
3766
+ const inside = git(root, ["rev-parse", "--is-inside-work-tree"]);
3767
+ if (inside === null || inside.trim() !== "true") return {
3768
+ repository: false,
3769
+ born: false,
3770
+ shallow: false,
3771
+ versions: null
3772
+ };
3773
+ const shallow = (git(root, ["rev-parse", "--is-shallow-repository"]) ?? "").trim() === "true";
3774
+ const born = git(root, [
3775
+ "rev-parse",
3776
+ "--verify",
3777
+ "--quiet",
3778
+ "HEAD"
3779
+ ]) !== null;
3780
+ const versions = /* @__PURE__ */ new Map();
3781
+ if (!born || paths.length === 0) return {
3782
+ repository: true,
3783
+ born,
3784
+ shallow,
3785
+ versions
3786
+ };
3787
+ const prefix = (git(root, ["rev-parse", "--show-prefix"]) ?? "").trim();
3788
+ const log = git(root, [
3789
+ "log",
3790
+ "--full-history",
3791
+ "-m",
3792
+ "--format=%x1e%H %cI",
3793
+ "--name-only",
3794
+ "--",
3795
+ BUNDLE$2
3796
+ ]);
3797
+ if (log === null) return {
3798
+ repository: true,
3799
+ born,
3800
+ shallow,
3801
+ versions: null
3802
+ };
3803
+ const wanted = new Set(paths);
3804
+ const refs = [];
3805
+ const seen = /* @__PURE__ */ new Set();
3806
+ for (const block of log.split("")) {
3807
+ const [header = "", ...names] = block.split("\n");
3808
+ const [sha = "", committedAt = ""] = header.trim().split(" ");
3809
+ if (sha === "") continue;
3810
+ for (const name of names) {
3811
+ const trimmed = name.trim();
3812
+ if (trimmed === "" || !trimmed.startsWith(prefix)) continue;
3813
+ const rel = trimmed.slice(prefix.length);
3814
+ if (!wanted.has(rel) || seen.has(`${sha} ${rel}`)) continue;
3815
+ seen.add(`${sha} ${rel}`);
3816
+ refs.push({
3817
+ sha,
3818
+ committedAt,
3819
+ path: rel
3820
+ });
3821
+ }
3822
+ }
3823
+ const blobs = readBlobs(root, refs.map((r) => `${r.sha}:${prefix}${r.path}`));
3824
+ if (blobs === null) return {
3825
+ repository: true,
3826
+ born,
3827
+ shallow,
3828
+ versions: null
3829
+ };
3830
+ refs.forEach((ref, i) => {
3831
+ const text = blobs[i];
3832
+ if (text === null || text === void 0) return;
3833
+ const list = versions.get(ref.path) ?? [];
3834
+ list.push({
3835
+ sha: ref.sha,
3836
+ committedAt: ref.committedAt,
3837
+ text
3838
+ });
3839
+ versions.set(ref.path, list);
3840
+ });
3841
+ return {
3842
+ repository: true,
3843
+ born,
3844
+ shallow,
3845
+ versions
3846
+ };
3847
+ }
3848
+ /**
3849
+ * `<rev>:<path>` specs → text, in order; null for a spec that names nothing,
3850
+ * and null for the WHOLE read when git failed — a version dropped in silence
3851
+ * would read as verified.
3852
+ */
3853
+ function readBlobs(root, specs) {
3854
+ const out = [];
3855
+ for (let i = 0; i < specs.length; i += BATCH) {
3856
+ const chunk = specs.slice(i, i + BATCH);
3857
+ const r = spawnSync("git", ["cat-file", "--batch"], {
3858
+ cwd: root,
3859
+ input: `${chunk.join("\n")}\n`,
3860
+ maxBuffer: MAX_BUFFER
3861
+ });
3862
+ if (r.status !== 0) return null;
3863
+ const buf = r.stdout;
3864
+ let at = 0;
3865
+ for (let k = 0; k < chunk.length; k += 1) {
3866
+ const nl = buf.indexOf(10, at);
3867
+ if (nl === -1) return null;
3868
+ const header = buf.subarray(at, nl).toString("utf8");
3869
+ at = nl + 1;
3870
+ if (header.endsWith(" missing")) {
3871
+ out.push(null);
3872
+ continue;
3873
+ }
3874
+ const [, type, sizeText] = header.split(" ");
3875
+ const size = Number(sizeText);
3876
+ if (type !== "blob" || !Number.isInteger(size) || at + size > buf.length) return null;
3877
+ out.push(buf.subarray(at, at + size).toString("utf8"));
3878
+ at += size + 1;
3879
+ }
3880
+ }
3881
+ return out;
3882
+ }
3883
+ /** Line endings are the checkout's and a trailing blank line is nobody's edit; everything else is the text. */
3884
+ function comparable(body) {
3885
+ return normalizeText(body).trimEnd();
3886
+ }
3887
+ /**
3888
+ * A committed version as a stable, stamped text — or null: a draft's body is
3889
+ * free, and a version the profile cannot read (no fence, broken YAML, no
3890
+ * `generated.at`) was never a stable version under the profile.
3891
+ */
3892
+ function stableVersionOf(version, path) {
3893
+ const split = splitFrontmatter(version.text, path);
3894
+ if (!split.ok || split.frontmatter === null || split.frontmatter["status"] !== "stable") return null;
3895
+ const generated = split.frontmatter["generated"];
3896
+ if (typeof generated !== "object" || generated === null) return null;
3897
+ const at = generated["at"];
3898
+ if (typeof at !== "string") return null;
3899
+ const generatedAt = parseInstant(at);
3900
+ if (generatedAt === null) return null;
3901
+ return {
3902
+ sha: version.sha,
3903
+ committedAt: version.committedAt,
3904
+ generatedAt,
3905
+ stamp: at,
3906
+ body: comparable(split.body)
3907
+ };
3908
+ }
3909
+ /**
3910
+ * The rule, pure: for every `stable` concept whose body differs from a
3911
+ * committed version that was `stable`, the tree's `generated.at` must be
3912
+ * strictly LATER than that version's. Equal is the defect this rule exists to
3913
+ * catch — edit the sentence, leave the stamp — and EARLIER is the same defect
3914
+ * with one more keystroke: backdating the stamp changes it without advancing
3915
+ * it, so the approval that ratified the old text still post-dates the new one.
3916
+ * Only strictly-later clears a version, which is exactly what the refusal's
3917
+ * own `fix` prints. All of history, not only HEAD's version — an edit
3918
+ * committed without a bump matches HEAD exactly, and it is the version BEHIND
3919
+ * it that tells (the shape CI sees). A path with no committed stable version
3920
+ * passes: stable for the first time, or renamed, since path is identity.
3921
+ * Instants are compared as instants, so two spellings of one moment are one
3922
+ * stamp.
3923
+ */
3924
+ function checkGeneratedStale(concepts, files, versions) {
3925
+ const refusals = [];
3926
+ for (const concept of concepts) {
3927
+ if (concept.status !== "stable" || concept.generatedAt === null) continue;
3928
+ const text = files.get(concept.path);
3929
+ if (text === void 0) continue;
3930
+ const split = splitFrontmatter(text, concept.path);
3931
+ if (!split.ok) continue;
3932
+ const body = comparable(split.body);
3933
+ for (const version of versions.get(concept.path) ?? []) {
3934
+ const stable = stableVersionOf(version, concept.path);
3935
+ if (stable === null || stable.generatedAt < concept.generatedAt || stable.body === body) continue;
3936
+ const authored = concept.frontmatter["generated"]?.at;
3937
+ const stamp = typeof authored === "string" ? authored : new Date(concept.generatedAt).toISOString();
3938
+ const under = stable.generatedAt === concept.generatedAt ? `under the same \`generated.at\` (${stamp})` : `under \`generated.at\` ${stable.stamp}, LATER than the ${stamp} this file now carries (the stamp was moved backward, which changes it without advancing it)`;
3939
+ refusals.push({
3940
+ slug: "ksor-generated-stale",
3941
+ path: concept.path,
3942
+ why: `the body differs from the one committed at ${stable.sha.slice(0, 7)} (${stable.committedAt}), where this concept was \`stable\` ${under} — that instant dates the text, so an edit to a stable concept must advance it, or the approval that ratified the old text reads as ratifying the new one (KSP R23)`,
3943
+ fix: "set `generated.at` to an instant after this edit, then re-approve: `ksor.approval.at` must not precede the new `generated.at` (`ksor-generated-after-approval`)"
3944
+ });
3945
+ break;
3946
+ }
3947
+ }
3948
+ return refusals;
3949
+ }
3950
+ /** The two callers' one entry: read history for the stable concepts, judge, and say what could not be read. */
3951
+ function checkChangeControl(root, concepts, files) {
3952
+ const history = committedVersions(root, concepts.filter((c) => c.status === "stable").map((c) => c.path));
3953
+ return {
3954
+ refusals: history.versions === null ? [] : checkGeneratedStale(concepts, files, history.versions),
3955
+ notice: changeControlNotice(history)
3956
+ };
3957
+ }
3958
+ /**
3959
+ * Honest absence in the build's own idiom — the `source: unspecified` line
3960
+ * already says the commit is unknown; this says the same of the check that
3961
+ * needs one. A check that could not run is never a check that passed.
3962
+ */
3963
+ function changeControlNotice(history) {
3964
+ const what = "so whether a stable concept's body changed under its `generated.at` (KSP R23) was not checked";
3965
+ if (!history.repository) return `change-control: not checked — knowledge/ is not in a git repository (or git is not installed), ${what}`;
3966
+ if (!history.born) return `change-control: not checked — the repository has no commits yet, ${what}`;
3967
+ if (history.versions === null) return `change-control: not checked — git could not read the history of knowledge/ (\`git log -- knowledge/\` or \`git cat-file --batch\` failed), ${what}`;
3968
+ if (history.shallow) return `change-control: checked against the ${[...history.versions.values()].reduce((n, list) => n + list.length, 0)} committed version(s) this shallow clone holds — a stable version beyond the shallow boundary was not read; fetch full history (\`git fetch --unshallow\`; in CI, \`fetch-depth: 0\`) to check all of it`;
3969
+ return null;
3970
+ }
3971
+ /**
3526
3972
  * The audience rule, alone, with no imports and no side effects.
3527
3973
  *
3528
3974
  * CANONICAL COPY: `packages/content/src/lib/audience-rule.ts`. The scaffold's
@@ -3841,7 +4287,7 @@ function isSim(baseName) {
3841
4287
  * one by hand — nothing the old checker refused may become accepted silently.
3842
4288
  * Each rule keeps the scar that put it there.
3843
4289
  */
3844
- const KNOWLEDGE$1 = "knowledge/";
4290
+ const KNOWLEDGE$1$1 = "knowledge/";
3845
4291
  const ASSET_EXTENSIONS = /* @__PURE__ */ new Set([
3846
4292
  ".png",
3847
4293
  ".jpg",
@@ -3869,7 +4315,7 @@ function checkHygiene(tree) {
3869
4315
  const documents = tree.textPaths.filter((p) => p.endsWith(".md") && !p.endsWith("/index.md") && !p.endsWith("/log.md") && !p.endsWith("/README.md") && !COMPANION_SUFFIXES.some((s) => p.endsWith(s)));
3870
4316
  if (documents.length === 0) refusals.push({
3871
4317
  slug: "ksor-record-empty",
3872
- path: KNOWLEDGE$1,
4318
+ path: KNOWLEDGE$1$1,
3873
4319
  why: "the record has no concept — a KSoR is never empty; the site has nothing to render and the record stands behind nothing",
3874
4320
  fix: "restore a document from git history, or add one: knowledge/<name>.md with the profile's frontmatter (record spec §2)"
3875
4321
  });
@@ -4710,7 +5156,7 @@ async function withPgRetry(op, options = {}) {
4710
5156
  throw lastError;
4711
5157
  }
4712
5158
  //#endregion
4713
- //#region ../content/dist/commands-CO9-bQnb.mjs
5159
+ //#region ../content/dist/commands-DIKiyZBQ.mjs
4714
5160
  /**
4715
5161
  * EVAL-LOCKED constants, quarried verbatim from the oracle
4716
5162
  * (sor-agentfactory @ b554f91, config.py) — changing any of these is a
@@ -6308,6 +6754,15 @@ var OpenAiEmbeddingProvider = class {
6308
6754
  * directly, so it is not ported.)
6309
6755
  */
6310
6756
  /**
6757
+ * The stable first stderr line for a missing provider key, on BOTH planes:
6758
+ * `ksor serve` (through the gateway's `bootErrorLines`) and `ksor ingest` /
6759
+ * `ksor calibrate` (the write plane's `fail`). Exit 3 either way — the key is
6760
+ * the operator's environment — but an exit code is not a name, and this was
6761
+ * the one refusal in the first-hour path that printed its sentence with no
6762
+ * slug above it (found live, 2026-09-02).
6763
+ */
6764
+ const PROVIDER_KEY_MISSING = "ksor-provider-key-missing";
6765
+ /**
6311
6766
  * A key-needing provider was built without an API key. A TYPED error (mirrors
6312
6767
  * EmbeddingSpaceMismatch) so a composition root classifies the missing-key case
6313
6768
  * by TYPE, not by string-matching this message — the exact prose-coupling scar
@@ -6315,6 +6770,7 @@ var OpenAiEmbeddingProvider = class {
6315
6770
  * exit-code mapping no longer breaks when it is reworded.
6316
6771
  */
6317
6772
  var MissingProviderKeyError = class extends Error {
6773
+ slug = PROVIDER_KEY_MISSING;
6318
6774
  providerName;
6319
6775
  keyEnv;
6320
6776
  /**
@@ -6366,12 +6822,6 @@ function entryFor(name) {
6366
6822
  if (entry === void 0) throw new Error(`unknown embedding provider ${JSON.stringify(name)} — registered: ${Object.keys(PROVIDERS).sort().join(", ")}`);
6367
6823
  return entry;
6368
6824
  }
6369
- /** Whether the named provider's factory needs an API key. Unknown name → the
6370
- * same loud error as building it, so a composition root can ask this FIRST
6371
- * and still fail on a typo. */
6372
- function providerNeedsApiKey(name) {
6373
- return entryFor(name).needsApiKey;
6374
- }
6375
6825
  /**
6376
6826
  * The environment variable this provider's key comes from, or null when it
6377
6827
  * needs none. Unknown name → the same loud error as building it.
@@ -6880,6 +7330,31 @@ function parseQueriesFile(text) {
6880
7330
  return normalizeQueries(queries);
6881
7331
  }
6882
7332
  /**
7333
+ * Where the out-of-corpus probes came from, and WHAT they are — decided once.
7334
+ *
7335
+ * These were two expressions and they disagreed. The label tested
7336
+ * `=== undefined` while the probes fell back through `?? BUILT_IN_OOC`, and the
7337
+ * CLI passes `null` when `--ooc-file` is absent (`commands.ts`) — so every
7338
+ * `ksor calibrate` without `--ooc-file` scored against the twenty BUILT-IN
7339
+ * far-domain probes and reported `ooc_source: "provided"`. `BUILT_IN_OOC_CAVEAT`
7340
+ * is gated on that field, and it is the one line that exists to stop an operator
7341
+ * pasting a floor blessed by far-domain probes — so it never printed on the path
7342
+ * that needs it most. Found reviewing PR #259, whose tutorial's own pasted
7343
+ * output is the evidence: a run with no `--ooc-file`, and no caveat above it.
7344
+ *
7345
+ * Returning both together is the fix rather than correcting one test: a source
7346
+ * label and the probes it labels cannot drift when one expression yields both.
7347
+ */
7348
+ function resolveOoc(probes) {
7349
+ return probes === void 0 || probes === null ? {
7350
+ source: "built-in",
7351
+ probes: BUILT_IN_OOC
7352
+ } : {
7353
+ source: "provided",
7354
+ probes
7355
+ };
7356
+ }
7357
+ /**
6883
7358
  * The scope every calibration statement binds: the widest viewer, the lowest
6884
7359
  * trust floor, and (through the predicate's default) lifecycle at now(). The
6885
7360
  * floor must be measured on the set the door serves — a draft, a not-yet-
@@ -6966,8 +7441,9 @@ async function runCalibration(pool, options) {
6966
7441
  }
6967
7442
  inQueries = normalizeQueries(synthesized);
6968
7443
  }
6969
- const oocSource = options.oocProbes === void 0 ? "built-in" : "provided";
6970
- const ooc = normalizeQueries(options.oocProbes ?? BUILT_IN_OOC);
7444
+ const resolved = resolveOoc(options.oocProbes);
7445
+ const oocSource = resolved.source;
7446
+ const ooc = normalizeQueries(resolved.probes);
6971
7447
  return buildReport([...await scoreQueries(pool, scope, gucs, options.provider, inQueries, true), ...await scoreQueries(pool, scope, gucs, options.provider, ooc, false)], {
6972
7448
  generation: counted.measured,
6973
7449
  pinned: generation !== null,
@@ -7636,21 +8112,6 @@ async function reap(client, opts) {
7636
8112
  for (;;) if (!(await client.query("DELETE FROM content_nodes n WHERE n.tenant_id = $1 AND n.generation = $2 AND NOT EXISTS (SELECT 1 FROM content_nodes ch WHERE ch.parent_id = n.node_id AND ch.tenant_id = n.tenant_id AND ch.generation = n.generation)", [opts.tenantId, opts.generation])).rowCount) break;
7637
8113
  await client.query("UPDATE ingestion_runs SET state = 'reaped' WHERE tenant_id = $1 AND generation = $2", [opts.tenantId, opts.generation]);
7638
8114
  }
7639
- async function readLedger(pool, instance, limit) {
7640
- return runAuditRead(pool, instance.tenantId, async (client) => {
7641
- return (await client.query("SELECT action, actor, generation, detail, created_at FROM retrieval_log WHERE tenant_id = $1 AND corpus_id = $2 ORDER BY created_at DESC, id DESC LIMIT $3", [
7642
- instance.tenantId,
7643
- instance.corpusId,
7644
- limit
7645
- ])).rows.map((row) => ({
7646
- action: String(row.action),
7647
- actor: String(row.actor),
7648
- generation: row.generation === null ? null : Number(row.generation),
7649
- detail: row.detail ?? {},
7650
- createdAt: row.created_at
7651
- }));
7652
- });
7653
- }
7654
8115
  async function listTakedowns(pool, instance) {
7655
8116
  return runRead(pool, instance.tenantId, async (client) => {
7656
8117
  return (await client.query("SELECT stable_id, scope, reason, created_at FROM takedown_denylist WHERE tenant_id = $1 AND corpus_id = $2 AND revoked_at IS NULL ORDER BY created_at, stable_id", [instance.tenantId, instance.corpusId])).rows.map((r) => ({
@@ -7685,10 +8146,10 @@ function ledgerDenials(ledger) {
7685
8146
  * What `--ledger` prints: every entry the file records, newest first, in the
7686
8147
  * shape the database trail uses — the act, who, and the detail.
7687
8148
  *
7688
- * NOT the same trail as the database's: with a `database:` the flag reads §7
7689
- * `retrieval_log` acts, which include the APPLY. Here it is the file's own
7690
- * history, which is the only history this rung has and the one `--revoke` needs
7691
- * ids from.
8149
+ * NOT the database's §7 trail, which also records the APPLY of each entry to
8150
+ * the door and which no verb prints. The file's own history is what the flag
8151
+ * prints on every rung — it is the record of the act (record spec §5), it is
8152
+ * where `--revoke`'s entry ids live, and it never needs a DSN.
7692
8153
  */
7693
8154
  function ledgerActs(ledger) {
7694
8155
  return [...ledger.entries].map((e) => {
@@ -9097,7 +9558,7 @@ function formatRefusals(refusals) {
9097
9558
  return refusals.map((r) => `${r.slug}: ${r.path}\n why: ${r.why}\n fix: ${r.fix}`).join("\n");
9098
9559
  }
9099
9560
  const LOCK_PATH = "build.lock.json";
9100
- const KNOWLEDGE = "knowledge/";
9561
+ const KNOWLEDGE$1 = "knowledge/";
9101
9562
  const NOT_A_CONCEPT = /(^|\/)(index|log|README)\.md$|\.summary\.md$/;
9102
9563
  const INDEX = /(^|\/)index\.md$/;
9103
9564
  const INSTANCE_PATH = "instance.md";
@@ -9126,7 +9587,7 @@ function sha256OfDocument(text) {
9126
9587
  function conceptHashes(record) {
9127
9588
  const out = /* @__PURE__ */ new Map();
9128
9589
  for (const [path, text] of record.files) {
9129
- if (!path.startsWith(KNOWLEDGE) || !path.endsWith(".md") || NOT_A_CONCEPT.test(path)) continue;
9590
+ if (!path.startsWith(KNOWLEDGE$1) || !path.endsWith(".md") || NOT_A_CONCEPT.test(path)) continue;
9130
9591
  out.set(path.slice(10), sha256OfDocument(text));
9131
9592
  }
9132
9593
  return out;
@@ -9146,7 +9607,7 @@ function conceptHashes(record) {
9146
9607
  function companionHashes(record) {
9147
9608
  const out = /* @__PURE__ */ new Map();
9148
9609
  for (const [path, text] of record.files) {
9149
- if (!path.startsWith(KNOWLEDGE)) continue;
9610
+ if (!path.startsWith(KNOWLEDGE$1)) continue;
9150
9611
  if (attachmentKindOf(path.slice(path.lastIndexOf("/") + 1)) === null) continue;
9151
9612
  out.set(path.slice(10), sha256OfDocument(text));
9152
9613
  }
@@ -9163,7 +9624,7 @@ function companionHashes(record) {
9163
9624
  function indexHashes(record) {
9164
9625
  const out = /* @__PURE__ */ new Map();
9165
9626
  for (const [path, text] of record.files) {
9166
- if (!path.startsWith(KNOWLEDGE) || !INDEX.test(path)) continue;
9627
+ if (!path.startsWith(KNOWLEDGE$1) || !INDEX.test(path)) continue;
9167
9628
  out.set(path.slice(10), sha256OfDocument(text));
9168
9629
  }
9169
9630
  return out;
@@ -9179,7 +9640,7 @@ function indexHashes(record) {
9179
9640
  function assetHashes(record) {
9180
9641
  const out = /* @__PURE__ */ new Map();
9181
9642
  for (const [path, bytes] of record.assets ?? /* @__PURE__ */ new Map()) {
9182
- if (!path.startsWith(KNOWLEDGE)) continue;
9643
+ if (!path.startsWith(KNOWLEDGE$1)) continue;
9183
9644
  out.set(path.slice(10), createHash("sha256").update(bytes).digest("hex"));
9184
9645
  }
9185
9646
  return out;
@@ -9731,7 +10192,9 @@ async function buildGeneration(pool, instance, options) {
9731
10192
  accepted: true
9732
10193
  }]
9733
10194
  });
9734
- if (check.refusals.length > 0 || check.policy === null) throw new RecordRefused(check.refusals);
10195
+ const change = checkChangeControl(root, check.concepts, record.files);
10196
+ if (check.refusals.length > 0 || change.refusals.length > 0 || check.policy === null) throw new RecordRefused([...check.refusals, ...change.refusals]);
10197
+ if (change.notice !== null) (options.onReport ?? log)(change.notice);
9735
10198
  const policy = check.policy;
9736
10199
  const lock = checkLock(lockText, record);
9737
10200
  if (!lock.ok) throw new RecordRefused([lock.refusal]);
@@ -10062,9 +10525,10 @@ function renderDrift(report, window) {
10062
10525
  * zero would drag every statistic here toward a number nobody measured, in the
10063
10526
  * direction that makes a floor look safer than it is.
10064
10527
  *
10065
- * Scoped by CORPUS as well as tenant, for the reason `readLedger` records: a
10066
- * tenant serving two corpora would otherwise measure one record's floor
10067
- * against the other's traffic. Read through `runAuditRead` the serving role
10528
+ * Scoped by CORPUS as well as tenant, because every governance write records
10529
+ * `corpus_id`: a tenant serving two corpora the shape the second record
10530
+ * prepares for would otherwise measure one record's floor against the
10531
+ * other's traffic. Read through `runAuditRead` — the serving role
10068
10532
  * has no SELECT on this table at all, deliberately, and widening that to read
10069
10533
  * a monitor would trade an audit guarantee for a convenience.
10070
10534
  */
@@ -10285,7 +10749,8 @@ Usage:
10285
10749
  --check reads the record's OWN logged searches instead and reports how
10286
10750
  the declared floor is holding against them — no provider key, no
10287
10751
  embedding call, no LLM. A monitor, never a gate: it says what to
10288
- re-measure and always exits 0.
10752
+ re-measure, and a verdict always exits 0 — the environment exits 3, as
10753
+ for every verb.
10289
10754
  ksor grant --instance PATH [--revoke]
10290
10755
  Authorize ingest for the instance's tenant (the row row-level security
10291
10756
  requires), or withdraw it. Idempotent; reports the state it established.
@@ -10324,10 +10789,11 @@ Exit codes: 0 ok · 1 refused · 3 environment
10324
10789
  function usageFor(command) {
10325
10790
  const lines = USAGE.split("\n");
10326
10791
  const isHeading = (l) => /^ {2}ksor \S/.test(l);
10327
- const start = lines.findIndex((l) => isHeading(l) && l.trimStart().startsWith(`ksor ${command}`));
10792
+ const verbOf = (l) => l.trimStart().split(/\s+/)[1];
10793
+ const start = lines.findIndex((l) => isHeading(l) && verbOf(l) === command);
10328
10794
  if (start === -1) return USAGE;
10329
10795
  const rest = lines.slice(start + 1);
10330
- const end = rest.findIndex(isHeading);
10796
+ const end = rest.findIndex((l) => isHeading(l) && verbOf(l) !== command);
10331
10797
  return `${[lines[start], ...end === -1 ? rest : rest.slice(0, end)].join("\n").replace(/\s+$/, "")}\n`;
10332
10798
  }
10333
10799
  function fail$1(code, message) {
@@ -10396,18 +10862,13 @@ function resolveDsn(instance) {
10396
10862
  /** The ingest composition root's provider step (oracle cli.py:58-74). */
10397
10863
  function composeProvider(instance) {
10398
10864
  try {
10399
- let apiKey = null;
10400
- if (providerNeedsApiKey(instance.embeddingProvider)) {
10401
- const keyEnv = providerKeyEnv(instance.embeddingProvider) ?? "";
10402
- apiKey = process.env[keyEnv] || null;
10403
- if (apiKey === null) return fail$1(ENVIRONMENT, `${keyEnv} is required (the instance's embedding provider ${JSON.stringify(instance.embeddingProvider)} needs a key)\n fix: export ${keyEnv}=... and rerun`);
10404
- }
10405
10865
  return buildShippedProvider(instance.embeddingProvider, {
10406
- apiKey,
10866
+ apiKey: process.env[providerKeyEnv(instance.embeddingProvider) ?? ""] || null,
10407
10867
  modelId: instance.embeddingModel,
10408
10868
  dim: instance.embeddingDim
10409
10869
  });
10410
10870
  } catch (exc) {
10871
+ if (exc instanceof MissingProviderKeyError) return fail$1(ENVIRONMENT, `error: ${exc.slug}\n${exc.message}\n fix: export ${exc.keyEnv ?? "the key"}=... and rerun`);
10411
10872
  return refuse$2("ksor-instance-format", `instance embedding.provider: ${exc instanceof Error ? exc.message : String(exc)}`);
10412
10873
  }
10413
10874
  }
@@ -10775,13 +11236,18 @@ function printLedger(rows) {
10775
11236
  process.stdout.write(`${when}\t${r.action}\t${r.actor}\t${JSON.stringify(r.detail)}\n`);
10776
11237
  }
10777
11238
  }
10778
- /** One denial in force per line: what, at which scope, why. */
10779
- function printDenials(rows) {
11239
+ /**
11240
+ * One denial in force per line: what, at which scope, why — and, when the
11241
+ * lines come from the FILE rather than the door's rows, a fourth column saying
11242
+ * so, because a ledger entry is the act and a row is its projection, and only
11243
+ * the row is what the door refuses on.
11244
+ */
11245
+ function printDenials(rows, label = null) {
10780
11246
  if (rows.length === 0) {
10781
11247
  process.stdout.write("takedown: nothing is denied in this corpus\n");
10782
11248
  return;
10783
11249
  }
10784
- for (const r of rows) process.stdout.write(`${r.stableId}\t${r.scope}\t${r.reason}\n`);
11250
+ for (const r of rows) process.stdout.write(`${r.stableId}\t${r.scope}\t${r.reason}${label === null ? "" : `\t${label}`}\n`);
10785
11251
  }
10786
11252
  async function takedownCommand(args) {
10787
11253
  const { values, positionals } = parseArgs({
@@ -10856,15 +11322,24 @@ async function takedownCommand(args) {
10856
11322
  const parsedLedger = parseLedger(existsSync(ledgerPath) ? readFileSync(ledgerPath, "utf8") : null, ".ksor/takedowns.yaml");
10857
11323
  if (!parsedLedger.ok) return refuse$2(parsedLedger.refusals[0].slug, parsedLedger.refusals.map((r) => `${r.slug}: ${r.path}\n why: ${r.why}\n fix: ${r.fix}`).join("\n"));
10858
11324
  if (mode.kind === "list" || mode.kind === "ledger" || mode.kind === "apply") {
11325
+ if (mode.kind === "ledger") {
11326
+ process.stdout.write("ledger: from .ksor/takedowns.yaml — the committed record of every governance act\n");
11327
+ printLedger(ledgerActs(parsedLedger.ledger));
11328
+ return 0;
11329
+ }
10859
11330
  if (instance === null) {
10860
11331
  if (mode.kind === "apply") {
10861
11332
  process.stdout.write("takedown: instance.md declares no database, so there is nothing to apply — the ledger IS the record and the site reads it at its next build\n");
10862
11333
  return 0;
10863
11334
  }
10864
- process.stdout.write(`${mode.kind === "ledger" ? "ledger" : "takedown"}: from .ksor/takedowns.yaml — instance.md declares no database, so the committed ledger is the whole state
11335
+ process.stdout.write("takedown: from .ksor/takedowns.yaml — instance.md declares no database, so the committed ledger is the whole state\n");
11336
+ printDenials(ledgerDenials(parsedLedger.ledger));
11337
+ return 0;
11338
+ }
11339
+ if (mode.kind === "list" && (process.env[instance.dsnEnv] ?? "") === "") {
11340
+ process.stdout.write(`takedown: from .ksor/takedowns.yaml — ${instance.dsnEnv} is unset, so no denylist row was read; each denial below is the ledger's word, not a row the door refuses on
10865
11341
  `);
10866
- if (mode.kind === "ledger") printLedger(ledgerActs(parsedLedger.ledger));
10867
- else printDenials(ledgerDenials(parsedLedger.ledger));
11342
+ printDenials(ledgerDenials(parsedLedger.ledger), "not applied (no database)");
10868
11343
  return 0;
10869
11344
  }
10870
11345
  const dsn = resolveDsn(instance);
@@ -10875,10 +11350,6 @@ async function takedownCommand(args) {
10875
11350
  for (const line of unmergedLines(applied.unmerged)) process.stderr.write(line + "\n");
10876
11351
  return 0;
10877
11352
  }
10878
- if (mode.kind === "ledger") {
10879
- printLedger(await withPool(dsn, (pool) => readLedger(pool, instance, 50)));
10880
- return 0;
10881
- }
10882
11353
  printDenials(await withPool(dsn, (pool) => listTakedowns(pool, instance)));
10883
11354
  return 0;
10884
11355
  }
@@ -11039,7 +11510,7 @@ async function runContentCli(argv) {
11039
11510
  }
11040
11511
  }
11041
11512
  //#endregion
11042
- //#region ../content/dist/record-Cxw0SUG3.mjs
11513
+ //#region ../content/dist/record-CKv4cZY8.mjs
11043
11514
  /**
11044
11515
  * May `surface` publish `doc` at instant `at`? `drafts` is the build's drafts switch,
11045
11516
  * which admits drafts to HUMAN surfaces only.
@@ -11070,6 +11541,11 @@ const OKF_PIN = {
11070
11541
  };
11071
11542
  const hex64 = z.string().regex(/^[0-9a-f]{64}$/, "a sha256 hex digest");
11072
11543
  const viewerList = z.array(z.string().min(1));
11544
+ const bundleEntry = z.object({
11545
+ viewer: z.string().min(1),
11546
+ sha256: hex64,
11547
+ files: z.number().int().nonnegative()
11548
+ }).strict();
11073
11549
  const lockSchema = z.object({
11074
11550
  format: z.literal(1),
11075
11551
  build_id: z.string().regex(/^sha256:[0-9a-f]{64}$/),
@@ -11117,7 +11593,8 @@ const lockSchema = z.object({
11117
11593
  indexes: z.array(z.object({
11118
11594
  path: z.string().min(1),
11119
11595
  sha256: hex64
11120
- }).strict())
11596
+ }).strict()),
11597
+ bundles: z.array(bundleEntry)
11121
11598
  }).strict();
11122
11599
  function parseLock(text) {
11123
11600
  let value;
@@ -11239,144 +11716,119 @@ function composeLock(input) {
11239
11716
  documents,
11240
11717
  companions,
11241
11718
  assets,
11242
- indexes
11719
+ indexes,
11720
+ bundles: input.bundles.map((b) => ({
11721
+ viewer: b.viewer,
11722
+ sha256: b.sha256,
11723
+ files: b.files
11724
+ }))
11243
11725
  };
11244
11726
  }
11245
11727
  function compare(a, b) {
11246
11728
  return a < b ? -1 : a > b ? 1 : 0;
11247
11729
  }
11248
- /**
11249
- * The takedown ledger's OTHER baseline: every entry any COMMITTED version of
11250
- * `.ksor/takedowns.yaml` has ever carried.
11251
- *
11252
- * The committed lock is a baseline too, and a good one — it holds each entry's
11253
- * digest, so an entry retargeted in place is caught. What it cannot do is prove
11254
- * that an entry was never deleted, because the lock travels in the SAME change
11255
- * as the ledger: delete the row, recompute `ledger_sha256`, empty
11256
- * `ledger_entries`, and the two agree with each other about a denial that is
11257
- * gone. Only history remembers.
11258
- *
11259
- * The baseline may be INCOMPLETE only if it SAYS so: every version history
11260
- * holds is read, or `entries` comes back null and the caller reports that it
11261
- * could not verify. A version silently skipped would contribute neither
11262
- * digests nor ids while the answer still read "verified"
11263
- * (`git-ledger.integration.test.ts`).
11264
- *
11265
- * This lives in the record module because THREE surfaces need the same answer
11266
- * `ksor build`, the emitted checker, and the site's stage (decision 19: a
11267
- * surface that refuses must refuse on both surfaces). Plain `git log` / `git
11268
- * show`, so nothing here needs installing.
11730
+ //#endregion
11731
+ //#region src/build/bundles.ts
11732
+ /**
11733
+ * `ksor build --bundles` (build spec §1 step 4; KSP-001 Class E, R15): one
11734
+ * OKF bundle per canonical viewer, holding exactly what that viewer's MACHINE
11735
+ * surfaces publish. Pure the CLI hands in the record the loader read and
11736
+ * the admission the lock decided, and writes what comes back.
11737
+ *
11738
+ * Selection is NOT re-derived here. A concept is in the bundle for viewer V
11739
+ * iff the lock admits it to V (`admittedViewersOf`: stable, effective,
11740
+ * unexpired, undenied, audience-overlapping) — the same predicate the site's
11741
+ * stage and the door's `admitted` set compose (decision 18). What this module
11742
+ * adds is what travels WITH a concept: its companions, the assets its body
11743
+ * references, and an index for every directory of the filtered tree —
11744
+ * regenerated, never the committed map, which lists every status and every
11745
+ * audience (decision 27's index clause).
11746
+ *
11747
+ * R5 for a directory someone will send somewhere: no byte of a concept the
11748
+ * viewer may not read. The AUDIENCE half is closed upstream the checker
11749
+ * refuses a link, a supersession pointer or a companion body that reaches a
11750
+ * narrower audience (`ksor-link-widens`), and an asset positioned under a
11751
+ * narrower directory with it — so a body copied verbatim cannot name what its
11752
+ * own readers may not open. A link to a concept excluded for another reason
11753
+ * (a draft, one not yet effective, one past `stale_after`, one denied) is a
11754
+ * different case: R5 is about audience, the body stays verbatim rather than
11755
+ * being rewritten, and the link dangles — REPORTED per bundle rather than
11756
+ * silently shipped, which is what `dangling` is for.
11269
11757
  */
11270
- const LEDGER = ".ksor/takedowns.yaml";
11758
+ const KNOWLEDGE = "knowledge/";
11271
11759
  /**
11272
- * `spawnSync` defaults to a 1 MB stdout buffer, and past it the child is KILLED
11273
- * `status` comes back null, so the query reads as a failure. A ledger with a
11274
- * few thousand entries, or one entry carrying a long reason, clears 1 MB
11275
- * easily, and the version was then dropped from the baseline while the caller
11276
- * was still told history had been verified. The ceiling stays finite on
11277
- * purpose: past it this returns null, which is a state the caller SAYS.
11760
+ * The companions of every concept, keyed by the parent's id derived from the
11761
+ * canonical attachment rule, never from a suffix list of this module's own
11762
+ * (`build.integration.test.ts` refuses a hand copy anywhere in this package).
11278
11763
  */
11279
- const MAX_BUFFER = 67108864;
11280
- /** One git query, read-only. Null on any non-zero exit, including no git at all. */
11281
- function git(root, args) {
11282
- const r = spawnSync("git", [...args], {
11283
- cwd: root,
11284
- encoding: "utf8",
11285
- maxBuffer: MAX_BUFFER
11286
- });
11287
- return r.status === 0 ? r.stdout : null;
11764
+ function companionsByParent(files) {
11765
+ const out = /* @__PURE__ */ new Map();
11766
+ for (const file of files.keys()) {
11767
+ if (!file.startsWith(KNOWLEDGE)) continue;
11768
+ const rel = file.slice(10);
11769
+ const base = rel.slice(rel.lastIndexOf("/") + 1);
11770
+ if (attachmentKindOf(base) === null) continue;
11771
+ const parent = parentDocumentOf(base);
11772
+ if (parent === null) continue;
11773
+ const parentId = `${rel.slice(0, rel.length - base.length)}${parent}`.replace(/\.md$/, "");
11774
+ out.set(parentId, [...out.get(parentId) ?? [], rel]);
11775
+ }
11776
+ return out;
11288
11777
  }
11289
- function historicLedger(root) {
11290
- const inside = git(root, ["rev-parse", "--is-inside-work-tree"]);
11291
- if (inside === null || inside.trim() !== "true") return {
11292
- repository: false,
11293
- shallow: false,
11294
- entries: null,
11295
- unreadable: null
11296
- };
11297
- const shallow = (git(root, ["rev-parse", "--is-shallow-repository"]) ?? "").trim() === "true";
11298
- const born = git(root, [
11299
- "rev-parse",
11300
- "--verify",
11301
- "--quiet",
11302
- "HEAD"
11303
- ]) !== null;
11304
- const entries = shallow ? null : born ? historicEntries(root) : [];
11305
- return {
11306
- repository: true,
11307
- shallow,
11308
- entries,
11309
- unreadable: entries !== null ? null : shallow ? "shallow" : "unreadable"
11310
- };
11778
+ function planBundles(input) {
11779
+ const companions = companionsByParent(input.files);
11780
+ const conceptIds = new Set(input.concepts.map((c) => c.id));
11781
+ const dirs = input.dirs.filter((d) => d.startsWith(KNOWLEDGE)).map((d) => d.slice(10));
11782
+ return input.viewers.map((viewer) => {
11783
+ const files = /* @__PURE__ */ new Map();
11784
+ const admitted = input.concepts.filter((c) => c.admitted.includes(viewer));
11785
+ const held = new Set(admitted.map((c) => c.id));
11786
+ const dangling = [];
11787
+ for (const concept of admitted) {
11788
+ const bodies = [[`${concept.id}.md`, input.files.get(`${KNOWLEDGE}${concept.id}.md`) ?? ""], ...(companions.get(concept.id) ?? []).map((rel) => [rel, input.files.get(`${KNOWLEDGE}${rel}`) ?? ""])];
11789
+ for (const [rel, text] of bodies) {
11790
+ files.set(rel, Buffer.from(text, "utf8"));
11791
+ const split = splitFrontmatter(text, rel);
11792
+ const seen = /* @__PURE__ */ new Set();
11793
+ for (const target of linkTargets(split.ok ? split.body : text)) {
11794
+ const id = resolveLink(concept.id, target);
11795
+ if (id === null || seen.has(id)) continue;
11796
+ seen.add(id);
11797
+ const asset = input.assets.get(`${KNOWLEDGE}${id}`);
11798
+ if (asset !== void 0) files.set(id, asset);
11799
+ else if (conceptIds.has(id) && !held.has(id)) dangling.push({
11800
+ from: rel,
11801
+ to: `${id}.md`
11802
+ });
11803
+ }
11804
+ }
11805
+ }
11806
+ const indexes = generateIndexes({
11807
+ title: input.title,
11808
+ concepts: admitted.map((c) => ({
11809
+ id: c.id,
11810
+ title: c.title,
11811
+ description: c.description,
11812
+ order: c.order
11813
+ })),
11814
+ dirs
11815
+ });
11816
+ for (const [rel, text] of indexes) files.set(rel, Buffer.from(text, "utf8"));
11817
+ return {
11818
+ viewer,
11819
+ files,
11820
+ dangling
11821
+ };
11822
+ });
11311
11823
  }
11312
11824
  /**
11313
- * Every id history has ever recorded, each with the text it carried the FIRST
11314
- * time it was written. A version that parses contributes each entry's digest,
11315
- * so an entry EDITED in place is caught, not only one deleted; a version that
11316
- * no longer parses still contributes its ids, read permissively — the point
11317
- * there is that an id once written never disappears.
11318
- *
11319
- * FIRST, not every: keying this by `id\tdigest` kept one baseline entry per
11320
- * version an id ever had, so a tamper that was COMMITTED and then UNDONE left
11321
- * two digests for one id, the restored entry matched only one of them, and
11322
- * `ksor-ledger-amended` fired for good. The record became permanently
11323
- * unbuildable — by a tamper that had already been put right — and the only
11324
- * escape was rewriting git history, which is not a remedy a refusal may
11325
- * demand (found in review, 2026-08-25).
11326
- *
11327
- * Taking the OLDEST is what makes the guarantee both enforceable and
11328
- * escapable. It still refuses a committed tamper (the baseline is what the
11329
- * entry said when it was written, so committing the edit does not launder
11330
- * it), and the remedy it names — put the entry back — now actually clears
11331
- * it. Taking the NEWEST would have done the opposite on both counts.
11825
+ * The digest `build.lock.json` records for a bundle: sha256 over the JSON of
11826
+ * its sorted `[path, sha256(bytes)]` pairs. Stated this plainly so a recipient
11827
+ * holding only the directory can recompute it and match it to a publication.
11332
11828
  */
11333
- function historicEntries(root) {
11334
- const atRoot = `${(git(root, ["rev-parse", "--show-prefix"]) ?? "").trim()}${LEDGER}`;
11335
- const commits = git(root, [
11336
- "log",
11337
- "--full-history",
11338
- "--topo-order",
11339
- "--format=%H",
11340
- "--",
11341
- LEDGER
11342
- ]);
11343
- if (commits === null) return null;
11344
- const seen = /* @__PURE__ */ new Map();
11345
- for (const sha of commits.split("\n").filter((s) => s !== "")) {
11346
- const text = git(root, ["show", `${sha}:${atRoot}`]);
11347
- if (text === null) {
11348
- const listed = git(root, [
11349
- "ls-tree",
11350
- "--full-tree",
11351
- "--name-only",
11352
- sha,
11353
- "--",
11354
- atRoot
11355
- ]);
11356
- if (listed !== null && listed.trim() === "") continue;
11357
- return null;
11358
- }
11359
- const where = sha.slice(0, 7);
11360
- const parsed = parseLedger(text, LEDGER);
11361
- if (parsed.ok) {
11362
- for (const entry of parsed.ledger.entries) seen.set(entry.id, {
11363
- id: entry.id,
11364
- digest: entryDigest(entry),
11365
- entry,
11366
- where
11367
- });
11368
- continue;
11369
- }
11370
- for (const m of text.matchAll(/^\s*(?:-\s+)?id:\s*["']?([^\s"']+)/gm)) {
11371
- const id = m[1] ?? "";
11372
- if (!seen.has(id)) seen.set(id, {
11373
- id,
11374
- digest: null,
11375
- where
11376
- });
11377
- }
11378
- }
11379
- return [...seen.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
11829
+ function bundleDigest(files) {
11830
+ const pairs = [...files].map(([rel, bytes]) => [rel, sha256Hex(bytes)]).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
11831
+ return sha256Hex(JSON.stringify(pairs));
11380
11832
  }
11381
11833
  //#endregion
11382
11834
  //#region src/build/git.ts
@@ -11504,7 +11956,7 @@ const iso = (ms) => new Date(ms).toISOString();
11504
11956
  * and `build.lock.json`. Database-free, network-free. A refusal leaves the
11505
11957
  * tree exactly as it found it, with the slug on the first stderr line.
11506
11958
  */
11507
- const BUILD_USAGE = `Usage: ksor build [--instance <path>] [--as-of <instant>] [--strict] [--allow-unverifiable-ledger]
11959
+ const BUILD_USAGE = `Usage: ksor build [--instance <path>] [--as-of <instant>] [--strict] [--allow-unverifiable-ledger] [--bundles]
11508
11960
 
11509
11961
  Generates every knowledge/**/index.md in memory, runs the record checker, and
11510
11962
  on green writes the indexes whose bytes changed plus build.lock.json — the
@@ -11519,8 +11971,35 @@ toolchain. A refusal (exit 1, slug first on stderr) writes nothing.
11519
11971
  --allow-unverifiable-ledger
11520
11972
  build on a shallow clone, where the ledger's history
11521
11973
  cannot be checked for deleted entries
11522
- --bundles designed, not implemented (exit 2)
11974
+ --bundles also write one OKF bundle per viewer under
11975
+ .ksor/out/bundles/<viewer>/ — public, and [public, X]
11976
+ for each registered audience X — holding only what that
11977
+ viewer's machine surfaces publish, with its indexes
11978
+ regenerated for that tree; any OKF consumer reads it
11979
+ with no ksor in the loop. The lock beside them names
11980
+ the build. The directory is replaced on every run.
11523
11981
  `;
11982
+ /** What `--bundles` writes, record-relative. Gitignored by the scaffold's `.ksor/*` rule. */
11983
+ const BUNDLES_DIR = ".ksor/out/bundles";
11984
+ const LOCK_NAME = "build.lock.json";
11985
+ /**
11986
+ * An audience identifier that can be a directory name. The policy admits any
11987
+ * non-empty string as a registry key, and every other surface uses one only as
11988
+ * a token — `--bundles` is the first to use it as a PATH, so `../x` written as
11989
+ * given would land outside the output directory. The first character must be a
11990
+ * letter or a digit, which is what stops `.`, `..`, a dotfile and a name a
11991
+ * shell reads as a flag; `-`, `_` and `.` are fine after it.
11992
+ *
11993
+ * The LAST character may not be a `.`. Win32 path normalization strips a
11994
+ * trailing dot from a path segment, so `internal.` and `internal` are two
11995
+ * viewers in the policy and ONE directory on Windows — the case rule's merge
11996
+ * exactly, and the case fold cannot see it. Refused on every platform, for the
11997
+ * reason that rule gives: a record must not build here and leak there. A `.`
11998
+ * INSIDE the name (`v1.2`) is untouched; nothing normalizes it away.
11999
+ */
12000
+ const PATH_SEGMENT = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9_-])?$/;
12001
+ /** {@link PATH_SEGMENT} in words, in ONE place, so the refusal and the docs cannot drift from the regex. */
12002
+ const PATH_SEGMENT_PROSE = "a letter or a digit first, then letters, digits, `-`, `_` and `.`, and never a `.` last";
11524
12003
  function parseArgs$2(args) {
11525
12004
  let instance = null;
11526
12005
  let asOf = null;
@@ -11577,6 +12056,40 @@ function refuse$1(io, slug, why, fix) {
11577
12056
  io.err(`error: ${slug}\n${why}\n fix: ${fix}\n`);
11578
12057
  return exitCodes.refused;
11579
12058
  }
12059
+ /**
12060
+ * What every canonical viewer name has to be for `.ksor/out/bundles/<name>/`
12061
+ * to hold what the lock says it holds.
12062
+ *
12063
+ * Run on EVERY build, not only under `--bundles`, because `bundles[]` is in
12064
+ * the lock on every build: a plain build that let `../escape` through would
12065
+ * commit a digest for a directory the tool refuses to write — provenance for
12066
+ * something that cannot exist (invariant: provenance is load-bearing). The
12067
+ * alternative considered was omitting unbuildable viewers from `bundles[]`;
12068
+ * it keeps the lock honest too, but leaves the record broken and the owner
12069
+ * unwarned until their first exchange, which is the weaker guarantee. Here
12070
+ * the owner learns at the build they already run.
12071
+ */
12072
+ function viewerRefusal(viewers) {
12073
+ const list = (ids) => ids.map((v) => JSON.stringify(v)).join(", ");
12074
+ const unsafe = viewers.filter((v) => !PATH_SEGMENT.test(v) || v.toLowerCase() === LOCK_NAME);
12075
+ if (unsafe.length > 0) return {
12076
+ slug: "ksor-audience-identifier-invalid",
12077
+ why: `the audience identifier${unsafe.length === 1 ? "" : "s"} ${list(unsafe)} cannot name a bundle directory: --bundles writes each viewer's bundle to ${BUNDLES_DIR}/<identifier>/ beside a copy of ${LOCK_NAME}, so an identifier that is not a plain path segment would land somewhere else, one ending in \`.\` names a DIFFERENT directory on Windows (which strips a trailing dot from a path segment, merging it into the name without one), and one named ${LOCK_NAME} would collide with the lock. build.lock.json records that bundle's digest on EVERY build, flag or not, so this is refused here rather than only under --bundles`,
12078
+ fix: `name audiences in plain words (${PATH_SEGMENT_PROSE}) in .ksor/governance.yaml and in every \`ksor.audience\` list, then rebuild`
12079
+ };
12080
+ const byFold = /* @__PURE__ */ new Map();
12081
+ for (const v of viewers) {
12082
+ const fold = v.toLowerCase();
12083
+ byFold.set(fold, [...byFold.get(fold) ?? [], v]);
12084
+ }
12085
+ const collided = [...byFold.values()].filter((group) => group.length > 1);
12086
+ if (collided.length > 0) return {
12087
+ slug: "ksor-audience-identifier-collides",
12088
+ why: `${collided.map((g) => list(g)).join("; ")} differ only in case, so each set is several viewers naming ONE directory: --bundles writes ${BUNDLES_DIR}/<identifier>/ per viewer, and on a case-insensitive filesystem (macOS and Windows, by default) the later bundle merges into the earlier one — leaving a directory that holds concepts the viewer named on it may not read, and a digest in build.lock.json that no longer describes it. build.lock.json records each of those digests on EVERY build, flag or not, so this is refused here rather than only under --bundles`,
12089
+ fix: `give each audience in .ksor/governance.yaml a name that differs by more than case — \`public\` is reserved, casefolded too — and update every \`ksor.audience\` list that named the one you dropped, then rebuild`
12090
+ };
12091
+ return null;
12092
+ }
11580
12093
  function runBuild(args, cwd, io, options) {
11581
12094
  if (args.includes("--help") || args.includes("-h")) {
11582
12095
  io.out(BUILD_USAGE);
@@ -11587,10 +12100,6 @@ function runBuild(args, cwd, io, options) {
11587
12100
  io.err(`error: bad-args\n${parsed}\n${BUILD_USAGE}`);
11588
12101
  return exitCodes.refused;
11589
12102
  }
11590
- if (parsed.bundles) {
11591
- io.out(`ksor build --bundles: designed but not implemented in ${options.version}.\n`);
11592
- return exitCodes.notImplemented;
11593
- }
11594
12103
  const start = parsed.instance === null ? cwd : existsSync(parsed.instance) && statSync(parsed.instance).isFile() ? path.dirname(path.resolve(cwd, parsed.instance)) : path.resolve(cwd, parsed.instance);
11595
12104
  const root = resolveInstanceDir(start);
11596
12105
  if (root === null) return refuse$1(io, "ksor-instance-missing", `no instance.md at or above ${start} — the record root is the directory holding it`, "run from inside the record, or pass --instance <path>");
@@ -11619,10 +12128,12 @@ function runBuild(args, cwd, io, options) {
11619
12128
  mode: "build",
11620
12129
  ledgerBaselines: baselines
11621
12130
  });
11622
- if (result.refusals.length > 0) {
11623
- io.err(`error: ${result.refusals[0]?.slug ?? "ksor-refused"}\n`);
11624
- io.err(`ksor build: ${result.refusals.length} problem(s) — nothing written:\n\n`);
11625
- for (const r of result.refusals) io.err(` ${formatRefusal(r)}\n\n`);
12131
+ const change = checkChangeControl(root, result.concepts, record.files);
12132
+ const refusals = sortRefusals([...result.refusals, ...change.refusals]);
12133
+ if (refusals.length > 0) {
12134
+ io.err(`error: ${refusals[0]?.slug ?? "ksor-refused"}\n`);
12135
+ io.err(`ksor build: ${refusals.length} problem(s) — nothing written:\n\n`);
12136
+ for (const r of refusals) io.err(` ${formatRefusal(r)}\n\n`);
11626
12137
  return exitCodes.refused;
11627
12138
  }
11628
12139
  const pendingIndexes = [...result.indexes].filter(([rel, text]) => record.files.get(rel) !== text).map(([rel]) => rel);
@@ -11631,18 +12142,38 @@ function runBuild(args, cwd, io, options) {
11631
12142
  const ledgerText = record.files.get(".ksor/takedowns.yaml") ?? null;
11632
12143
  const ledger = parseLedger(ledgerText, ".ksor/takedowns.yaml");
11633
12144
  const denials = ledger.ok ? inForce(ledger.ledger) : [];
12145
+ const asOf = parsed.asOf ?? Date.now();
12146
+ const audiences = result.policy?.audiences ?? [];
12147
+ const viewers = canonicalViewers(audiences);
12148
+ const bad = viewerRefusal(Object.keys(viewers));
12149
+ if (bad !== null) return refuse$1(io, bad.slug, bad.why, bad.fix);
12150
+ const instance = parseInstanceDocument(record.files.get("instance.md") ?? "");
12151
+ const bundles = planBundles({
12152
+ title: instance.ok ? instance.instance.title : "Index",
12153
+ viewers: Object.keys(viewers),
12154
+ concepts: result.concepts.map((c) => ({
12155
+ id: c.id,
12156
+ title: c.title,
12157
+ description: c.description,
12158
+ order: c.order,
12159
+ admitted: admittedViewersOf(c, viewers, asOf, denials)
12160
+ })),
12161
+ files: record.files,
12162
+ assets: record.assets,
12163
+ dirs: record.dirs
12164
+ });
11634
12165
  const lock = composeLock({
11635
12166
  ksorVersion: options.version,
11636
12167
  sourceCommit: facts.sourceCommit,
11637
12168
  dirty: facts.dirty || pendingIndexes.length > 0 || staleIndexes.length > 0,
11638
- asOf: parsed.asOf ?? Date.now(),
12169
+ asOf,
11639
12170
  drafts: options.drafts,
11640
12171
  instanceText: record.files.get("instance.md") ?? "",
11641
12172
  policyText: record.files.get(".ksor/governance.yaml") ?? "",
11642
12173
  peopleText: record.files.get(".ksor/people.yaml") ?? null,
11643
12174
  ledgerText,
11644
12175
  ledgerEntries: result.ledgerEntries,
11645
- audiences: result.policy?.audiences ?? [],
12176
+ audiences,
11646
12177
  concepts: result.concepts.map((c) => ({
11647
12178
  id: c.id,
11648
12179
  status: c.status,
@@ -11663,11 +12194,18 @@ function runBuild(args, cwd, io, options) {
11663
12194
  path: rel.slice(10),
11664
12195
  text
11665
12196
  })),
11666
- denials
12197
+ denials,
12198
+ bundles: bundles.map((b) => ({
12199
+ viewer: b.viewer,
12200
+ sha256: bundleDigest(b.files),
12201
+ files: b.files.size
12202
+ }))
11667
12203
  });
12204
+ const lockText = `${JSON.stringify(lock, null, 2)}\n`;
11668
12205
  for (const rel of pendingIndexes) writeFileSync(path.join(root, rel), result.indexes.get(rel));
11669
12206
  for (const rel of staleIndexes) unlinkSync(path.join(root, rel));
11670
- writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
12207
+ if (parsed.bundles) writeBundles(root, bundles, lockText);
12208
+ writeFileSync(lockPath, lockText);
11671
12209
  const admitted = lock.documents.filter((d) => d.admitted.length > 0).length;
11672
12210
  const notice = lifecycleNotice(result.concepts.map((c) => ({
11673
12211
  path: c.path.slice(10),
@@ -11678,9 +12216,42 @@ function runBuild(args, cwd, io, options) {
11678
12216
  io.out(`ksor build: ${lock.documents.length} document(s), ${admitted} admitted to a machine surface at ${lock.as_of}\n${provenanceLine({
11679
12217
  ...facts,
11680
12218
  dirty: lock.dirty
11681
- }, root)}\n` + notice + `${pendingIndexes.map((w) => ` wrote ${w}\n`).join("")}${staleIndexes.map((r) => ` removed ${r} (its directory earns no index)\n`).join("")} wrote build.lock.json — build_id ${lock.build_id}\n`);
12219
+ }, root)}\n` + (change.notice === null ? "" : ` ${change.notice}\n`) + notice + `${pendingIndexes.map((w) => ` wrote ${w}\n`).join("")}${staleIndexes.map((r) => ` removed ${r} (its directory earns no index)\n`).join("")}` + (parsed.bundles ? bundlesReport(bundles, viewers) : "") + ` wrote build.lock.json — build_id ${lock.build_id}\n`);
11682
12220
  return 0;
11683
12221
  }
12222
+ /**
12223
+ * Replace `.ksor/out/bundles/` with exactly this build's bundles. Replaced,
12224
+ * not merged: a bundle an earlier build wrote for an audience the policy no
12225
+ * longer registers, or a file a document no longer admits, would otherwise sit
12226
+ * beside the fresh ones under the same directory — the sims leak of 2026-08-25
12227
+ * (`pruneSims`) in a directory that exists to be sent somewhere. The lock goes
12228
+ * beside them the way it sits beside `knowledge/` (KSP-001 4.1.2), so the
12229
+ * output travels with the provenance that names it.
12230
+ */
12231
+ function writeBundles(root, bundles, lockText) {
12232
+ const out = path.join(root, BUNDLES_DIR);
12233
+ rmSync(out, {
12234
+ recursive: true,
12235
+ force: true
12236
+ });
12237
+ for (const bundle of bundles) for (const [rel, bytes] of bundle.files) {
12238
+ const to = path.join(out, bundle.viewer, rel);
12239
+ mkdirSync(path.dirname(to), { recursive: true });
12240
+ writeFileSync(to, bytes);
12241
+ }
12242
+ writeFileSync(path.join(out, LOCK_NAME), lockText);
12243
+ }
12244
+ /** One line per bundle written, and a line per link it carries to a concept it excludes. */
12245
+ function bundlesReport(bundles, viewers) {
12246
+ let text = "";
12247
+ for (const bundle of bundles) {
12248
+ const list = (viewers[bundle.viewer] ?? [bundle.viewer]).join(", ");
12249
+ text += ` wrote ${BUNDLES_DIR}/${bundle.viewer}/ — the OKF bundle for viewer [${list}], ${bundle.files.size} file(s)\n`;
12250
+ for (const link of bundle.dangling) text += ` ${link.from} links to ${link.to}, which this bundle excludes — the link dangles for its reader\n`;
12251
+ }
12252
+ text += ` wrote ${BUNDLES_DIR}/build.lock.json — a copy, so the bundles travel with the build that made them\n`;
12253
+ return text;
12254
+ }
11684
12255
  //#endregion
11685
12256
  //#region src/init/manager.ts
11686
12257
  /**
@@ -12121,8 +12692,7 @@ function unifiedDiff(change) {
12121
12692
  const before = lines(change.before);
12122
12693
  const after = lines(change.after);
12123
12694
  const header = `--- ${change.before === null ? "/dev/null" : `a/${change.path}`}\n+++ ${change.after === null ? "/dev/null" : `b/${change.path}`}\n`;
12124
- if (change.generated === true) return header + `@@ generated @@ replaced wholesale: ${before.length} line(s) become ${after.length}. This file is built from ksor's own rules and is never hand-edited.
12125
- `;
12695
+ if (change.generated === true) return header + (change.after === null ? `@@ generated @@ deleted: ${before.length} line(s). ` : `@@ generated @@ replaced wholesale: ${before.length} line(s) become ${after.length}. `) + "This file is built from ksor's own rules and is never hand-edited.\n";
12126
12696
  return header + hunks(before, after);
12127
12697
  }
12128
12698
  /** Every change as one text, in path order, with a heading a reader can scan. */
@@ -13142,6 +13712,8 @@ no instance.md at or above ${start} — the record root is the directory holding
13142
13712
  changes.push(...checkerChanges(root, options.templatesDir));
13143
13713
  const manifest = manifestChange(root);
13144
13714
  if (manifest !== null) changes.push(manifest);
13715
+ const staleLock = staleLockChange(root);
13716
+ if (staleLock !== null) changes.push(staleLock);
13145
13717
  if (parsed.writeSite) changes.push(...siteChanges(root, options.templatesDir, {
13146
13718
  name: identity.ok ? identity.name : path.basename(root),
13147
13719
  version: options.version
@@ -13392,6 +13964,34 @@ function checkerChanges(root, templatesDir) {
13392
13964
  return out;
13393
13965
  }
13394
13966
  /**
13967
+ * A committed `build.lock.json` this ksor cannot read. The lock gains keys as
13968
+ * the record's surfaces grow, and `ksor build` REFUSES one that is missing any
13969
+ * of them rather than regenerating it — a lock nothing can read is a takedown
13970
+ * baseline that quietly holds nothing (record spec §4), which is the state
13971
+ * `ksor-lock-invalid` exists to stop. So the refusal is right and the upgrade
13972
+ * still has to get past it: `pnpm build` is `ksor build && <site build>`, the
13973
+ * deploy command under decision 29, so an adopter who took a new ksor would
13974
+ * find their build and their deploy red on a file only a human could delete.
13975
+ *
13976
+ * Decision 28 permits removing a shape only when the removal is paired with a
13977
+ * MIGRATION, and this is the pairing: migrate offers exactly the deletion the
13978
+ * refusal asks for, so `docs/upgrading.md`'s four steps carry it. Deleted and
13979
+ * never rewritten — only a build can compute a lock, and inventing one here
13980
+ * would forge the provenance the file exists to hold.
13981
+ */
13982
+ function staleLockChange(root) {
13983
+ const abs = path.join(root, "build.lock.json");
13984
+ if (!existsSync(abs)) return null;
13985
+ const before = readFileSync(abs, "utf8");
13986
+ if (parseLock(before).ok) return null;
13987
+ return {
13988
+ path: "build.lock.json",
13989
+ before,
13990
+ after: null,
13991
+ generated: true
13992
+ };
13993
+ }
13994
+ /**
13395
13995
  * The root scripts this release breaks. `export-denylist` ran
13396
13996
  * `ksor takedown --export`, a flag the committed ledger retired, and the
13397
13997
  * scaffold's own `build` calls it first — so the adopter's build died on