@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.
@@ -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-C0vL3oOK.mjs
9
+ //#region ../content-gateway/dist/gateway-api-CEsb-e8z.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
  /**
@@ -9238,6 +9238,15 @@ var OpenAiEmbeddingProvider = class {
9238
9238
  * directly, so it is not ported.)
9239
9239
  */
9240
9240
  /**
9241
+ * The stable first stderr line for a missing provider key, on BOTH planes:
9242
+ * `ksor serve` (through the gateway's `bootErrorLines`) and `ksor ingest` /
9243
+ * `ksor calibrate` (the write plane's `fail`). Exit 3 either way — the key is
9244
+ * the operator's environment — but an exit code is not a name, and this was
9245
+ * the one refusal in the first-hour path that printed its sentence with no
9246
+ * slug above it (found live, 2026-09-02).
9247
+ */
9248
+ const PROVIDER_KEY_MISSING = "ksor-provider-key-missing";
9249
+ /**
9241
9250
  * A key-needing provider was built without an API key. A TYPED error (mirrors
9242
9251
  * EmbeddingSpaceMismatch) so a composition root classifies the missing-key case
9243
9252
  * by TYPE, not by string-matching this message — the exact prose-coupling scar
@@ -9245,6 +9254,7 @@ var OpenAiEmbeddingProvider = class {
9245
9254
  * exit-code mapping no longer breaks when it is reworded.
9246
9255
  */
