@panaversity/ksor 0.0.54 → 0.0.55

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 CHANGED
@@ -1,5 +1,184 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.55
4
+
5
+ ### Patch Changes
6
+
7
+ - 4ecf549: Test infrastructure only — nothing an adopter installs behaves differently.
8
+
9
+ The behavioural evals scored a missing `top_cosine` as `-1`. When a provider
10
+ rate-limits, the read plane degrades to keyword-only by design, so searches
11
+ answer with no score — and the assertions then compared sentinels, reporting a
12
+ vendor outage as "the abstention floor is broken". Four CI failures in a day
13
+ read that way before anyone looked past the assertion. A missing score now
14
+ refuses, naming the cause, and never invents the number that is absent.
15
+
16
+ - e476197: Fix the hello-world tutorial, which could not be completed as written.
17
+
18
+ Three defects, all found by walking the published 0.0.54 rather than by reading:
19
+
20
+ - Step 3's document declared `type: Policy`. `Policy` is a reserved type, so the
21
+ record demands `sources` — `ksor build`, `npm run check` and the dev server all
22
+ refused it, and steps 4 through 10 were unreachable. It is now `type: Document`,
23
+ the type the profile promises never to reserve, with a note on why and on what
24
+ an agent should do when it reaches for a reserved one.
25
+ - Step 1 scaffolds with `npx`, which emits an **npm** project, and every command
26
+ after it said `pnpm`. On that project `pnpm install && pnpm dev` fails with
27
+ `sh: next: command not found`. All sixteen commands are npm's now, and the step
28
+ that explains manager detection says which one the rest of the tutorial speaks.
29
+ - The captured outputs had been trimmed after capture, in a document whose second
30
+ paragraph promises they were "pasted as it appeared": `ksor serve`'s boot report
31
+ was missing the `trust` line it has always printed, the build outputs were
32
+ missing their timestamp, `source:` and `wrote` lines, and the port-conflict
33
+ refusal was quoted offering `pnpm serve` where it says `ksor serve`.
34
+
35
+ The walk also surfaced that `ksor init` leaves a repo with no commits, so every
36
+ reader's first build prints `source: unspecified`. Rather than hide it, the
37
+ tutorial now shows it and folds `git commit` into the approval step — which is
38
+ where provenance belongs anyway, and which lets the second build print a real
39
+ commit sha.
40
+
41
+ The tutorial also said `.mcp.json`'s "first is Neon's" and named the second
42
+ server nowhere, and said nothing about the Neon server acting on the whole Neon
43
+ account. Both are fixed here for the tutorial; the emitted scaffold's copies of
44
+ the same two defects are fixed separately.
45
+
46
+ Only the tutorial and the test that pins its prompts changed; nothing an adopter
47
+ installs behaves differently.
48
+
49
+ - ae49524: Stop a spent OpenAI balance from quarantining content and flipping a generation,
50
+ and name the right variable when a provider key is missing.
51
+
52
+ **The serious one.** `insufficient_quota` — OpenAI's answer to an exhausted
53
+ balance, which arrives as 429 like an ordinary rate limit — was classified
54
+ non-retryable, correctly, because no amount of waiting adds credit. But
55
+ "non-retryable" is what the ingest drain reads as **poison chunk**: it
56
+ binary-splits the batch down to singletons and marks each `failed`. A spent
57
+ balance arrives on _every_ chunk, so a run walked the queue quarantining
58
+ everything it touched; if the failed fraction stayed under
59
+ `MAX_FAILED_FRACTION` (2%), `generationReady` admitted it and the generation
60
+ **flipped** — publishing a record in which exactly the passages the owner had
61
+ just edited were unsearchable, `ksor ingest` exit 0, the billing reason visible
62
+ only in `chunks.embed_error`. The same event on Gemini aborts the run, so
63
+ switching provider silently changed what a spent quota does.
64
+
65
+ The drain now has three answers instead of two: retryable (abort, chunks stay
66
+ pending), **fatal** (abort the same way, but without spending five backoffs
67
+ first — the account is what is wrong, not the passage), and everything else
68
+ (binary-split to the poison chunk). `isFatal` is optional on `EmbeddingProvider`,
69
+ so a provider that cannot tell keeps the old two-kind behaviour and Gemini's
70
+ path is unchanged.
71
+
72
+ **The missing-key refusal names the variable.** `ksor serve` on an
73
+ `embedding.provider: openai` record said `embedding provider "openai" needs an
74
+ API key and none was supplied` and stopped — while `ksor serve --help`,
75
+ `env.example` and `docs/deploying.md` all named `GEMINI_API_KEY`, which that
76
+ door does not read. The registry row already held `keyEnv`; it now reaches the
77
+ operator (`— set OPENAI_API_KEY`), and all three documents describe the choice
78
+ instead of one vendor.
79
+
80
+ **`ksor calibrate`'s Gemini requirement is stated rather than papered over.**
81
+ Question synthesis is Gemini-only today, so a record embedding with
82
+ `OPENAI_API_KEY` is still refused for a Google key when calibrating through the
83
+ synthesized door. That gap is now said plainly in the refusal and in
84
+ `docs/ingesting.md`, which taught calibration without mentioning it. The
85
+ `--queries-file` door avoids it entirely.
86
+
87
+ **The OpenAI live test announces itself.** It is gated on `OPENAI_API_KEY`, no
88
+ workflow supplied one, and a false `describe.runIf` contributes nothing to a run
89
+ — so the suite its own header calls "the tripwire for vendor drift" was absent
90
+ from CI and reported as absent by nobody. It now prints `skipped — set
91
+ OPENAI_API_KEY`, the way Gemini's does, and CI passes the secret so the tripwire
92
+ arms the moment one is added.
93
+
94
+ Found by an adversarial review of this week's commits.
95
+
96
+ - ff99eb5: Hash `.ksor/people.yaml` into `build_id`, so the two surfaces of one build
97
+ cannot publish different provenance.
98
+
99
+ The phone book added in 0.0.53 rewrites the actor printed on every Owner,
100
+ Approved, Withdrawn and Trust row — `displayActor` replaces `human:contractor-a`
101
+ with "Human: Jane Doe, VP Compliance", and the identifier does not appear on the
102
+ page at all. It was hashed by nothing. `.ksor/governance.yaml` and
103
+ `.ksor/takedowns.yaml` are both in `build_id`; this one was left out, on the
104
+ stated reasoning that including it would refuse the next site build after a
105
+ spelling correction.
106
+
107
+ That is the trade critical rule 1 forbids, and the consequence was reachable
108
+ without doing anything unusual: edit a name, `pnpm check` stays green,
109
+ `ksor build` emits a byte-identical lock, and the deployed page publishes an
110
+ approver the `/md/` twin stamped with that same `build_id` contradicts. An
111
+ auditor reconciling the page against the lock finds nothing wrong, because the
112
+ string they are auditing was never covered by it.
113
+
114
+ Now: `people_sha256` joins `policy_sha256` and `ledger_sha256` in the lock and
115
+ in `build_id`; `.ksor/people.yaml` joins the inputs that move `source_commit`;
116
+ and the site's staleness gate compares it like the other three, so an edit the
117
+ lock never saw refuses with `ksor-lock-stale` naming the file. Refusing until
118
+ `ksor build` is re-run is the behaviour, not a regression — it is what every
119
+ other published byte already does.
120
+
121
+ Two things found alongside it, in the same file:
122
+
123
+ - `people.ts` claimed "duplicate keys are refused by the parser rather than
124
+ resolved by whichever came last". They were not. `uniqueKeys: true` makes the
125
+ parser RECORD a duplicate; `toJS()` still resolves last-wins, and nothing read
126
+ the errors — so two entries for one actor published the second person's name
127
+ on the first person's approval, the precise collision the map replaced a name
128
+ derivation to avoid. A duplicate now drops the whole book, and identifiers are
129
+ published instead.
130
+ - The rule lived behind a module that reads `instance.md` on import, so it could
131
+ only be tested by building a record on disk — which is why it shipped asserted
132
+ by a comment. It is now a leaf, `lib/people-rule.ts`, with the shipped
133
+ function under test.
134
+
135
+ **Upgrading:** a lock written before this refuses with `ksor-lock-invalid`
136
+ naming `people_sha256`; run `ksor build` and commit the lock it writes.
137
+
138
+ Found by an adversarial review of this week's commits.
139
+
140
+ - b45d477: Say what the scaffold's `.mcp.json` attaches to an adopter's coding agent, and
141
+ stop the README telling them to destroy it.
142
+
143
+ `ksor init` emits `.mcp.json` with two servers. The emitted README and AGENTS.md
144
+ both said "the first is Neon" and named the second nowhere — so
145
+ `agentfactory-system-of-record`, a Panaversity-operated endpoint, was wired into
146
+ every adopter's coding agent with no emitted document mentioning it. `.mcp.json`
147
+ attaches servers to the agent that OPERATES the record; a server nobody
148
+ documented is a capability nobody reviewed.
149
+
150
+ Both are now named, with what each is and that either may be deleted. The second
151
+ is described as what it is: a read-only example record that is **not** the
152
+ adopter's and that nothing in the project depends on.
153
+
154
+ The Neon step also said only that the server exists. It acts on the Neon
155
+ _account_ — an agent holding it can create and delete projects and branches — so
156
+ the README and AGENTS.md now say that before handing over a prompt that runs
157
+ against real infrastructure, and point at Neon's own documentation for the
158
+ scopes rather than paraphrasing them.
159
+
160
+ And the "Test the door with an actual agent" section told the adopter to _write_
161
+ `.mcp.json` with a file containing only `test-record` — overwriting the Neon
162
+ entry the same README depends on two sections earlier — and then closed with
163
+ "Delete `.mcp.json`, or keep it". It now shows the entry to **add**, and says not
164
+ to delete the file.
165
+
166
+ A guard derived from `mcp.json` itself asserts every server key appears in both
167
+ emitted documents, so adding a server and saying nothing fails on the server
168
+ that was added. Mutation-tested: unnaming the second server turns both red.
169
+
170
+ Found by an adversarial review of this week's commits. Whether the scaffold
171
+ should ship a second, vendor-operated MCP record at all is an owner question and
172
+ is untouched here.
173
+
174
+ - 5283084: Test infrastructure only — nothing an adopter installs behaves differently.
175
+
176
+ A skill's `description` is its trigger and nothing measured it (#30). Every
177
+ prompt the hello world tells a reader to say is now matched to a shipped skill
178
+ or recorded as needing none, and each skill's trigger phrases are pinned — so
179
+ narrowing one, the failure mode where a skill silently stops firing, goes red
180
+ naming the phrase. The model-scored half of that issue is untouched.
181
+
3
182
  ## 0.0.54