9247
9256
  var MissingProviderKeyError = class extends Error {
9257
+ slug = PROVIDER_KEY_MISSING;
9248
9258
  providerName;
9249
9259
  keyEnv;
9250
9260
  /**
@@ -9577,6 +9587,34 @@ function keywordAbstains(topRank, config) {
9577
9587
  if (config.keywordFloor === null) return topRank === null;
9578
9588
  return topRank === null || topRank < config.keywordFloor;
9579
9589
  }
9590
+ const PUBLISHED_SQL = `
9591
+ SELECT c.active_generation AS generation,
9592
+ r.source_commit,
9593
+ (SELECT count(*)::int FROM content_nodes n
9594
+ WHERE n.tenant_id = c.tenant_id AND n.generation = c.active_generation) AS nodes
9595
+ FROM corpora c
9596
+ LEFT JOIN ingestion_runs r
9597
+ ON r.tenant_id = c.tenant_id AND r.corpus_id = c.corpus_id
9598
+ AND r.generation = c.active_generation
9599
+ WHERE c.tenant_id = $1 AND c.corpus_id = $2`;
9600
+ /**
9601
+ * One statement on an already-scoped client, so a readiness probe can read it
9602
+ * in place of its own `SELECT 1` and keep the answer fresh without a second
9603
+ * statement per probe.
9604
+ */
9605
+ async function readPublished(client, instance) {
9606
+ const row = (await client.query(PUBLISHED_SQL, [instance.tenantId, instance.corpusId])).rows[0];
9607
+ const generation = Number(row?.generation ?? 0);
9608
+ if (row === void 0 || generation === 0) return null;
9609
+ return {
9610
+ generation,
9611
+ nodes: Number(row.nodes ?? 0),
9612
+ sourceCommit: row.source_commit ?? "unspecified"
9613
+ };
9614
+ }
9615
+ async function publishedGeneration(pool, instance) {
9616
+ return runRead(pool, instance.tenantId, (client) => readPublished(client, instance));
9617
+ }
9580
9618
  /** The serving subset of the schema CHECK vocabulary. */
9581
9619
  const READ_ACTIONS = /* @__PURE__ */ new Set([
9582
9620
  "similarity_searched",
@@ -10551,6 +10589,39 @@ async function search(ctx, query, k = 10) {
10551
10589
  textSearchConfig: inst.textSearchConfig,
10552
10590
  pinnedGeneration: null
10553
10591
  };
10592
+ if (await runRead(ctx.pool, inst.tenantId, async (client) => {
10593
+ const r = await client.query("SELECT active_generation FROM corpora WHERE tenant_id = $1 AND corpus_id = $2", [inst.tenantId, inst.corpusId]);
10594
+ return Number(r.rows[0]?.active_generation ?? 0);
10595
+ }, servingScope(ctx)) === 0) {
10596
+ const audited = await logRead(ctx.pool, {
10597
+ tenantId: inst.tenantId,
10598
+ corpusId: inst.corpusId,
10599
+ actor,
10600
+ action: "search_abstained",
10601
+ instanceDigest: ctx.instanceDigest,
10602
+ detail: {
10603
+ ...actScope(ctx),
10604
+ abstained: true,
10605
+ result_count: 0,
10606
+ query_chars: queryChars,
10607
+ k,
10608
+ k_effective: kb,
10609
+ top_cosine: null,
10610
+ degraded: false
10611
+ }
10612
+ });
10613
+ return {
10614
+ ok: false,
10615
+ abstained: false,
10616
+ reason: "unpublished",
10617
+ gate: gateState(inst),
10618
+ top_cosine: null,
10619
+ hits: [],
10620
+ snapshot: null,
10621
+ ...kNote === void 0 ? {} : { k_note: kNote },
10622
+ ...audited ? {} : { audit: "degraded" }
10623
+ };
10624
+ }
10554
10625
  let queryVector = null;
10555
10626
  let degradedReason;
10556
10627
  let embedFailed = false;
@@ -10602,11 +10673,7 @@ async function search(ctx, query, k = 10) {
10602
10673
  degraded: degradedReason !== void 0
10603
10674
  }
10604
10675
  });
10605
- const unpublished = generation === void 0 && await runRead(ctx.pool, inst.tenantId, async (client) => {
10606
- const r = await client.query("SELECT active_generation FROM corpora WHERE tenant_id = $1 AND corpus_id = $2", [inst.tenantId, inst.corpusId]);
10607
- return Number(r.rows[0]?.active_generation ?? 0) === 0;
10608
- }, servingScope(ctx));
10609
- const reason = embedFailed ? "unavailable" : unpublished ? "unpublished" : "abstained";
10676
+ const reason = embedFailed ? "unavailable" : "abstained";
10610
10677
  return {
10611
10678
  ok: false,
10612
10679
  abstained: reason === "abstained",
@@ -11156,4 +11223,4 @@ function readHandler(ctx) {
11156
11223
  };
11157
11224
  }
11158
11225
  //#endregion
11159
- export { parseViewer as A, tallyHandlers as B, contentPoolMin as C, outlineHandler as D, keyRingFromEnv as E, recordIsUndescribed as F, z$1 as G, validateViewer as H, runProbe as I, searchHandler as L, prewarmPool as M, providerKeyEnv as N, parseInstanceText as O, readHandler as P, servingPolicy as R, contentPool as S, instancePathOf as T, withPgRetry as U, tlsPosture as V, withProbeDeadline as W, assertGovernanceServable as _, GovernanceGateError as a, checkEmbeddingSpace as b, McpServer$1 as c, READ_ONLY as d, READ_OUTPUT as f, TextSearchConfigMismatch as g, TRUST_TIERS as h, FLOOR as i, pooledEndpointFor as j, parseTrustFloor as k, MissingProviderKeyError as l, SchemaVersionError as m, ContentStoreError as n, MAX_OUTLINE_LIMIT as o, SEARCH_OUTPUT as p, EmbeddingSpaceMismatch as r, MAX_SEARCH_K as s, AudienceError as t, OUTLINE_OUTPUT as u, assertSchemaCompatible as v, embedQueryVlit as w, composeInstructions as x, buildShippedProvider as y, storedTextSearchConfig as z };
11226
+ export { parseViewer as A, servingPolicy as B, contentPoolMin as C, outlineHandler as D, keyRingFromEnv as E, readHandler as F, withPgRetry as G, tallyHandlers as H, readPublished as I, withProbeDeadline as K, recordIsUndescribed as L, prewarmPool as M, providerKeyEnv as N, parseInstanceText as O, publishedGeneration as P, runProbe as R, contentPool as S, instancePathOf as T, tlsPosture as U, storedTextSearchConfig as V, validateViewer as W, assertGovernanceServable as _, GovernanceGateError as a, checkEmbeddingSpace as b, McpServer$1 as c, READ_ONLY as d, READ_OUTPUT as f, TextSearchConfigMismatch as g, TRUST_TIERS as h, FLOOR as i, pooledEndpointFor as j, parseTrustFloor as k, MissingProviderKeyError as l, SchemaVersionError as m, ContentStoreError as n, MAX_OUTLINE_LIMIT as o, SEARCH_OUTPUT as p, z$1 as q, EmbeddingSpaceMismatch as r, MAX_SEARCH_K as s, AudienceError as t, OUTLINE_OUTPUT as u, assertSchemaCompatible as v, embedQueryVlit as w, composeInstructions as x, buildShippedProvider as y, searchHandler as z };
@@ -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-CEnK8Bc8.d.mts
4
+ //#region ../content-gateway/dist/gateway-api-Cla3mzJ0.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-D-xEl8mz.d.mts
19
+ //#region ../content/dist/index-gfAuwbwI.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-C0vL3oOK-D24n786A.mjs";
1
+ import { D as outlineHandler, F as readHandler, L as recordIsUndescribed, 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, q as z$1, s as MAX_SEARCH_K, u as OUTLINE_OUTPUT, x as composeInstructions, z as searchHandler } from "./gateway-api-CEsb-e8z-Ch6oMaq2.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 };
@@ -0,0 +1,157 @@
1
+ ---
2
+ title: Building
3
+ status: draft
4
+ ---
5
+
6
+ # `ksor build`, and handing the record to something else
7
+
8
+ `ksor build` is the database-free verb that makes the SITE correct: it
9
+ regenerates every `index.md` in memory, runs the record checker, and on green
10
+ writes the indexes whose bytes changed plus `build.lock.json` — the provenance
11
+ every machine artefact stamps. `pnpm build` in a scaffold runs it before the
12
+ site build, so most owners never type it. The verb's own page is
13
+ `ksor build --help`; the flags that matter here:
14
+
15
+ ```bash
16
+ ksor build # check, regenerate, write the lock
17
+ ksor build --as-of 2026-09-01T00:00:00Z # pin the instant lifecycle is judged at
18
+ ksor build --strict # refuse an uncommitted input (a release)
19
+ ksor build --bundles # also write one OKF bundle per viewer
20
+ ```
21
+
22
+ A refusal exits `1` with its slug on the first stderr line and writes nothing.
23
+
24
+ ## `--bundles`: one OKF bundle per viewer
25
+
26
+ The record is an OKF bundle in the KSoR Profile, so `knowledge/` handed to any
27
+ OKF consumer already reads as a conformant bundle. What it does NOT do is
28
+ respect audience: the committed tree holds every document for every reader,
29
+ drafts and internal ones included, because anyone with the repository has the
30
+ files anyway. `--bundles` is the projection for the case where you hand the
31
+ record to someone who should see only part of it.
32
+
33
+ For `public`, and for each audience `X` registered in `.ksor/governance.yaml`,
34
+ it writes `.ksor/out/bundles/<viewer>/` built for the viewer list
35
+ `[public, X]` exactly — the same admission the site and the MCP door use, taken
36
+ from the lock's own `admitted` set:
37
+
38
+ - the admitted concepts only: `stable`, past `effective_from`, before
39
+ `stale_after`, not taken down, audience overlapping;
40
+ - their companions (`x.summary.md`, `x.flashcards.yaml`, …) beside them;
41
+ - the assets their bodies reference — an image nothing published mentions does
42
+ not travel;
43
+ - every `index.md` regenerated for that filtered tree, with `okf_version` at
44
+ the root, so a folder with nothing admitted has no index and no bullet in
45
+ its parent;
46
+ - frontmatter verbatim, unknown keys preserved, bytes unchanged.
47
+
48
+ No byte of a concept excluded for AUDIENCE reaches a bundle: not its title, its
49
+ path, its description, a companion of it, or an asset only it references. The
50
+ test that holds this greps the emitted tree for the excluded document rather
51
+ than inspecting it by name, because a bundle is the one projection that leaves
52
+ the building. What makes it byte-complete is the record checker, not this
53
+ command: a link, a supersession pointer or a companion body reaching a narrower
54
+ audience is refused as `ksor-link-widens` before a bundle is ever planned, so a
55
+ body copied verbatim cannot name what its own readers may not open.
56
+
57
+ An exclusion for a LIFECYCLE or LEDGER reason — a draft, a document not yet
58
+ effective, one past `stale_after`, one taken down — is narrower on purpose. The
59
+ body is copied verbatim, never rewritten, so if a held document links to one of
60
+ those, the excluded path ships inside that link. It is [reported per
61
+ bundle](#what-it-will-tell-you) rather than edited away: rewriting a body would
62
+ make the bundle a derivative instead of a copy you can hash against the record,
63
+ and the reader who sees the path is already entitled to that audience.
64
+
65
+ ```
66
+ .ksor/out/bundles/
67
+ ├── build.lock.json # a copy — the bundles travel with the build that made them
68
+ ├── public/
69
+ │ ├── index.md # okf_version: "0.2", bullets for what public may read
70
+ │ ├── what-is-a-ksor.md
71
+ │ ├── what-is-a-ksor.summary.md
72
+ │ └── surfaces/
73
+ │ ├── index.md
74
+ │ └── …
75
+ └── internal/ # the viewer [public, internal]: everything above, plus
76
+ ├── index.md
77
+ ├── board-pay.md
78
+ └── …
79
+ ```
80
+
81
+ The directory is REPLACED on every `--bundles` run, so a bundle for an audience
82
+ the policy no longer registers does not sit beside the fresh ones. It is
83
+ gitignored by the scaffold's `.ksor/*` rule; nothing under it is the record.
84
+
85
+ ### What the lock records
86
+
87
+ `build.lock.json` carries `bundles[]` — one `{ viewer, sha256, files }` per
88
+ viewer — on EVERY build, whether or not `--bundles` was passed. The bundles are
89
+ a function of what the lock already hashes, so recording them does not depend
90
+ on the flag and does not move `build_id`. The digest is sha256 over the JSON of
91
+ the bundle's sorted `[path, sha256]` pairs, stated this plainly so a recipient
92
+ holding only the directory can recompute it and match it to a publication:
93
+
94
+ ```js
95
+ import { createHash } from "node:crypto";
96
+ const sha = (b) => createHash("sha256").update(b).digest("hex");
97
+ const pairs = files // [[relativePath, bytes], …] — every file in the bundle
98
+ .map(([rel, bytes]) => [rel, sha(bytes)])
99
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
100
+ const digest = sha(JSON.stringify(pairs)); // equals lock.bundles[i].sha256
101
+ ```
102
+
103
+ ### What it will tell you
104
+
105
+ A body is copied verbatim, never rewritten. If an admitted document links to a
106
+ concept the bundle excludes for a lifecycle or ledger reason — a draft, one not
107
+ yet effective, one past its review date, one taken down — the link dangles for
108
+ that bundle's reader, and the build says so on stdout:
109
+
110
+ ```
111
+ wrote .ksor/out/bundles/public/ — the OKF bundle for viewer [public], 8 file(s)
112
+ policies/board-pay.md links to policies/purchase-approval.md, which this bundle excludes — the link dangles for its reader
113
+ ```
114
+
115
+ A link that would widen AUDIENCE never gets this far: the record checker
116
+ refuses it (`ksor-link-widens`) before any bundle is planned, because a public
117
+ document naming an internal one leaks the name whether or not the target
118
+ travels.
119
+
120
+ Two refusals come from the bundles, and both run on EVERY build, with or
121
+ without the flag — the lock records `bundles[]` either way, and a digest for a
122
+ directory the tool refuses to write would be provenance for something that
123
+ cannot exist.
124
+
125
+ An audience identifier is a directory name here, so one that cannot be a path
126
+ segment is refused before anything is written:
127
+
128
+ ```
129
+ error: ksor-audience-identifier-invalid
130
+ ```
131
+
132
+ Name audiences in plain words — a letter or a digit first, then letters,
133
+ digits, `-`, `_` and `.` — in `.ksor/governance.yaml` and in every
134
+ `ksor.audience` list. That first-character rule is why `../escape`, `.hidden`
135
+ and `-x` are all refused, and `build.lock.json` is refused too: the lock copy
136
+ sits beside the bundle directories.
137
+
138
+ Two registered audiences that differ only in case — `internal` and `Internal` —
139
+ are two viewers and one directory on macOS and on Windows, whose filesystems
140
+ are case-insensitive by default. The second bundle written would merge into the
141
+ first, leaving a directory holding concepts the viewer named on it may not read
142
+ and a lock digest that no longer describes it, so they are refused on every
143
+ platform alike:
144
+
145
+ ```
146
+ error: ksor-audience-identifier-collides
147
+ ```
148
+
149
+ Give each audience a name that differs by more than case. `public` is reserved,
150
+ casefolded too.
151
+
152
+ ### What it is not
153
+
154
+ It is not import: reading a foreign bundle into a record is not built. And it
155
+ is not the site or the door — a bundle carries no `llms.txt`, no search index,
156
+ no citations. It is the record, filtered to one viewer, in the format the record
157
+ is already written in.
package/docs/deploying.md CHANGED
@@ -314,8 +314,8 @@ correct for a genuinely public record or one behind your own gateway; it is not
314
314
  way to make a deploy go green.
315
315
 
316
316
  The alternative is a real authorization server — `KSOR_SSO_URL`,
317
- `KSOR_MCP_RESOURCE_URL`, `KSOR_JWT_ALLOWED_AUDIENCES`, with worked recipes for two
318
- of them in [authorization.md](./authorization.md).
317
+ `KSOR_MCP_RESOURCE_URL`, `KSOR_JWT_ALLOWED_AUDIENCES`, with worked recipes for
318
+ four of them in [authorization.md](./authorization.md).
319
319
 
320
320
  ### Set this on any container host
321
321
 
@@ -378,7 +378,14 @@ to anyone who types the URL, so there is nothing for rebinding to steal.
378
378
  database: it regenerates every `index.md`, runs the record checker, and writes
379
379
  `build.lock.json` — commit it — which every machine artefact stamps. A refusal
380
380
  stops the build before a byte is written; `--strict` also refuses an
381
- uncommitted input. Takedowns reach the site through the committed ledger
381
+ uncommitted input. One refusal reads git: a `stable` document whose body
382
+ changed while its `generated.at` did not ADVANCE — left alone, or moved
383
+ backward — is `ksor-generated-stale`, compared against every committed version
384
+ of the file, so when you edit a published document, move the stamp forward and
385
+ re-approve. A checkout without history cannot
386
+ read those versions; the build then prints `change-control: not checked` (or,
387
+ on a shallow clone, how many versions it did read) beside the `source:` line
388
+ instead of passing quietly. Takedowns reach the site through the committed ledger
382
389
  (`.ksor/takedowns.yaml`), which is a file in the repository — so the site build
383
390
  needs no `KSOR_DB_URL` at all.
384
391
 
@@ -573,7 +580,7 @@ auth-off default, and that refusal is the last real step of a deployment. Two
573
580
  ways past it:
574
581
 
575
582
  - **Configure the SSO door** — `KSOR_SSO_URL`, `KSOR_MCP_RESOURCE_URL`,
576
- `KSOR_JWT_ALLOWED_AUDIENCES`. Worked recipes for two different authorization
583
+ `KSOR_JWT_ALLOWED_AUDIENCES`. Worked recipes for four different authorization
577
584
  servers: [authorization.md](./authorization.md).
578
585
  - **Set `KSOR_AUTH=disabled-public`** — a deliberate decision that
579
586
  serves your whole record to anyone who can reach the port. Correct for a
@@ -585,13 +592,18 @@ Check which one you got. `/health` says so plainly:
585
592
  ```json
586
593
  {
587
594
  "corpus_id": "book",
588
- "abstain_gate": "OFF (no floor declared will not refuse out-of-corpus questions)",
595
+ "generation": "1 · 81 nodes · source 3807493d3f2f1b4c2e6b0a9d8c7f6e5d4c3b2a10",
596
+ "abstain_gate": "OFF — no floor calibrated; out-of-corpus questions will be answered, not refused",
589
597
  "embedding_space": "gemini-embedding-001/d1536 ok",
590
598
  "auth": "disabled"
591
599
  }
592
600
  ```
593
601
 
594
602
  `"auth":"disabled"` on a public host means the second option is in effect.
603
+ `"generation"` is what the door is serving — `NONE — nothing published; run
604
+ pnpm refresh` on a record that was provisioned and never ingested, which is
605
+ where skipping the publish step leaves it — and it is re-read on every
606
+ readiness probe, so a `refresh` after boot shows here without a restart.
595
607
 
596
608
  ## What a cold start costs
597
609
 
package/docs/index.md CHANGED
@@ -43,6 +43,10 @@ instead of their training memory. The corpus grows with each implemented verb.
43
43
  `system/gateways/content.ts` is emitted, adopter-owned and deletable; it
44
44
  decides tool names, what the record says it covers, and how much of the
45
45
  caller's context an answer costs. Includes the measurements.
46
+ - **[building.md](./building.md)** — `ksor build`, and `--bundles`: one OKF
47
+ bundle per viewer under `.ksor/out/bundles/`, holding only what that
48
+ viewer's machine surfaces publish, for handing the record to another
49
+ system with no ksor in the loop. Includes how the lock records each one.
46
50
  - **[deploying.md](./deploying.md)** — getting both surfaces onto a host. The
47
51
  scaffold emits a `Dockerfile` that names no vendor, and a `vercel.json` that
48
52
  points at it to put the site and the MCP door behind one domain. Includes
@@ -57,14 +61,14 @@ instead of their training memory. The corpus grows with each implemented verb.
57
61
  which a dependency bump reaches a project already scaffolded. Includes the
58
62
  list of files migrate does NOT carry, so you know what to diff by hand.
59
63
  - **[authorization.md](./authorization.md)** — putting the record behind an
60
- authorization server, with worked recipes for two of them, executed rather
61
- than written. `ksor serve` refuses to boot unauthenticated on a public bind,
64
+ authorization server, with worked recipes for four of them two self-hosted,
65
+ one hosted, one an organisation's own — executed rather than written. `ksor serve` refuses to boot unauthenticated on a public bind,
62
66
  so this is the last step of a deployment, not an optional hardening pass.
63
67
  - Exit codes are a contract: `1` refused (first stderr line is a stable
64
68
  slug such as `error: bad-name`, followed by a remedy), `2` designed but
65
69
  not implemented, `3` the environment cannot run ksor
66
70
  (`error: unsupported-platform`, `error: broken-install`,
67
- `error: environment`).
71
+ `error: ksor-provider-key-missing`, `error: environment`).
68
72
  - The package root exports the CLI contract: `exitCodes`, `verbs`, and
69
73
  `resolveCommand`.
70
74
 
package/docs/ingesting.md CHANGED
@@ -50,7 +50,7 @@ root**, where `instance.md` and `package.json` live.
50
50
 
51
51
  ```sh
52
52
  pnpm provision # ksor schema --apply, then ksor grant
53
- pnpm refresh # ksor ingest --flip, then ksor gc
53
+ pnpm refresh # ksor build, then ksor ingest --flip, then ksor gc
54
54
  ```
55
55
 
56
56
  `provision` is separate because applying DDL and granting ingest are acts an
@@ -88,6 +88,26 @@ website's lock was fixed for exactly this once already, after deleting a
88
88
  denial's four lines republished the document with the committed lock still
89
89
  validating; ingest had the same hole, found by review before anyone hit it.
90
90
 
91
+ ## When ingest says generated.at is stale
92
+
93
+ `ksor ingest` runs the same change-control check `ksor build` runs (KSP R23;
94
+ the scaffold's `pnpm check` does not — it is the format gate, and reads no
95
+ document history): a `stable` document whose body differs from a committed version
96
+ that was `stable`, without `generated.at` having advanced past that version's,
97
+ is refused `ksor-generated-stale`, before anything is written. The stamp dates
98
+ the text, so leaving it alone and moving it backward are refused alike.
99
+ The fix is the one it prints — set `generated.at` to an instant after the edit,
100
+ then re-approve, because `ksor.approval.at` may not precede it — and the check
101
+ reads every committed version of the file, so an edit committed without a bump
102
+ is refused on a clean tree too.
103
+
104
+ Ingest reads git for this, and a container that received the record without
105
+ its `.git` cannot. It then prints `change-control: not checked` on stderr and
106
+ continues: a check that could not run is reported, never counted as passed.
107
+ Who approved is still checked against the policy alone — every envelope says
108
+ `approval.checked: "policy"` — because R22 and R25 wait on an identity the
109
+ platform can vouch for.
110
+
91
111
  ## What a generation is
92
112
 
93
113
  Each ingest builds a **fresh generation** — invisible until activated — and
@@ -204,7 +224,10 @@ LLM, and question synthesis is Gemini-only today — so a record on
204
224
  refused here for a Google key. That is a real gap, stated rather than papered
205
225
  over; the zero-LLM door below avoids it entirely and is the better choice on a
206
226
  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.
227
+ and a bigger corpus makes that worse, not better. Zero-LLM is not zero-key: the
228
+ questions are still embedded, so the embedding provider's own key
229
+ (`GEMINI_API_KEY` or `OPENAI_API_KEY`, whichever `embedding.provider` names) is
230
+ still required — only the question synthesis is skipped.
208
231
 
209
232
  Write your own in-corpus questions, one per line, and pass them:
210
233
 
@@ -289,9 +312,12 @@ decision in this project's own gold rather than a threshold somebody picked.
289
312
  Run it on a schedule, or in your own CI beside `ksor build`. Three things to
290
313
  know about what it is:
291
314
 
292
- - **It never fails a run.** It always exits 0. A stale floor wants
293
- re-measuring, and failing a build for one would make the shortest way out
294
- deleting `vector_floor` — turning the gate off entirely to clear the error.
315
+ - **A verdict never fails a run.** STEADY, WATCH and no-data all exit 0: a
316
+ stale floor wants re-measuring, and failing a build for one would make the
317
+ shortest way out deleting `vector_floor` — turning the gate off entirely to
318
+ clear the error. What does exit non-zero is the environment, the same way it
319
+ does for every verb: 3 when `KSOR_DB_URL` is unset or the database is
320
+ unreachable, 1 on a bad flag. Put it in CI knowing that.
295
321
  - **It reads traffic, so it needs traffic**, and it says so rather than
296
322
  reporting a healthy-looking nothing. It also cannot see questions nobody
297
323
  asked: it can tell you the floor has gone permissive, never that it is too
@@ -326,7 +352,11 @@ pull request is refused exactly as the verb would refuse it.
326
352
  Lifting a takedown is `--revoke <entry-id>` — the id of the LEDGER ENTRY, not
327
353
  the stable id. The denial that created it prints the id, `ksor takedown
328
354
  --ledger` lists it, and it is written in `.ksor/takedowns.yaml`; none of the
329
- three needs a database, because the ledger is a file in the repository. The
355
+ three needs a database, because the ledger is a file in the repository
356
+ `--ledger` reads that file and never asks for a DSN, whatever `instance.md`
357
+ declares. (`--list` is a question about the door's rows, so it reads them when
358
+ the DSN is set; with none it lists the ledger's denials, each labelled
359
+ `not applied (no database)`.) The
330
360
  ledger is append-only: a revocation is a new entry, never a deleted line, and a
331
361
  build whose ledger shrank against its own git history is refused.
332
362
 
@@ -144,9 +144,6 @@ made it answer from.
144
144
  only things that can prove a passage came from the governed record. A
145
145
  hand-written one returning fabricated hits with plausible `stable_id`s would
146
146
  pass every shape check there is.
147
- - **The output schemas.** `SEARCH_OUTPUT`, `OUTLINE_OUTPUT`, `READ_OUTPUT` carry
148
- `provenance`, each hit's `governance`, the `snapshot` token and `gate`. A
149
- record that reshaped them would still look like a KSoR and no longer be one.
150
147
  - **The output schemas.** `SEARCH_OUTPUT`, `OUTLINE_OUTPUT`, `READ_OUTPUT` carry
151
148
  `provenance`, each hit's `governance`, the `snapshot` token, `gate`, and
152
149
  `audit`. A record that reshaped them would still look like a KSoR and no
package/docs/upgrading.md CHANGED
@@ -45,6 +45,7 @@ easier to read when nothing else is uncommitted.
45
45
  | `.gitignore` | the entries a new release needs negated |
46
46
  | `.agents/` and `.claude/` format-checker | the emitted checker, so your own `pnpm check` and your CI agree with the tool |
47
47
  | root `package.json` **scripts** | scripts a release broke — a removed flag, a step that now needs `ksor build` in front of it |
48
+ | `build.lock.json` | DELETED when this ksor cannot read it — see below |
48
49
  | `system/site/**` | **only with `--write-site`** — every file of the site this release emits |
49
50
 
50
51
  `--write-site` is the one to remember, because it is the only path by which a
@@ -54,6 +55,19 @@ security bump reaches an existing project: the site's `package.json` is where
54
55
  It is an **update, never a creation**. A record with no `system/site` of its own
55
56
  is not given one.
56
57
 
58
+ ### A lock a newer ksor cannot read is deleted, never rewritten
59
+
60
+ `build.lock.json` gains fields as the record's surfaces grow, and `ksor build`
61
+ REFUSES a lock missing one (`ksor-lock-invalid`) instead of regenerating it: the
62
+ lock is also a takedown baseline, and a baseline nothing can parse is one that
63
+ quietly holds nothing. So the file has to GO before the first build on a new
64
+ ksor, and step 3 is what removes it — the diff shows the deletion like any
65
+ other. Step 4 then writes the current one.
66
+
67
+ Skip the migration and step 4 tells you the same thing: `ksor build` exits 1
68
+ naming the file, and so does `pnpm build`, which runs it first. Nothing is lost
69
+ — a lock is computed from the tree, never edited.
70
+
57
71
  ### The site manifest is merged, not replaced
58
72
 
59
73
  Every other file under `system/site` is reissued whole. `system/site/package.json`
@@ -76,7 +90,7 @@ These are yours, and no release touches them. Diff them against a fresh
76
90
  - `pnpm-workspace.yaml` / `.npmrc` and any lockfile
77
91
 
78
92
  ```sh
79
- npx @panaversity/ksor@latest init /tmp/fresh
93
+ (cd /tmp && npx @panaversity/ksor@latest init fresh) # init takes a NAME, not a path
80
94
  diff -ru /tmp/fresh/vercel.json ./vercel.json
81
95
  ```
82
96
 
@@ -97,7 +111,7 @@ that supplies it. Two you will meet often:
97
111
 
98
112
  ## After it applies
99
113
 
100
- `pnpm build` regenerates every index and rewrites `build.lock.json`; `pnpm check`
114
+ `pnpm build` regenerates every index and writes a fresh `build.lock.json`; `pnpm check`
101
115
  runs the record checker that shipped with the new tool. If you serve the record,
102
116
  `ksor schema --apply` and then a full `pnpm refresh` publish a generation the new
103
117
  door can read — and a calibrated `vector_floor` measured under an older serving
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.57",
3
+ "version": "0.0.59",
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",