4
183
 
5
184
  ### Patch Changes
@@ -10892,7 +10892,7 @@ var require_public_api = /* @__PURE__ */ __commonJSMin(((exports) => {
10892
10892
  exports.stringify = stringify;
10893
10893
  }));
10894
10894
  //#endregion
10895
- //#region ../content/dist/check-DNWlQuBg.mjs
10895
+ //#region ../content/dist/check-6fB_kR4G.mjs
10896
10896
  var import_dist = (/* @__PURE__ */ __commonJSMin(((exports) => {
10897
10897
  var composer = require_composer();
10898
10898
  var Document = require_Document();
@@ -12150,6 +12150,7 @@ function changedFields(before, after) {
12150
12150
  const CONTROL_FILES = [
12151
12151
  "instance.md",
12152
12152
  ".ksor/governance.yaml",
12153
+ ".ksor/people.yaml",
12153
12154
  ".ksor/takedowns.yaml"
12154
12155
  ];
12155
12156
  /** Files the operating system writes behind the author's back: ignored, never reported. */
@@ -13714,7 +13715,7 @@ function checkAgainstPolicy(concept, policy, refusals) {
13714
13715
  }
13715
13716
  }
13716
13717
  //#endregion
13717
- //#region ../content/dist/record-DnMnZelb.mjs
13718
+ //#region ../content/dist/record-Cxw0SUG3.mjs
13718
13719
  const hex64 = string().regex(/^[0-9a-f]{64}$/, "a sha256 hex digest");
13719
13720
  const viewerList = array(string().min(1));
13720
13721
  const lockSchema = object({
@@ -13732,6 +13733,7 @@ const lockSchema = object({
13732
13733
  drafts: _enum(["hidden", "shown"]),
13733
13734
  instance_sha256: hex64,
13734
13735
  policy_sha256: hex64,
13736
+ people_sha256: hex64,
13735
13737
  ledger_sha256: hex64,
13736
13738
  ledger_entries: array(object({
13737
13739
  id: string().min(1),
@@ -13928,6 +13930,7 @@ const INPUTS = [
13928
13930
  "knowledge",
13929
13931
  "instance.md",
13930
13932
  ".ksor/governance.yaml",
13933
+ ".ksor/people.yaml",
13931
13934
  ".ksor/takedowns.yaml"
13932
13935
  ];
13933
13936
  function gitFacts(root) {
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-uhx2l1kC-C2BAxISt.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";
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";
@@ -17,7 +17,7 @@ import { Document, YAMLParseError, isCollection, isMap, isPair, isSeq, parseAllD
17
17
  import { parseArgs } from "node:util";
18
18
  import { readFile } from "node:fs/promises";
19
19
  import { execFileSync, spawnSync } from "node:child_process";
20
- //#region ../content-gateway/dist/main-BHQDvsKA.mjs
20
+ //#region ../content-gateway/dist/main-BoKhKcP0.mjs
21
21
  /**
22
22
  * The default registration — and the ORIGINAL of the file `ksor init` emits.
23
23
  *
@@ -1533,7 +1533,7 @@ async function main$1(version = GATEWAY_VERSION) {
1533
1533
  }
1534
1534
  }
1535
1535
  //#endregion
1536
- //#region ../content/dist/check-DNWlQuBg.mjs
1536
+ //#region ../content/dist/check-6fB_kR4G.mjs
1537
1537
  /**
1538
1538
  * One reader for the control files beside the bundle (`.ksor/*.yaml`): the
1539
1539
  * same parser posture as a concept's frontmatter — one document, unique keys,
@@ -2825,6 +2825,7 @@ const LEDGER_HEADER$1 = "# The takedown ledger (record spec §5): append-only, w
2825
2825
  const CONTROL_FILES = [
2826
2826
  "instance.md",
2827
2827
  ".ksor/governance.yaml",
2828
+ ".ksor/people.yaml",
2828
2829
  ".ksor/takedowns.yaml"
2829
2830
  ];
2830
2831
  /** Files the operating system writes behind the author's back: ignored, never reported. */
@@ -4709,7 +4710,7 @@ async function withPgRetry(op, options = {}) {
4709
4710
  throw lastError;
4710
4711
  }
4711
4712
  //#endregion
4712
- //#region ../content/dist/commands-DM5TajmH.mjs
4713
+ //#region ../content/dist/commands-CO9-bQnb.mjs
4713
4714
  /**
4714
4715
  * EVAL-LOCKED constants, quarried verbatim from the oracle
4715
4716
  * (sor-agentfactory @ b554f91, config.py) — changing any of these is a
@@ -6228,6 +6229,14 @@ function isRetryable(exc) {
6228
6229
  * project stays rate-limited on the next second, so a search degrades to
6229
6230
  * keyword-only now rather than stalling a reader behind backoff.
6230
6231
  */
6232
+ /**
6233
+ * An ACCOUNT-level failure: no amount of waiting and no other passage changes
6234
+ * it. The drain must abort on this rather than quarantine, because the chunk
6235
+ * it happened to be holding is not what is wrong — see `ingest/worker.ts`.
6236
+ */
6237
+ function isFatal(exc) {
6238
+ return exc instanceof OpenAiHttpError && exc.kind === "insufficient_quota";
6239
+ }
6231
6240
  function isRetryableQuery(exc) {
6232
6241
  if (isTransportBlip(exc)) return true;
6233
6242
  const status = httpStatusOf(exc);
@@ -6278,6 +6287,9 @@ var OpenAiEmbeddingProvider = class {
6278
6287
  isRetryableQuery(exc) {
6279
6288
  return isRetryableQuery(exc);
6280
6289
  }
6290
+ isFatal(exc) {
6291
+ return isFatal(exc);
6292
+ }
6281
6293
  };
6282
6294
  /**
6283
6295
  * The embedding-provider registry — a plain object, NOT any discovery
@@ -6304,10 +6316,20 @@ var OpenAiEmbeddingProvider = class {
6304
6316
  */
6305
6317
  var MissingProviderKeyError = class extends Error {
6306
6318
  providerName;
6307
- constructor(providerName) {
6308
- super(`embedding provider ${JSON.stringify(providerName)} needs an API key and none was supplied`);
6319
+ keyEnv;
6320
+ /**
6321
+ * `keyEnv` is not decoration. The message named the PROVIDER and nothing
6322
+ * else, so an operator whose `ksor serve` exited 3 on an OpenAI record was
6323
+ * told "provider openai needs an API key" and left to guess which variable —
6324
+ * while `ksor serve --help`, `env.example` and `docs/deploying.md` all named
6325
+ * `GEMINI_API_KEY`, which the door does not read (review, 2026-09-01). The
6326
+ * registry row already held the answer; this is it reaching the operator.
6327
+ */
6328
+ constructor(providerName, keyEnv = null) {
6329
+ super(`embedding provider ${JSON.stringify(providerName)} needs an API key and none was supplied` + (keyEnv === null ? "" : ` — set ${keyEnv}`));
6309
6330
  this.name = "MissingProviderKeyError";
6310
6331
  this.providerName = providerName;
6332
+ this.keyEnv = keyEnv;
6311
6333
  }
6312
6334
  };
6313
6335
  const PROVIDERS = {
@@ -6369,7 +6391,7 @@ function providerKeyEnv(name) {
6369
6391
  */
6370
6392
  function buildShippedProvider(name, opts) {
6371
6393
  const entry = entryFor(name);
6372
- if (entry.needsApiKey && !opts.apiKey) throw new MissingProviderKeyError(name);
6394
+ if (entry.needsApiKey && !opts.apiKey) throw new MissingProviderKeyError(name, entry.keyEnv);
6373
6395
  return entry.build({
6374
6396
  apiKey: opts.apiKey ?? "",
6375
6397
  modelId: opts.modelId ?? "gemini-embedding-001",
@@ -9275,6 +9297,7 @@ async function drain(pending, io) {
9275
9297
  literals = await io.embedBatch(batch.map(([, text]) => text));
9276
9298
  } catch (exc) {
9277
9299
  if (io.isRetryable(exc)) throw exc;
9300
+ if (io.isFatal?.(exc) === true) throw exc;
9278
9301
  if (batch.length === 1) {
9279
9302
  await io.markFailed(failureReason(exc), batch[0][0]);
9280
9303
  failed += 1;
@@ -9826,7 +9849,8 @@ async function buildGeneration(pool, instance, options) {
9826
9849
  markFailed: (reason, chunkId) => runIngest(pool, tenant, async (c) => {
9827
9850
  await c.query(FAIL_SQL, [reason, chunkId]);
9828
9851
  }),
9829
- isRetryable: (exc) => provider.isRetryable(exc)
9852
+ isRetryable: (exc) => provider.isRetryable(exc),
9853
+ isFatal: (exc) => provider.isFatal?.(exc) === true
9830
9854
  });
9831
9855
  log(`embedded ${embedded}, failed ${failed}`);
9832
9856
  const fin = await runIngest(pool, tenant, async (c) => {
@@ -10633,7 +10657,7 @@ async function calibrateCommand(args) {
10633
10657
  let textGenerator = null;
10634
10658
  if (queries === null) {
10635
10659
  const apiKey = process.env["GEMINI_API_KEY"];
10636
- if (apiKey === void 0 || apiKey === "") return refuse$2("bad-args", "the synthesized door needs GEMINI_API_KEY (it writes one probe question per sampled passage) — or calibrate with zero LLM: --queries-file PATH (one in-corpus question per line)");
10660
+ if (apiKey === void 0 || apiKey === "") return refuse$2("bad-args", "the synthesized door needs GEMINI_API_KEY (it writes one probe question per sampled passage) — or calibrate with zero LLM: --queries-file PATH (one in-corpus question per line).\n note: this is the TEXT generator, not the embedding provider. A record on `embedding.provider: openai` still embeds with OPENAI_API_KEY; only question synthesis is Gemini-only today, and --queries-file avoids it entirely");
10637
10661
  textGenerator = new GeminiTextGenerator({ apiKey });
10638
10662
  }
10639
10663
  const ooc = values["ooc-file"] === void 0 ? null : parseQueriesFile(readFileSync(values["ooc-file"], "utf8"));
@@ -11015,7 +11039,7 @@ async function runContentCli(argv) {
11015
11039
  }
11016
11040
  }
11017
11041
  //#endregion
11018
- //#region ../content/dist/record-DnMnZelb.mjs
11042
+ //#region ../content/dist/record-Cxw0SUG3.mjs
11019
11043
  /**
11020
11044
  * May `surface` publish `doc` at instant `at`? `drafts` is the build's drafts switch,
11021
11045
  * which admits drafts to HUMAN surfaces only.
@@ -11061,6 +11085,7 @@ const lockSchema = z.object({
11061
11085
  drafts: z.enum(["hidden", "shown"]),
11062
11086
  instance_sha256: hex64,
11063
11087
  policy_sha256: hex64,
11088
+ people_sha256: hex64,
11064
11089
  ledger_sha256: hex64,
11065
11090
  ledger_entries: z.array(z.object({
11066
11091
  id: z.string().min(1),
@@ -11133,6 +11158,7 @@ function buildIdOf(inputs) {
11133
11158
  indexes: [...inputs.indexes].map((i) => [i.path, i.sha256]).sort((a, b) => compare(a[0] ?? "", b[0] ?? "")),
11134
11159
  instance_sha256: inputs.instance_sha256,
11135
11160
  policy_sha256: inputs.policy_sha256,
11161
+ people_sha256: inputs.people_sha256,
11136
11162
  ledger_sha256: inputs.ledger_sha256,
11137
11163
  ksor_version: inputs.ksor_version,
11138
11164
  drafts: inputs.drafts
@@ -11179,6 +11205,7 @@ function composeLock(input) {
11179
11205
  }));
11180
11206
  const instance_sha256 = sha256Hex(input.instanceText);
11181
11207
  const policy_sha256 = sha256Hex(input.policyText);
11208
+ const people_sha256 = sha256Hex(input.peopleText ?? "");
11182
11209
  const ledger_sha256 = sha256Hex(input.ledgerText ?? "");
11183
11210
  return {
11184
11211
  format: 1,
@@ -11189,6 +11216,7 @@ function composeLock(input) {
11189
11216
  indexes,
11190
11217
  instance_sha256,
11191
11218
  policy_sha256,
11219
+ people_sha256,
11192
11220
  ledger_sha256,
11193
11221
  ksor_version: input.ksorVersion,
11194
11222
  drafts: input.drafts
@@ -11201,6 +11229,7 @@ function composeLock(input) {
11201
11229
  drafts: input.drafts,
11202
11230
  instance_sha256,
11203
11231
  policy_sha256,
11232
+ people_sha256,
11204
11233
  ledger_sha256,
11205
11234
  ledger_entries: [...input.ledgerEntries].sort((a, b) => compare(a.id, b.id)),
11206
11235
  audiences: {
@@ -11363,6 +11392,7 @@ const INPUTS = [
11363
11392
  "knowledge",
11364
11393
  "instance.md",
11365
11394
  ".ksor/governance.yaml",
11395
+ ".ksor/people.yaml",
11366
11396
  ".ksor/takedowns.yaml"
11367
11397
  ];
11368
11398
  /**
@@ -11373,7 +11403,11 @@ const INPUTS = [
11373
11403
  * clone is invisible to every surface, so the build says so by name.
11374
11404
  */
11375
11405
  function ignoredGovernance(root) {
11376
- const paths = [".ksor/governance.yaml", ".ksor/takedowns.yaml"].filter((rel) => existsSync(join(root, rel)));
11406
+ const paths = [
11407
+ ".ksor/governance.yaml",
11408
+ ".ksor/people.yaml",
11409
+ ".ksor/takedowns.yaml"
11410
+ ].filter((rel) => existsSync(join(root, rel)));
11377
11411
  if (paths.length === 0) return [];
11378
11412
  const out = git(root, [
11379
11413
  "check-ignore",
@@ -11563,8 +11597,8 @@ function runBuild(args, cwd, io, options) {
11563
11597
  const facts = gitFacts(root);
11564
11598
  if (facts.repository && facts.historicLedger === null && !parsed.allowUnverifiable) return refuse$1(io, "ksor-ledger-unverifiable", facts.historyUnreadable === "shallow" ? "this is a shallow clone: the takedown ledger is append-only, and without history a deleted entry cannot be told from one that never existed" : "git could not read the takedown ledger's history (`git log -- .ksor/takedowns.yaml` failed, and this is not a shallow clone): the ledger is append-only, and without history a deleted entry cannot be told from one that never existed", facts.historyUnreadable === "shallow" ? "fetch full history (`git fetch --unshallow`; in CI, `fetch-depth: 0`), or pass --allow-unverifiable-ledger to build anyway" : "check that `git log` works in this checkout, or pass --allow-unverifiable-ledger to build anyway");
11565
11599
  const ignored = facts.repository ? ignoredGovernance(root) : [];
11566
- if (ignored.length > 0) return refuse$1(io, "ksor-governance-ignored", `git ignores ${ignored.join(" and ")}, so ${ignored.length === 1 ? "it is" : "they are"} in no commit — the policy and the takedown ledger ARE the record, and a clone (your CI, your deploy) would build without ${ignored.length === 1 ? "it" : "them"}`, "un-ignore them in .gitignore — the directory form `.ksor/` cannot be negated, so use `.ksor/*` plus `!.ksor/governance.yaml` and `!.ksor/takedowns.yaml` — then commit them (`ksor migrate` offers that edit)");
11567
- if (parsed.strict && facts.dirty) return refuse$1(io, "ksor-build-dirty", facts.repository ? "an input (knowledge/, instance.md, .ksor/governance.yaml, .ksor/takedowns.yaml) differs from its last commit, and --strict stamps only committed content" : "the record is not in a git repository, so no input is committed", "commit the inputs and rebuild, or drop --strict to stamp a dirty build (the lock says `dirty: true`)");
11600
+ if (ignored.length > 0) return refuse$1(io, "ksor-governance-ignored", `git ignores ${ignored.join(" and ")}, so ${ignored.length === 1 ? "it is" : "they are"} in no commit — the policy and the takedown ledger ARE the record, and a clone (your CI, your deploy) would build without ${ignored.length === 1 ? "it" : "them"}`, "un-ignore them in .gitignore — the directory form `.ksor/` cannot be negated, so use `.ksor/*` plus `!.ksor/governance.yaml`, `!.ksor/people.yaml` and `!.ksor/takedowns.yaml` — then commit them (`ksor migrate` offers that edit)");
11601
+ if (parsed.strict && facts.dirty) return refuse$1(io, "ksor-build-dirty", facts.repository ? "an input (knowledge/, instance.md, .ksor/governance.yaml, .ksor/people.yaml, .ksor/takedowns.yaml) differs from its last commit, and --strict stamps only committed content" : "the record is not in a git repository, so no input is committed", "commit the inputs and rebuild, or drop --strict to stamp a dirty build (the lock says `dirty: true`)");
11568
11602
  const baselines = [];
11569
11603
  if (facts.historicLedger !== null) baselines.push({
11570
11604
  source: "git history",
@@ -11605,6 +11639,7 @@ function runBuild(args, cwd, io, options) {
11605
11639
  drafts: options.drafts,
11606
11640
  instanceText: record.files.get("instance.md") ?? "",
11607
11641
  policyText: record.files.get(".ksor/governance.yaml") ?? "",
11642
+ peopleText: record.files.get(".ksor/people.yaml") ?? null,
11608
11643
  ledgerText,
11609
11644
  ledgerEntries: result.ledgerEntries,
11610
11645
  audiences: result.policy?.audiences ?? [],
@@ -13291,11 +13326,13 @@ const BARE_DOTKSOR_PATTERNS = /* @__PURE__ */ new Set([
13291
13326
  ]);
13292
13327
  const GOVERNANCE_IGNORE_BLOCK = [
13293
13328
  "# ksor's working directory — build output and scratch, never the record.",
13294
- "# The two governance files inside it ARE the record (the policy and the",
13295
- "# takedown ledger) and are un-ignored by name: the directory form `.ksor/`",
13296
- "# cannot be negated, so the glob is `.ksor/*`.",
13329
+ "# The governance files inside it ARE the record (the policy, the takedown",
13330
+ "# ledger, and the phone book the site publishes names from) and are",
13331
+ "# un-ignored by name: the directory form `.ksor/` cannot be negated, so the",
13332
+ "# glob is `.ksor/*`.",
13297
13333
  ".ksor/*",
13298
13334
  "!.ksor/governance.yaml",
13335
+ "!.ksor/people.yaml",
13299
13336
  "!.ksor/takedowns.yaml"
13300
13337
  ];
13301
13338
  /**
@@ -13932,7 +13969,9 @@ serves nothing. Runs in this process and holds it; SIGTERM/SIGINT drains.
13932
13969
  Configured by environment — .env beside the record is read automatically:
13933
13970
 
13934
13971
  <database.dsn_env> the Postgres DSN, under the NAME instance.md gives
13935
- GEMINI_API_KEY iff the instance's embedding provider needs a key
13972
+ <provider key> iff the instance's embedding provider needs one:
13973
+ GEMINI_API_KEY for gemini, OPENAI_API_KEY for openai.
13974
+ The refusal names the variable your record needs
13936
13975
  KSOR_AUTH disabled-local (loopback dev) | disabled-public.
13937
13976
  Serve REFUSES to boot with neither this nor a
13938
13977
  configured SSO door — never open by accident
@@ -6,7 +6,7 @@ import { z, z as z$1 } from "zod";
6
6
  import path, { join } from "node:path";
7
7
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
8
8
  import pg from "pg";
9
- //#region ../content-gateway/dist/gateway-api-uhx2l1kC.mjs
9
+ //#region ../content-gateway/dist/gateway-api-C0vL3oOK.mjs
10
10
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
11
11
  var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
12
12
  /**
@@ -9159,6 +9159,14 @@ function isRetryable(exc) {
9159
9159
  * project stays rate-limited on the next second, so a search degrades to
9160
9160
  * keyword-only now rather than stalling a reader behind backoff.
9161
9161
  */
9162
+ /**
9163
+ * An ACCOUNT-level failure: no amount of waiting and no other passage changes
9164
+ * it. The drain must abort on this rather than quarantine, because the chunk
9165
+ * it happened to be holding is not what is wrong — see `ingest/worker.ts`.
9166
+ */
9167
+ function isFatal(exc) {
9168
+ return exc instanceof OpenAiHttpError && exc.kind === "insufficient_quota";
9169
+ }
9162
9170
  function isRetryableQuery(exc) {
9163
9171
  if (isTransportBlip(exc)) return true;
9164
9172
  const status = httpStatusOf(exc);
@@ -9209,6 +9217,9 @@ var OpenAiEmbeddingProvider = class {
9209
9217
  isRetryableQuery(exc) {
9210
9218
  return isRetryableQuery(exc);
9211
9219
  }
9220
+ isFatal(exc) {
9221
+ return isFatal(exc);
9222
+ }
9212
9223
  };
9213
9224
  /**
9214
9225
  * The embedding-provider registry — a plain object, NOT any discovery
@@ -9235,10 +9246,20 @@ var OpenAiEmbeddingProvider = class {
9235
9246
  */
9236
9247
  var MissingProviderKeyError = class extends Error {
9237
9248
  providerName;
9238
- constructor(providerName) {
9239
- super(`embedding provider ${JSON.stringify(providerName)} needs an API key and none was supplied`);
9249
+ keyEnv;
9250
+ /**
9251
+ * `keyEnv` is not decoration. The message named the PROVIDER and nothing
9252
+ * else, so an operator whose `ksor serve` exited 3 on an OpenAI record was
9253
+ * told "provider openai needs an API key" and left to guess which variable —
9254
+ * while `ksor serve --help`, `env.example` and `docs/deploying.md` all named
9255
+ * `GEMINI_API_KEY`, which the door does not read (review, 2026-09-01). The
9256
+ * registry row already held the answer; this is it reaching the operator.
9257
+ */
9258
+ constructor(providerName, keyEnv = null) {
9259
+ super(`embedding provider ${JSON.stringify(providerName)} needs an API key and none was supplied` + (keyEnv === null ? "" : ` — set ${keyEnv}`));
9240
9260
  this.name = "MissingProviderKeyError";
9241
9261
  this.providerName = providerName;
9262
+ this.keyEnv = keyEnv;
9242
9263
  }
9243
9264
  };
9244
9265
  const PROVIDERS = {
@@ -9294,7 +9315,7 @@ function providerKeyEnv(name) {
9294
9315
  */
9295
9316
  function buildShippedProvider(name, opts) {
9296
9317
  const entry = entryFor(name);
9297
- if (entry.needsApiKey && !opts.apiKey) throw new MissingProviderKeyError(name);
9318
+ if (entry.needsApiKey && !opts.apiKey) throw new MissingProviderKeyError(name, entry.keyEnv);
9298
9319
  return entry.build({
9299
9320
  apiKey: opts.apiKey ?? "",
9300
9321
  modelId: opts.modelId ?? "gemini-embedding-001",
@@ -1,7 +1,7 @@
1
1
  import { CallToolResult, McpServer as McpServer$1, StandardSchemaWithJSON } from "@modelcontextprotocol/server";
2
2
  import { z as z$1 } from "zod";
3
3
  import pg from "pg";
4
- //#region ../content-gateway/dist/gateway-api-Da-9ssbU.d.mts
4
+ //#region ../content-gateway/dist/gateway-api-CEnK8Bc8.d.mts
5
5
  //#region src/instructions.d.ts
6
6
  /**
7
7
  * Has the owner said what this record is FOR yet?
@@ -16,7 +16,7 @@ import pg from "pg";
16
16
  declare function recordIsUndescribed(authored: string): boolean;
17
17
  declare function composeInstructions(authored: string): string;
18
18
  //#endregion
19
- //#region ../content/dist/index-CQcB_oVG.d.mts
19
+ //#region ../content/dist/index-D-xEl8mz.d.mts
20
20
  declare const TRUST_TIERS: readonly ["unverified", "machine-confirmed", "human-reviewed"];
21
21
  type TrustTier = (typeof TRUST_TIERS)[number];
22
22
  //#endregion
package/dist/gateway.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { D as outlineHandler, F as recordIsUndescribed, G as z$1, L as searchHandler, P as readHandler, c as McpServer$1, d as READ_ONLY, f as READ_OUTPUT, h as TRUST_TIERS, i as FLOOR, o as MAX_OUTLINE_LIMIT, p as SEARCH_OUTPUT, s as MAX_SEARCH_K, u as OUTLINE_OUTPUT, x as composeInstructions } from "./gateway-api-uhx2l1kC-C2BAxISt.mjs";
1
+ import { D as outlineHandler, F as recordIsUndescribed, G as z$1, L as searchHandler, P as readHandler, c as McpServer$1, d as READ_ONLY, f as READ_OUTPUT, h as TRUST_TIERS, i as FLOOR, o as MAX_OUTLINE_LIMIT, p as SEARCH_OUTPUT, s as MAX_SEARCH_K, u as OUTLINE_OUTPUT, x as composeInstructions } from "./gateway-api-C0vL3oOK-D24n786A.mjs";
2
2
  export { FLOOR, MAX_OUTLINE_LIMIT, MAX_SEARCH_K, McpServer$1 as McpServer, OUTLINE_OUTPUT, READ_ONLY, READ_OUTPUT, SEARCH_OUTPUT, TRUST_TIERS, composeInstructions, outlineHandler, readHandler, recordIsUndescribed, searchHandler, z$1 as z };
package/docs/deploying.md CHANGED
@@ -286,9 +286,15 @@ are doing it wrong" — and only the first tier is true.
286
286
  | variable | why |
287
287
  | ----------------------------------------- | ---------------------------------------------------- |
288
288
  | `KSOR_DB_URL` | the record's Postgres store |
289
- | `GEMINI_API_KEY` | embeds the incoming query, so retrieval works at all |
289
+ | the provider key | embeds the incoming query, so retrieval works at all |
290
290
  | `KSOR_AUTH`, **or** a configured SSO door | see below |
291
291
 
292
+ The provider key is whichever variable `embedding.provider` in `instance.md`
293
+ names — `GEMINI_API_KEY` for `gemini` (the default), `OPENAI_API_KEY` for
294
+ `openai`. A record reads exactly one, and the boot refusal names the one it
295
+ wanted: `embedding provider "openai" needs an API key and none was supplied —
296
+ set OPENAI_API_KEY`.
297
+
292
298
  `KSOR_AUTH` takes one of two values, and the value IS the decision:
293
299
 
294
300
  ```sh
package/docs/ingesting.md CHANGED
@@ -197,11 +197,16 @@ measure until the corpus is in there.
197
197
  pnpm exec ksor calibrate --instance instance.md
198
198
  ```
199
199
 
200
- **On a free-tier key, use the zero-LLM door instead.** The command above is the
201
- SYNTHESIZED door: it writes one probe question per sampled passage with an LLM,
202
- and a free key allows only a few generations a minute — a bigger corpus makes
203
- that worse, not better. Write your own in-corpus questions, one per line, and
204
- pass them:
200
+ **The synthesized door needs `GEMINI_API_KEY`, whatever your embedding provider
201
+ is.** The command above writes one probe question per sampled passage with an
202
+ LLM, and question synthesis is Gemini-only today so a record on
203
+ `embedding.provider: openai` embeds with `OPENAI_API_KEY` and would still be
204
+ refused here for a Google key. That is a real gap, stated rather than papered
205
+ over; the zero-LLM door below avoids it entirely and is the better choice on a
206
+ free-tier key anyway, because a free key allows only a few generations a minute
207
+ and a bigger corpus makes that worse, not better.
208
+
209
+ Write your own in-corpus questions, one per line, and pass them:
205
210
 
206
211
  ```sh
207
212
  pnpm exec ksor calibrate --instance instance.md --queries-file questions.txt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.54",
3
+ "version": "0.0.55",
4
4
  "description": "Knowledge System of Record — compile governed markdown into a static site for people and an MCP server for AI agents, with citations and measured abstention.",
5
5
  "keywords": [
6
6
  "abstention",
@@ -11971,6 +11971,7 @@ function changedFields(before, after) {
11971
11971
  const CONTROL_FILES = [
11972
11972
  "instance.md",
11973
11973
  ".ksor/governance.yaml",
11974
+ ".ksor/people.yaml",
11974
11975
  ".ksor/takedowns.yaml"
11975
11976
  ];
11976
11977
  /** Files the operating system writes behind the author's back: ignored, never reported. */
@@ -13551,6 +13552,7 @@ const lockSchema = object({
13551
13552
  drafts: _enum(["hidden", "shown"]),
13552
13553
  instance_sha256: hex64,
13553
13554
  policy_sha256: hex64,
13555
+ people_sha256: hex64,
13554
13556
  ledger_sha256: hex64,
13555
13557
  ledger_entries: array(object({
13556
13558
  id: string().min(1),
@@ -13745,6 +13747,7 @@ const INPUTS = [
13745
13747
  "knowledge",
13746
13748
  "instance.md",
13747
13749
  ".ksor/governance.yaml",
13750
+ ".ksor/people.yaml",
13748
13751
  ".ksor/takedowns.yaml"
13749
13752
  ];
13750
13753
  function gitFacts(root) {
@@ -11971,6 +11971,7 @@ function changedFields(before, after) {
11971
11971
  const CONTROL_FILES = [
11972
11972
  "instance.md",
11973
11973
  ".ksor/governance.yaml",
11974
+ ".ksor/people.yaml",
11974
11975
  ".ksor/takedowns.yaml"
11975
11976
  ];
11976
11977
  /** Files the operating system writes behind the author's back: ignored, never reported. */
@@ -13551,6 +13552,7 @@ const lockSchema = object({
13551
13552
  drafts: _enum(["hidden", "shown"]),
13552
13553
  instance_sha256: hex64,
13553
13554
  policy_sha256: hex64,
13555
+ people_sha256: hex64,
13554
13556
  ledger_sha256: hex64,
13555
13557
  ledger_entries: array(object({
13556
13558
  id: string().min(1),
@@ -13745,6 +13747,7 @@ const INPUTS = [
13745
13747
  "knowledge",
13746
13748
  "instance.md",
13747
13749
  ".ksor/governance.yaml",
13750
+ ".ksor/people.yaml",
13748
13751
  ".ksor/takedowns.yaml"
13749
13752
  ];
13750
13753
  function gitFacts(root) {
@@ -118,7 +118,15 @@ text-embedding-3-small`, `dim: 1536`, key in `OPENAI_API_KEY` — and each
118
118
  Turning it on is step 4, AFTER the record is serving.
119
119
 
120
120
  2. **Get the database — your agent can do this one.** `.mcp.json` at the repo
121
- root declares the MCP servers this project may reach, and the first is Neon.
121
+ root declares the MCP servers this project may reach. It ships with two:
122
+ `Neon`, which provisions the Postgres this step needs, and
123
+ `agentfactory-system-of-record`, a read-only KSoR record Panaversity operates
124
+ as an example of the surface being built here — not this project's record,
125
+ and not needed by anything below. Either can be deleted; the file is the
126
+ adopter's.
127
+
128
+ The Neon server acts on the whole Neon ACCOUNT, not on one database: it can
129
+ create and delete projects and branches. Show the plan before running it.
122
130
  With it connected, ask:
123
131
 
124
132
  > Using the Neon MCP server, create a project called `<your-record>` and
@@ -174,7 +174,26 @@ the variable name here only if you want a different one.
174
174
  ### 2. Get a database — your agent can do this one
175
175
 
176
176
  `.mcp.json` at the repo root declares the MCP servers this project may reach.
177
- The first is Neon's. With it connected, ask your coding agent:
177
+ It ships with two, and both are yours to keep or delete — it is your file:
178
+
179
+ | server | what it is |
180
+ | -------------------------------- | ------------------------------------------------------------------------------------------------ |
181
+ | `Neon` | Neon's own hosted MCP server, for provisioning the Postgres the agent surface needs (step 2) |
182
+ | `agentfactory-system-of-record` | a KSoR record Panaversity operates, served over MCP — an example of the surface you are building |
183
+
184
+ The second is **not** your record and is not needed to run this project. It is
185
+ read-only and it answers about the Agent Factory curriculum, not about your
186
+ knowledge. Delete the entry if you would rather your agent not have it; nothing
187
+ here depends on it.
188
+
189
+ **Before you connect Neon, know what you are granting.** The Neon MCP server
190
+ acts on your Neon *account*, not on one database: an agent holding it can create
191
+ and delete projects and branches. Point it at an account you are willing to let
192
+ an agent change, review the plan it shows you before approving, and read Neon's
193
+ own documentation on the server's scopes and permissions rather than taking this
194
+ paragraph as the whole of it.
195
+
196
+ With it connected, ask your coding agent:
178
197
 
179
198
  > Using the Neon MCP server, create a project called `<your-record>` and enable
180
199
  > the pgvector extension on it. Then create a branch called `dev`, and save that
@@ -254,17 +273,14 @@ separately](#the-agent-surface-deploys-separately). Any other operation is
254
273
  ### Test the door with an actual agent
255
274
 
256
275
  The MCP door is meant to be read by agents, so check it with one rather than
257
- with `curl`. With `pnpm serve` running, write `.mcp.json` at the repo root:
276
+ with `curl`. With `pnpm serve` running, **add** an entry to the `.mcp.json` you
277
+ already have — alongside `Neon`, not in place of it:
258
278
 
259
279
  ```json
260
- {
261
- "mcpServers": {
262
280
  "test-record": {
263
281
  "type": "http",
264
282
  "url": "http://127.0.0.1:8080/mcp"
265
283
  }
266
- }
267
- }
268
284
  ```
269
285
 
270
286
  **If you skipped `calibrate`, expect answers where this test wants refusals** —
@@ -288,7 +304,9 @@ Question 2 is the one that matters. Anything can answer questions it has the
288
304
  text for; refusing a plausible near-miss is the property that makes a system of
289
305
  record worth trusting, and it is the one that breaks quietly.
290
306
 
291
- Delete `.mcp.json`, or keep it — it holds no secret.
307
+ Keep the `test-record` entry or remove it — it points at loopback and holds no
308
+ secret either way. Do not delete `.mcp.json` itself: it is where `Neon` is
309
+ declared, and step 2 above needs it.
292
310
 
293
311
  ---
294
312
 
@@ -10,8 +10,13 @@
10
10
  # explicitly instead of relying on a driver default that is due to change.
11
11
  KSOR_DB_URL=postgresql://user:password@host:5432/dbname
12
12
 
13
- # The embedding provider key. instance.md defaults to gemini-embedding-001.
13
+ # The embedding provider key ONE of these, whichever `embedding.provider` in
14
+ # instance.md names. It defaults to gemini, so that is the one uncommented; on
15
+ # `provider: openai`, comment this out and set OPENAI_API_KEY instead. A record
16
+ # reads exactly one of them, and `ksor serve` names the one it wanted when it
17
+ # is missing.
14
18
  GEMINI_API_KEY=
19
+ # OPENAI_API_KEY=
15
20
 
16
21
  # ── Who may ask ─────────────────────────────────────────────────────────────
17
22
  # ONE variable, and its VALUE is the decision. `ksor serve` refuses to boot
@@ -1,7 +1,8 @@
1
1
  # ksor's working directory — build output and scratch, never the record.
2
- # The two governance files inside it ARE the record (the policy and the
3
- # takedown ledger) and are un-ignored by name: the directory form `.ksor/`
4
- # cannot be negated, so the glob is `.ksor/*`.
2
+ # The governance files inside it ARE the record (the policy, the takedown
3
+ # ledger, and the phone book the site publishes names from) and are un-ignored
4
+ # by name: the directory form `.ksor/` cannot be negated, so the glob is
5
+ # `.ksor/*`.
5
6
  .ksor/*
6
7
  !.ksor/governance.yaml
7
8
  !.ksor/people.yaml
@@ -73,6 +73,8 @@ export interface ControlTexts {
73
73
  readonly policy: string;
74
74
  /** Null when `.ksor/takedowns.yaml` is not there — an empty ledger. */
75
75
  readonly ledger: string | null;
76
+ /** Null when `.ksor/people.yaml` is not there — no natural names declared. */
77
+ readonly people: string | null;
76
78
  }
77
79
 
78
80
  function sha256Text(text: string): string {
@@ -139,12 +141,17 @@ export function readLock(
139
141
  ["instance.md", lock.data.instance_sha256, sha256Text(control.instance)],
140
142
  [".ksor/governance.yaml", lock.data.policy_sha256, sha256Text(control.policy)],
141
143
  [".ksor/takedowns.yaml", lock.data.ledger_sha256, sha256Text(control.ledger ?? "")],
144
+ // The phone book publishes NAMES in place of stored actors, so an edit here
145
+ // changes the approver printed on every page. Left out, the site could
146
+ // publish one approver while the `/md/` twin stamped with the same
147
+ // `build_id` published another (review, 2026-09-01).
148
+ [".ksor/people.yaml", lock.data.people_sha256, sha256Text(control.people ?? "")],
142
149
  ] as const) {
143
150
  if (want === have) continue;
144
151
  refuse(
145
152
  "ksor-lock-stale",
146
153
  `${file} changed since ${LOCK_FILE} was written`,
147
- "the lock's build_id is a hash over the record AND the three files that govern it, so a projection under a control file the lock never saw publishes what nothing checked — a denial lifted by deleting a line would otherwise leave the lock valid",
154
+ "the lock's build_id is a hash over the record AND the four files that govern it, so a projection under a control file the lock never saw publishes what nothing checked — a denial lifted by deleting a line would otherwise leave the lock valid",
148
155
  "run `ksor build` again and commit the lock with the change; lift a denial with `ksor takedown --revoke <id>`, never by editing the ledger",
149
156
  );
150
157
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What a phone book file DECLARES, as a pure function of its text.
3
+ *
4
+ * A LEAF, like `actor-display.ts`: `people.ts` roots itself at the project via
5
+ * `shared.ts`, which reads `instance.md` at module load, so a rule living
6
+ * there could only be exercised by building a whole record on disk first. That
7
+ * is a test nobody writes, and the duplicate-key rule below went four days
8
+ * asserted only by a comment because of it.
9
+ *
10
+ * A map comes back for every malformed shape rather than an error, because a
11
+ * phone book is DECORATION: a record with an unreadable one publishes
12
+ * identifiers, which is exactly what it did before the file existed.
13
+ */
14
+
15
+ import { parseAllDocuments } from "yaml";
16
+
17
+ /**
18
+ * The phone book a file DECLARES, as a pure function of its text.
19
+ *
20
+ * Separated from the read so the rule can be exercised without a record on
21
+ * disk: the loader below is a filesystem act rooted at `projectRoot`, and a
22
+ * test that has to build a whole scaffold to ask "what do two entries for one
23
+ * actor mean?" is a test nobody writes. A map is handed back for every
24
+ * malformed shape rather than an error, because a phone book is decoration:
25
+ * a record with an unreadable one publishes identifiers, which is what it did
26
+ * before the file existed.
27
+ */
28
+ export function parsePeople(text: string): ReadonlyMap<string, string> {
29
+ try {
30
+ const docs = parseAllDocuments(text.replace(/^\ufeff/, ""), {
31
+ schema: "core",
32
+ uniqueKeys: true,
33
+ logLevel: "silent",
34
+ });
35
+ const doc = docs[0];
36
+ if (doc === undefined) return new Map();
37
+ // `uniqueKeys: true` RECORDS a duplicate rather than refusing one — `toJS()`
38
+ // still resolves last-wins. Reading the option and not the errors is what
39
+ // made the claim above false: two entries for one actor silently published
40
+ // the second person's name on the first person's governance act.
41
+ if (doc.errors.length > 0) return new Map();
42
+ const value: unknown = doc.toJS();
43
+ if (typeof value !== "object" || value === null) return new Map();
44
+ const table = (value as { people?: unknown }).people;
45
+ if (typeof table !== "object" || table === null || Array.isArray(table)) return new Map();
46
+ const out = new Map<string, string>();
47
+ for (const [actor, name] of Object.entries(table as Record<string, unknown>)) {
48
+ // A blank value is an entry someone started and left; printing "" would
49
+ // erase the identifier rather than replace it.
50
+ if (typeof name === "string" && name.trim() !== "") out.set(actor.trim(), name.trim());
51
+ }
52
+ return out;
53
+ } catch {
54
+ return new Map();
55
+ }
56
+ }
@@ -8,8 +8,9 @@
8
8
  * `human:mjs` — most of the actors in a real record — had no expressible name
9
9
  * at all. It also collided: "Bashir Aziz" and "Bashira Ziz" both derive
10
10
  * `bashiraziz`, which would print one person's name on the other's governance
11
- * act. A map has neither problem, and duplicate keys are refused by the parser
12
- * rather than resolved by whichever came last.
11
+ * act. A map has neither problem, and a duplicate key drops the whole book
12
+ * rather than resolving to whichever came last — `uniqueKeys` only REPORTS the
13
+ * duplicate, so the errors have to be read for that to be true.
13
14
  *
14
15
  * ONE-WAY. The identifier is what the record stores, cites and checks against
15
16
  * the policy; this is only what a page prints. Nothing reads a name back into
@@ -34,8 +35,7 @@
34
35
  import { readFileSync } from "node:fs";
35
36
  import path from "node:path";
36
37
 
37
- import { parseAllDocuments } from "yaml";
38
-
38
+ import { parsePeople } from "./people-rule";
39
39
  import { projectRoot } from "./shared";
40
40
 
41
41
  const PEOPLE_YAML = path.join(projectRoot, ".ksor", "people.yaml");
@@ -48,26 +48,7 @@ function loadPeople(): ReadonlyMap<string, string> {
48
48
  // Optional: its absence means "no natural names declared".
49
49
  return new Map();
50
50
  }
51
- try {
52
- const docs = parseAllDocuments(text.replace(/^/, ""), {
53
- schema: "core",
54
- uniqueKeys: true,
55
- logLevel: "silent",
56
- });
57
- const value: unknown = docs[0]?.toJS();
58
- if (typeof value !== "object" || value === null) return new Map();
59
- const table = (value as { people?: unknown }).people;
60
- if (typeof table !== "object" || table === null || Array.isArray(table)) return new Map();
61
- const out = new Map<string, string>();
62
- for (const [actor, name] of Object.entries(table as Record<string, unknown>)) {
63
- // A blank value is an entry someone started and left; printing "" would
64
- // erase the identifier rather than replace it.
65
- if (typeof name === "string" && name.trim() !== "") out.set(actor.trim(), name.trim());
66
- }
67
- return out;
68
- } catch {
69
- return new Map();
70
- }
51
+ return parsePeople(text);
71
52
  }
72
53
 
73
54
  let cached: ReadonlyMap<string, string> | null = null;
@@ -48,6 +48,7 @@ import type { Refusal } from "../record/refusal";
48
48
  const KNOWLEDGE = "knowledge/";
49
49
  const LEDGER_PATH = ".ksor/takedowns.yaml";
50
50
  const POLICY_PATH = ".ksor/governance.yaml";
51
+ const PEOPLE_PATH = ".ksor/people.yaml";
51
52
  /**
52
53
  * Everything this build may publish, as bytes at bundle-relative paths: the
53
54
  * admitted concepts (copied), their companions (copied), ONLY the assets those
@@ -177,6 +178,7 @@ function planStage(recordDir: string, development: boolean): StagePlan {
177
178
  instance: record.files.get("instance.md") ?? "",
178
179
  policy: record.files.get(POLICY_PATH) ?? "",
179
180
  ledger: record.files.get(LEDGER_PATH) ?? null,
181
+ people: record.files.get(PEOPLE_PATH) ?? null,
180
182
  },
181
183
  { draftsRequested },
182
184
  );
@@ -11,7 +11,17 @@ import path from "node:path";
11
11
  import type { RecordFiles } from "./check";
12
12
  import type { ScaffoldStructure } from "./hygiene";
13
13
 
14
- const CONTROL_FILES = ["instance.md", ".ksor/governance.yaml", ".ksor/takedowns.yaml"] as const;
14
+ // `.ksor/people.yaml` is here because the SITE reads it and publishes what it
15
+ // says — it rewrites the approver, owner and verifier printed on every document
16
+ // page. A file that changes published bytes has to reach `build_id`, or the
17
+ // human surface and the machine surface of one build can disagree about who
18
+ // approved a document with nothing going red (found by review, 2026-09-01).
19
+ const CONTROL_FILES = [
20
+ "instance.md",
21
+ ".ksor/governance.yaml",
22
+ ".ksor/people.yaml",
23
+ ".ksor/takedowns.yaml",
24
+ ] as const;
15
25
  /** Files the operating system writes behind the author's back: ignored, never reported. */
16
26
  const OS_JUNK = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
17
27
 
@@ -46,6 +46,7 @@ const lockSchema = z
46
46
  drafts: z.enum(["hidden", "shown"]),
47
47
  instance_sha256: hex64,
48
48
  policy_sha256: hex64,
49
+ people_sha256: hex64,
49
50
  ledger_sha256: hex64,
50
51
  ledger_entries: z.array(z.object({ id: z.string().min(1), digest: hex64 }).strict()),
51
52
  audiences: z
@@ -89,6 +90,8 @@ export interface Lock {
89
90
  readonly drafts: Drafts;
90
91
  readonly instance_sha256: string;
91
92
  readonly policy_sha256: string;
93
+ /** The phone book the SITE publishes names from. Empty string when absent. */
94
+ readonly people_sha256: string;
92
95
  readonly ledger_sha256: string;
93
96
  /** `(id, digest)` per ledger entry, sorted by id — the baseline the next build compares TEXT against. */
94
97
  readonly ledger_entries: readonly { readonly id: string; readonly digest: string }[];
@@ -154,6 +157,7 @@ export interface BuildIdInputs {
154
157
  readonly indexes: readonly { readonly path: string; readonly sha256: string }[];
155
158
  readonly instance_sha256: string;
156
159
  readonly policy_sha256: string;
160
+ readonly people_sha256: string;
157
161
  readonly ledger_sha256: string;
158
162
  readonly ksor_version: string;
159
163
  readonly drafts: Drafts;
@@ -181,6 +185,9 @@ export function buildIdOf(inputs: BuildIdInputs): string {
181
185
  .sort((a, b) => compare(a[0] ?? "", b[0] ?? "")),
182
186
  instance_sha256: inputs.instance_sha256,
183
187
  policy_sha256: inputs.policy_sha256,
188
+ // The site prints what this file says in place of the stored actor, so it
189
+ // is published content and belongs in the id like any other.
190
+ people_sha256: inputs.people_sha256,
184
191
  ledger_sha256: inputs.ledger_sha256,
185
192
  ksor_version: inputs.ksor_version,
186
193
  drafts: inputs.drafts,
@@ -233,6 +240,8 @@ export interface LockInput {
233
240
  readonly drafts: Drafts;
234
241
  readonly instanceText: string;
235
242
  readonly policyText: string;
243
+ /** Null when `.ksor/people.yaml` does not exist, which is every record until an owner writes one. */
244
+ readonly peopleText: string | null;
236
245
  /** Null when the ledger file does not exist. */
237
246
  readonly ledgerText: string | null;
238
247
  readonly ledgerEntries: readonly { readonly id: string; readonly digest: string }[];
@@ -269,6 +278,7 @@ export function composeLock(input: LockInput): Lock {
269
278
  .map((i) => ({ path: i.path, sha256: sha256Hex(i.text) }));
270
279
  const instance_sha256 = sha256Hex(input.instanceText);
271
280
  const policy_sha256 = sha256Hex(input.policyText);
281
+ const people_sha256 = sha256Hex(input.peopleText ?? "");
272
282
  const ledger_sha256 = sha256Hex(input.ledgerText ?? "");
273
283
  return {
274
284
  format: LOCK_FORMAT,
@@ -279,6 +289,7 @@ export function composeLock(input: LockInput): Lock {
279
289
  indexes,
280
290
  instance_sha256,
281
291
  policy_sha256,
292
+ people_sha256,
282
293
  ledger_sha256,
283
294
  ksor_version: input.ksorVersion,
284
295
  drafts: input.drafts,
@@ -291,6 +302,7 @@ export function composeLock(input: LockInput): Lock {
291
302
  drafts: input.drafts,
292
303
  instance_sha256,
293
304
  policy_sha256,
305
+ people_sha256,
294
306
  ledger_sha256,
295
307
  ledger_entries: [...input.ledgerEntries].sort((a, b) => compare(a.id, b.id)),
296
308
  audiences: { registry: [...input.audiences].sort(), viewers },