@panaversity/ksor 0.0.28 → 0.0.30

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,89 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.30
4
+
5
+ ### Patch Changes
6
+
7
+ - fbf149b: Quizzes, as governed attachments of a document.
8
+
9
+ A document in `knowledge/` may now carry `<doc>.quiz.yaml` beside its summary
10
+ and its flashcard deck. It renders at the end of the document's page, under the
11
+ deck: choose an option, see immediately whether you were right, and read the
12
+ explanation before moving on. There is no pass mark — a quiz here checks
13
+ understanding of the record, it does not certify anybody — and answers stay in
14
+ the reader's own browser.
15
+
16
+ A quiz is **part of its document, not a document**: no URL, no sidebar row, no
17
+ `llms.txt` line, no markdown twin, no search entry, and no stable id. That last
18
+ one settles a question worth being explicit about: because `ksor ingest` creates
19
+ no node for a quiz, **the answer key cannot reach the MCP surface at all**.
20
+ There is nothing for an agent to search and nothing for it to read — not by a
21
+ filter that could be forgotten, but because the row does not exist. Governance
22
+ inherits from the parent exactly as the summary and the deck already do.
23
+
24
+ **`pnpm check` and `pnpm build` refuse a quiz a reader could pass without
25
+ reading it**, naming the questions to fix:
26
+
27
+ - `ksor-quiz-answer-bias` — more than 60% of answers at one option position
28
+ - `ksor-quiz-length-bias` — picking the longest or shortest option usually wins
29
+ - `ksor-quiz-answer-run` — four or more questions in a row share an answer
30
+ - `ksor-quiz-contradiction` — an explanation calls the marked answer wrong
31
+ - `ksor-quiz-duplicate-stem` — two questions open with the same 60 characters
32
+
33
+ These are carried from the predecessor, where the same mistakes shipped and were
34
+ found by readers rather than by the project — one quiz put every correct answer
35
+ in the same position across 451 questions. There they lived in a script that was
36
+ run once; here they are part of loading the file, so a quiz that fails them
37
+ cannot be published. The ratio rules do not apply below five questions, where
38
+ enforcing a spread would mean choosing an author's answers for them.
39
+
40
+ `ksor init` ships a quiz on the seed document, so a first `pnpm dev` shows the
41
+ shape. Its own first draft was refused for putting four of five answers at
42
+ option B — the check catching exactly what it was carried for.
43
+
44
+ ## 0.0.29
45
+
46
+ ### Patch Changes
47
+
48
+ - f7e15cd: **Breaking:** `KSOR_AUTH_DISABLED` and `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED` are
49
+ replaced by one variable, `KSOR_AUTH`, whose value is the decision:
50
+
51
+ ```sh
52
+ KSOR_AUTH=disabled-local # no auth, loopback only — a public bind REFUSES
53
+ KSOR_AUTH=disabled-public # no auth, served to anyone who can reach the port
54
+ ```
55
+
56
+ Two booleans that had to agree to express one decision produced a state neither
57
+ name could tell you, and a fourth combination that meant nothing. `AUTH_DISABLED`
58
+ sounds like it already means "auth is off", so being told you also need
59
+ `ALLOW_PUBLIC_UNAUTHENTICATED` read as the tool asking you to say the same thing
60
+ twice. The guarantee is unchanged and unweakened — a copied `.env` carrying
61
+ `disabled-local` still refuses on a container, which is the leak that pair
62
+ existed to catch. Setting either retired variable now refuses at boot and names
63
+ its replacement.
64
+
65
+ **The boot report no longer stays silent about ephemeral snapshot keys.** Unset
66
+ `KSOR_SNAPSHOT_KEYS` mints a per-process signing key — honest for one process,
67
+ and wrong for the container hosts we ship a Dockerfile for. A generation pin
68
+ issued by one instance is then unverifiable by the next, so `read` silently drops
69
+ to the active generation and reports `refreshed (invalid)`. It fails soft, so
70
+ nothing errors and nothing logs; the only symptom is an agent reading a
71
+ generation it did not search. Found on a real deployment by noticing one read in
72
+ three come back unpinned. On a public bind the door now says so:
73
+
74
+ ```
75
+ snapshot EPHEMERAL key — generation pins will NOT survive a restart or a
76
+ second instance; set KSOR_SNAPSHOT_KEYS to a value shared by every replica
77
+ ```
78
+
79
+ Not a refusal — a loopback dev run and a genuine single-instance deployment are
80
+ both legitimate — but no longer silent where the assumption stops holding.
81
+
82
+ `docs/deploying.md` splits its configuration table into three tiers: required to
83
+ boot, set on any container host, and set once auth is on. Listing six variables
84
+ as one table read as "set all of these or you are doing it wrong", and only the
85
+ first tier was ever true.
86
+
3
87
  ## 0.0.28
4
88
 
5
89
  ### Patch Changes
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-pl4aOpVs.mjs";
3
- import { A as runProbe$1, C as keyRingFromEnv, D as prewarmPool, E as pooledEndpointFor, F as withPgRetry$1, I as withProbeDeadline$1, L as z$1, M as storedTextSearchConfig, N as tlsPosture, O as readHandler, P as visibleTiers$1, S as embedQueryVlit, T as parseInstanceText$1, _ as buildShippedProvider$1, a as MAX_OUTLINE_LIMIT, b as contentPool$1, c as MissingProviderKeyError$1, d as READ_OUTPUT, f as SEARCH_OUTPUT, g as assertSchemaCompatible, h as assertGovernanceServable$1, i as GovernanceGateError$1, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, m as TextSearchConfigMismatch, n as EmbeddingSpaceMismatch$1, p as SchemaVersionError, r as FLOOR, s as McpServer$1, t as ContentStoreError$1, u as READ_ONLY, v as checkEmbeddingSpace$1, w as outlineHandler, x as contentPoolMin, y as composeInstructions } from "./gateway-api-8lNruq9e-CuohjtoK.mjs";
3
+ import { A as runProbe$1, C as keyRingFromEnv, D as prewarmPool, E as pooledEndpointFor, F as withPgRetry$1, I as withProbeDeadline$1, L as z$1, M as storedTextSearchConfig, N as tlsPosture, O as readHandler, P as visibleTiers$1, S as embedQueryVlit, T as parseInstanceText$1, _ as buildShippedProvider$1, a as MAX_OUTLINE_LIMIT, b as contentPool$1, c as MissingProviderKeyError$1, d as READ_OUTPUT, f as SEARCH_OUTPUT, g as assertSchemaCompatible, h as assertGovernanceServable$1, i as GovernanceGateError$1, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, m as TextSearchConfigMismatch, n as EmbeddingSpaceMismatch$1, p as SchemaVersionError, r as FLOOR, s as McpServer$1, t as ContentStoreError$1, u as READ_ONLY, v as checkEmbeddingSpace$1, w as outlineHandler, x as contentPoolMin, y as composeInstructions } from "./gateway-api-CbFkHZiU-HvlJRjRB.mjs";
4
4
  import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { InMemoryTransport, LATEST_PROTOCOL_VERSION, createMcpHandler } from "@modelcontextprotocol/server";
@@ -16,7 +16,7 @@ import { bodyLimit } from "hono/body-limit";
16
16
  import { execFileSync, spawnSync } from "node:child_process";
17
17
  import { parseArgs } from "node:util";
18
18
  import { readFile, readdir, stat } from "node:fs/promises";
19
- //#region ../content-gateway/dist/main-DiX1MNMX.mjs
19
+ //#region ../content-gateway/dist/main-CS5a_8OW.mjs
20
20
  /**
21
21
  * The default registration — and the ORIGINAL of the file `ksor init` emits.
22
22
  *
@@ -280,13 +280,18 @@ function configFromEnv(env) {
280
280
  */
281
281
  function buildAuth(env = process.env, deps = {}) {
282
282
  const warn = deps.warn ?? defaultWarn;
283
- const disabled = env.KSOR_AUTH_DISABLED === "1";
283
+ const declared = (env.KSOR_AUTH ?? "").trim();
284
284
  const config = configFromEnv(env);
285
- if (disabled) {
286
- if (config !== null) warn("auth DISABLED via KSOR_AUTH_DISABLED despite SSO config — UNAUTHENTICATED (dev)");
287
- return { mode: "disabled" };
285
+ if (declared === "" && (env.KSOR_AUTH_DISABLED !== void 0 || env.KSOR_ALLOW_PUBLIC_UNAUTHENTICATED !== void 0)) throw new AuthConfigError("KSOR_AUTH_DISABLED and KSOR_ALLOW_PUBLIC_UNAUTHENTICATED have been replaced by one variable, KSOR_AUTH. Set KSOR_AUTH=disabled-local for a loopback dev run, or KSOR_AUTH=disabled-public to serve the whole record to anyone who can reach the port.");
286
+ if (declared === "disabled-local" || declared === "disabled-public") {
287
+ if (config !== null) warn(`auth DISABLED via KSOR_AUTH=${declared} despite SSO config — UNAUTHENTICATED`);
288
+ return {
289
+ mode: "disabled",
290
+ publicAllowed: declared === "disabled-public"
291
+ };
288
292
  }
289
- if (config === null) throw new AuthConfigError("auth is not configured (KSOR_SSO_URL / KSOR_MCP_RESOURCE_URL unset) and KSOR_AUTH_DISABLED is not '1' refusing to boot unauthenticated. Set both SSO env vars, or set KSOR_AUTH_DISABLED=1 for a deliberate dev/unauthenticated run.");
293
+ if (declared !== "") throw new AuthConfigError(`KSOR_AUTH=${JSON.stringify(declared)} is not a posture. Use "disabled-local" (loopback dev run) or "disabled-public" (serves the whole record to anyone who can reach the port), or unset it and configure the SSO door.`);
294
+ if (config === null) throw new AuthConfigError("auth is not configured (KSOR_SSO_URL / KSOR_MCP_RESOURCE_URL unset) and KSOR_AUTH is unset — refusing to boot unauthenticated. Set both SSO env vars, or set KSOR_AUTH=disabled-local for a deliberate dev/unauthenticated run.");
290
295
  if (config.allowedAudiences.length === 0) throw new AuthConfigError("auth is ON but KSOR_JWT_ALLOWED_AUDIENCES is empty — set it to this server's MCP URL (fail-closed: an unset audience allowlist would accept any SSO-signed token).");
291
296
  let resolution = null;
292
297
  const jwks = () => {
@@ -540,10 +545,34 @@ function withoutSdkResponseModeWarning(body) {
540
545
  */
541
546
  function authPosture(mode, host, publicUnauthenticated) {
542
547
  if (mode !== "disabled") return "bearer tokens, verified against the record's authorization server";
543
- if (publicUnauthenticated) return `UNAUTHENTICATED and bound to ${host} — KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1 is set, so the whole record is served to anyone who can reach this port`;
548
+ if (publicUnauthenticated) return `UNAUTHENTICATED and bound to ${host} — KSOR_AUTH=disabled-public is set, so the whole record is served to anyone who can reach this port`;
544
549
  return `DISABLED — ${host} only, and a public bind will refuse to boot`;
545
550
  }
546
551
  /**
552
+ * Whether a generation pin will survive this deployment.
553
+ *
554
+ * Unset `KSOR_SNAPSHOT_KEYS` mints an EPHEMERAL per-process key. That is honest
555
+ * for one process — the code that chose it said so — but a container is not one
556
+ * process. On a scale-to-zero host every cold start mints a new key, so a
557
+ * snapshot token issued by one instance is unverifiable by the next and `read`
558
+ * silently drops to the ACTIVE generation, reporting `refreshed (invalid)`.
559
+ *
560
+ * Silently is the problem. It fails SOFT by design, so nothing errors and
561
+ * nothing logs; the only symptom is an agent reading a generation it did not
562
+ * search, which surfaces as roughly one read in three coming back unpinned.
563
+ * Found exactly that way on a real deployment, which is why this line exists —
564
+ * every other posture on this report announced itself and this one did not.
565
+ *
566
+ * Not a refusal: a loopback dev run and a genuine single-instance deployment
567
+ * are both legitimate, and neither is harmed. So the line appears only where
568
+ * the assumption actually stops holding — a public bind.
569
+ */
570
+ function snapshotPosture(activeKeyId, loopback) {
571
+ if (activeKeyId !== "ephemeral") return null;
572
+ if (loopback) return null;
573
+ return "EPHEMERAL key — generation pins will NOT survive a restart or a second instance; set KSOR_SNAPSHOT_KEYS to a value shared by every replica";
574
+ }
575
+ /**
547
576
  * What the record will refuse. `null` means no floor is declared and the gate
548
577
  * is off — which is honest, and is a correct level-0 state, but an agent
549
578
  * pointed at this door will get a confident cited answer to a question the
@@ -968,7 +997,7 @@ async function runHttp(composition) {
968
997
  const resourceMetadataUrl = auth.mode === "public" ? new URL("/.well-known/oauth-protected-resource/mcp", auth.config.resourceUrl).toString() : "";
969
998
  const bind = resolveBind(process.env);
970
999
  const loopback = bind.host === "127.0.0.1" || bind.host === "localhost" || bind.host === "::1";
971
- if (auth.mode === "disabled" && !loopback && process.env["KSOR_ALLOW_PUBLIC_UNAUTHENTICATED"] !== "1") throw new AuthConfigError(`refusing an UNAUTHENTICATED PUBLIC bind (${bind.host}) — KSOR_AUTH_DISABLED is the loopback-dev flag, not a licence to serve the corpus to the internet with no auth. Configure the SSO door (KSOR_SSO_URL + KSOR_MCP_RESOURCE_URL + KSOR_JWT_ALLOWED_AUDIENCES), bind loopback, or set KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1 to accept the risk deliberately.`);
1000
+ if (auth.mode === "disabled" && !loopback && !auth.publicAllowed) throw new AuthConfigError(`refusing an UNAUTHENTICATED PUBLIC bind (${bind.host}) — KSOR_AUTH=disabled-local is the loopback dev posture, not a licence to serve the corpus to the internet with no auth. Configure the SSO door (KSOR_SSO_URL + KSOR_MCP_RESOURCE_URL + KSOR_JWT_ALLOWED_AUDIENCES), bind loopback, or set KSOR_AUTH=disabled-public to accept the risk deliberately.`);
972
1001
  const security = resolveSecurity(bind);
973
1002
  const { ctx, instance, pool, spaceSkipReason, version, verifyBoot } = composition;
974
1003
  const maxBodyBytes = envInt$1(process.env, "KSOR_MAX_BODY_BYTES", 1e6, { minimum: 1024 });
@@ -1142,8 +1171,10 @@ async function runHttp(composition) {
1142
1171
  s.once("error", reject);
1143
1172
  });
1144
1173
  if (recordIsUndescribed(instance.instructions)) console.error(bootLine("identity", UNDESCRIBED_RECORD));
1145
- console.error(bootLine("auth", authPosture(auth.mode, bind.host, process.env["KSOR_ALLOW_PUBLIC_UNAUTHENTICATED"] === "1" && !loopback)));
1174
+ console.error(bootLine("auth", authPosture(auth.mode, bind.host, auth.mode === "disabled" && auth.publicAllowed && !loopback)));
1146
1175
  for (const line of keyLines) console.error(line);
1176
+ const snapshot = snapshotPosture(ctx.ring.active, loopback);
1177
+ if (snapshot !== null) console.error(bootLine("snapshot", snapshot));
1147
1178
  console.error(bootLine("abstain", abstainPosture(instance.abstain.vectorFloor)));
1148
1179
  console.error(bootLine("serving", `http://${bind.host}:${bind.port}/mcp`));
1149
1180
  let draining = false;
@@ -1181,7 +1212,7 @@ async function runHttp(composition) {
1181
1212
  * The bind decides the posture: unset PORT/host → loopback (127.0.0.1),
1182
1213
  * the dev door, DNS-rebind-protected and safe with auth off; a public bind
1183
1214
  * is a deliberate act that fails closed unless auth is configured or
1184
- * KSOR_AUTH_DISABLED=1 is set explicitly.
1215
+ * KSOR_AUTH=disabled-local is set explicitly.
1185
1216
  *
1186
1217
  * Env contract (each `KSOR_*` name descends from an oracle `SOR_*` var):
1187
1218
  * KSOR_INSTANCE path to instance.md (default ./instance.md)
@@ -1190,7 +1221,7 @@ async function runHttp(composition) {
1190
1221
  * KSOR_SNAPSHOT_KEYS kid=secret[,...]; unset = ephemeral key
1191
1222
  * KSOR_MCP_HOST / KSOR_MCP_PORT bind (loopback unless PORT — deliberate)
1192
1223
  * KSOR_SSO_URL + KSOR_MCP_RESOURCE_URL + KSOR_JWT_ALLOWED_AUDIENCES public door
1193
- * KSOR_AUTH_DISABLED=1 the deliberate unauthenticated opt-out
1224
+ * KSOR_AUTH=disabled-local the deliberate unauthenticated opt-out
1194
1225
  *
1195
1226
  * Exit codes follow the ksor CLI contract: refusal 1, environment 3 —
1196
1227
  * always a remedied message, never a stack trace.
@@ -1552,7 +1583,7 @@ async function withPgRetry(op, options = {}) {
1552
1583
  throw lastError;
1553
1584
  }
1554
1585
  //#endregion
1555
- //#region ../content/dist/commands-DU1nqwZa.mjs
1586
+ //#region ../content/dist/commands-CLUS9CX8.mjs
1556
1587
  /**
1557
1588
  * EVAL-LOCKED constants, quarried verbatim from the oracle
1558
1589
  * (sor-agentfactory @ b554f91, config.py) — changing any of these is a
@@ -4177,6 +4208,10 @@ const ATTACHMENT_SUFFIXES = [
4177
4208
  {
4178
4209
  suffix: ".flashcards.yaml",
4179
4210
  kind: "deck"
4211
+ },
4212
+ {
4213
+ suffix: ".quiz.yaml",
4214
+ kind: "quiz"
4180
4215
  }
4181
4216
  ];
4182
4217
  /**
@@ -5,7 +5,7 @@ import { z, z as z$1 } from "zod";
5
5
  import pg from "pg";
6
6
  import path from "node:path";
7
7
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
8
- //#region ../content-gateway/dist/gateway-api-8lNruq9e.mjs
8
+ //#region ../content-gateway/dist/gateway-api-CbFkHZiU.mjs
9
9
  /**
10
10
  * The record's system prompt: instance.md's body, preserved beneath a framework
11
11
  * floor. Lifted out of server.ts so the public gateway surface can re-export
package/dist/gateway.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { L as z$1, O as readHandler, a as MAX_OUTLINE_LIMIT, d as READ_OUTPUT, f as SEARCH_OUTPUT, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, o as MAX_SEARCH_K, r as FLOOR, s as McpServer$1, u as READ_ONLY, w as outlineHandler, y as composeInstructions } from "./gateway-api-8lNruq9e-CuohjtoK.mjs";
1
+ import { L as z$1, O as readHandler, a as MAX_OUTLINE_LIMIT, d as READ_OUTPUT, f as SEARCH_OUTPUT, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, o as MAX_SEARCH_K, r as FLOOR, s as McpServer$1, u as READ_ONLY, w as outlineHandler, y as composeInstructions } from "./gateway-api-CbFkHZiU-HvlJRjRB.mjs";
2
2
  export { FLOOR, MAX_OUTLINE_LIMIT, MAX_SEARCH_K, McpServer$1 as McpServer, OUTLINE_OUTPUT, READ_ONLY, READ_OUTPUT, SEARCH_OUTPUT, composeInstructions, outlineHandler, readHandler, recordIsUndescribed, searchHandler, z$1 as z };
@@ -189,7 +189,7 @@ re-admitted the instant the key set catches up.
189
189
 
190
190
  ## Before a public bind
191
191
 
192
- - Auth configured as above, **or** `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` set
192
+ - Auth configured as above, **or** `KSOR_AUTH=disabled-public` set
193
193
  deliberately — the door will not come up on a public address without one of
194
194
  them, and the second is a decision, not a default.
195
195
  - `KSOR_ALLOWED_HOSTS` set to the host you serve on.
package/docs/deploying.md CHANGED
@@ -103,30 +103,81 @@ setting came out.
103
103
 
104
104
  ## Configuration
105
105
 
106
- The door takes everything from the environment. Set these on the deployment, not
107
- in a file:
108
-
109
- | variable | why |
110
- | ----------------------- | ------------------------------------------------------------------------ |
111
- | `KSOR_DB_URL` | the record's Postgres store |
112
- | `GEMINI_API_KEY` | embeds the incoming query, so retrieval works at all |
113
- | `KSOR_SNAPSHOT_KEYS` | `kid=secret` **required in practice**, see below |
114
- | `KSOR_ALLOWED_HOSTS` | the host you serve on (DNS-rebind defence) |
115
- | `KSOR_MCP_RESOURCE_URL` | this record's canonical URL, e.g. `https://your-host/mcp` |
116
- | auth, one of two | `KSOR_SSO_URL` + audiences, **or** `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` |
117
-
118
- `KSOR_SNAPSHOT_KEYS` is listed as a production knob but behaves as a
119
- requirement on any host that scales to zero. Unset, the signing key is generated
120
- **per process** so a citation minted before a scale-down stops validating
121
- after it, with a single instance and no replicas involved. Generate one:
106
+ Three tiers, because listing them as one table reads as "set all of these or you
107
+ are doing it wrong" — and only the first tier is true.
108
+
109
+ ### Required — the door will not boot without these
110
+
111
+ | variable | why |
112
+ | ----------------------------------------- | ---------------------------------------------------- |
113
+ | `KSOR_DB_URL` | the record's Postgres store |
114
+ | `GEMINI_API_KEY` | embeds the incoming query, so retrieval works at all |
115
+ | `KSOR_AUTH`, **or** a configured SSO door | see below |
116
+
117
+ `KSOR_AUTH` takes one of two values, and the value IS the decision:
118
+
119
+ ```sh
120
+ KSOR_AUTH=disabled-local # no auth, loopback only a public bind REFUSES
121
+ KSOR_AUTH=disabled-public # no auth, and served to anyone who can reach the port
122
+ ```
123
+
124
+ **A container sets `$PORT`, so the door binds `0.0.0.0` — a public bind.**
125
+ `disabled-local` refuses there, deliberately: copying a dev `.env` into a hosting
126
+ dashboard must not quietly open your record to the internet. `disabled-public` is
127
+ correct for a genuinely public record or one behind your own gateway; it is not a
128
+ way to make a deploy go green.
129
+
130
+ The alternative is a real authorization server — `KSOR_SSO_URL`,
131
+ `KSOR_MCP_RESOURCE_URL`, `KSOR_JWT_ALLOWED_AUDIENCES`, with worked recipes for two
132
+ of them in [authorization.md](./authorization.md).
133
+
134
+ ### Set this on any container host
135
+
136
+ | variable | why |
137
+ | -------------------- | ---------------------------------------- |
138
+ | `KSOR_SNAPSHOT_KEYS` | `kid=secret`, identical on every replica |
139
+
140
+ Unset mints an **ephemeral per-process key**. A `search` hands back a snapshot
141
+ token pinning the generation it answered from; `read` honours that pin so a
142
+ conversation stays on one version of the record. With a per-process key, a token
143
+ minted by one instance is unverifiable by the next — so `read` silently drops to
144
+ the ACTIVE generation and reports `refreshed (invalid)`.
145
+
146
+ It fails **soft**, so nothing errors and nothing logs. The only symptom is an
147
+ agent reading a generation it did not search, seen as roughly one read in three
148
+ coming back unpinned. Generate one with `openssl rand -hex 32`:
122
149
 
123
150
  ```sh
124
151
  KSOR_SNAPSHOT_KEYS="k1=$(openssl rand -hex 32)"
125
152
  ```
126
153
 
127
- The value is `kid=secret`, not `kid:secret`, and the first entry is the active
128
- one. Multiple entries let you rotate without invalidating outstanding
129
- citations.
154
+ `k1` is a label, not a secret it appears in the token as `key_id` so you can
155
+ rotate later. The secret is used as literal text, never hex-decoded, and must be
156
+ byte-identical across every instance of one deployment. Set it once and leave it
157
+ alone: rotating invalidates every outstanding pin, and a compromised snapshot key
158
+ cannot read a withdrawn document, cross an audience boundary, or authenticate
159
+ anything.
160
+
161
+ The boot report now says so out loud when it matters:
162
+
163
+ ```
164
+ snapshot EPHEMERAL key — generation pins will NOT survive a restart or a
165
+ second instance; set KSOR_SNAPSHOT_KEYS to a value shared by every replica
166
+ ```
167
+
168
+ ### Set these once auth is ON
169
+
170
+ | variable | why |
171
+ | ---------------------- | ------------------------------------------ |
172
+ | `KSOR_ALLOWED_HOSTS` | Host allow-list — DNS-rebind defence |
173
+ | `KSOR_ALLOWED_ORIGINS` | browser Origin allow-list |
174
+ | `KSOR_SSO_ISSUER` | one more check per token, for one variable |
175
+
176
+ Unset, Host validation is simply off on a public bind — permissive, not refusing,
177
+ so it is never the reason a deploy fails. It earns its place when auth is on:
178
+ rebinding is worth an attacker's effort only when it reaches something they could
179
+ not reach directly. With `KSOR_AUTH=disabled-public` the record is already served
180
+ to anyone who types the URL, so there is nothing for rebinding to steal.
130
181
 
131
182
  ### The site build needs the DSN too
132
183
 
@@ -154,7 +205,7 @@ ways past it:
154
205
  - **Configure the SSO door** — `KSOR_SSO_URL`, `KSOR_MCP_RESOURCE_URL`,
155
206
  `KSOR_JWT_ALLOWED_AUDIENCES`. Worked recipes for two different authorization
156
207
  servers: [authorization.md](./authorization.md).
157
- - **Set `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1`** — a deliberate decision that
208
+ - **Set `KSOR_AUTH=disabled-public`** — a deliberate decision that
158
209
  serves your whole record to anyone who can reach the port. Correct for a
159
210
  genuinely public record, or behind your own gateway. Never as a way to get a
160
211
  deploy green.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
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",
@@ -41,7 +41,7 @@ const ASSET_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".web
41
41
  // rules below. This mirrors packages/content/src/lib/attachment-rule.ts, which
42
42
  // this dependency-free file cannot import; ATTACHMENT_CASES there is the table
43
43
  // both are held to.
44
- const ATTACHMENT_SUFFIXES = [".summary.md", ".summary.mdx", ".flashcards.yaml"];
44
+ const ATTACHMENT_SUFFIXES = [".summary.md", ".summary.mdx", ".flashcards.yaml", ".quiz.yaml"];
45
45
  // One character off a real attachment, refused BY NAME: `.yml` reaches the
46
46
  // site bundler's `Unknown file type` throw, which names the path and nothing
47
47
  // about the rule.
@@ -49,6 +49,8 @@ const ATTACHMENT_NEAR_MISSES = [
49
49
  [".flashcards.yml", ".flashcards.yaml"],
50
50
  [".flashcards.json", ".flashcards.yaml"],
51
51
  [".summary.markdown", ".summary.md"],
52
+ [".quiz.yml", ".quiz.yaml"],
53
+ [".quiz.json", ".quiz.yaml"],
52
54
  ];
53
55
 
54
56
  /** The attachment suffix this name carries, or null. A dotfile has no stem. */
@@ -41,7 +41,7 @@ const ASSET_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".web
41
41
  // rules below. This mirrors packages/content/src/lib/attachment-rule.ts, which
42
42
  // this dependency-free file cannot import; ATTACHMENT_CASES there is the table
43
43
  // both are held to.
44
- const ATTACHMENT_SUFFIXES = [".summary.md", ".summary.mdx", ".flashcards.yaml"];
44
+ const ATTACHMENT_SUFFIXES = [".summary.md", ".summary.mdx", ".flashcards.yaml", ".quiz.yaml"];
45
45
  // One character off a real attachment, refused BY NAME: `.yml` reaches the
46
46
  // site bundler's `Unknown file type` throw, which names the path and nothing
47
47
  // about the rule.
@@ -49,6 +49,8 @@ const ATTACHMENT_NEAR_MISSES = [
49
49
  [".flashcards.yml", ".flashcards.yaml"],
50
50
  [".flashcards.json", ".flashcards.yaml"],
51
51
  [".summary.markdown", ".summary.md"],
52
+ [".quiz.yml", ".quiz.yaml"],
53
+ [".quiz.json", ".quiz.yaml"],
52
54
  ];
53
55
 
54
56
  /** The attachment suffix this name carries, or null. A dotfile has no stem. */
@@ -96,7 +96,7 @@ Stand it up in this order (each step's errors explain how to fix themselves):
96
96
  - `KSOR_DB_URL` — the Postgres store named by `instance.md`'s `dsn_env`. It
97
97
  needs the pgvector extension: `CREATE EXTENSION vector;`
98
98
  - `GEMINI_API_KEY` — the embedding provider key.
99
- - `KSOR_AUTH_DISABLED=1` — **required for a local run.** `ksor serve`
99
+ - `KSOR_AUTH=disabled-local` — **required for a local run.** `ksor serve`
100
100
  refuses to boot unauthenticated without it, deliberately, so a server is
101
101
  never left open by accident. It binds loopback, where auth off is the
102
102
  intended dev shape. A PUBLIC deployment configures the SSO door instead —
@@ -228,11 +228,11 @@ abandoned ones.
228
228
  ### Serving safely (fail-closed posture)
229
229
 
230
230
  `pnpm serve` **refuses to boot unauthenticated** — there is no auth-off
231
- default. A local run says so deliberately with `KSOR_AUTH_DISABLED=1` and binds
231
+ default. A local run says so deliberately with `KSOR_AUTH=disabled-local` and binds
232
232
  loopback, which is the intended dev shape. A **public**
233
233
  bind refuses to boot unless auth is configured (`KSOR_SSO_URL` +
234
234
  `KSOR_MCP_RESOURCE_URL` + `KSOR_JWT_ALLOWED_AUDIENCES`, making it an OAuth
235
- Resource Server) OR you deliberately set `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1`.
235
+ Resource Server) OR you deliberately set `KSOR_AUTH=disabled-public`.
236
236
  Never let a dropped auth variable silently ship an open door. On a non-loopback
237
237
  bind, set `KSOR_ALLOWED_HOSTS` / `KSOR_ALLOWED_ORIGINS`; on more than one
238
238
  replica, set a shared `KSOR_SNAPSHOT_KEYS` (unset ⇒ a per-process key, so a
@@ -240,7 +240,7 @@ search token minted by one replica fails on another).
240
240
 
241
241
  Three things worth being deliberate about:
242
242
 
243
- - **`KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` serves your whole record to anyone
243
+ - **`KSOR_AUTH=disabled-public` serves your whole record to anyone
244
244
  who can reach the port.** It exists for deployments fronted by your own
245
245
  gateway or network policy. If nothing else is in front, do not set it.
246
246
  - **Set `KSOR_SSO_ISSUER` when your SSO stamps a stable `iss`.** Audience is
@@ -261,7 +261,7 @@ Once the SSO door is configured (the three variables above), the server is an
261
261
  OAuth **Resource Server**, which means a client is not told the authorization
262
262
  server — it discovers it. Nothing here needs configuring beyond those variables;
263
263
  this is what your agents will experience, and what to check when one cannot
264
- connect. With `KSOR_AUTH_DISABLED=1` — the local default `.env.example` ships —
264
+ connect. With `KSOR_AUTH=disabled-local` — the local default `.env.example` ships —
265
265
  none of it applies: there is no challenge and the metadata document answers 404,
266
266
  because there is no authorization server to point at.
267
267
 
@@ -506,11 +506,12 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
506
506
  refused.
507
507
  - Images and assets live in `knowledge/` beside the document that uses them,
508
508
  referenced by relative links. A relative link must never leave `knowledge/`.
509
- - **Study attachments.** A document may carry two optional companions named
510
- after it, in the same folder: `<doc>.summary.md` (a short précis) and
511
- `<doc>.flashcards.yaml` (a recall deck). The summary appears as a second tab
512
- beside the document's own words; the deck appears at the END of that
513
- document's page. Both appear nowhere else in the site.
509
+ - **Study attachments.** A document may carry three optional companions named
510
+ after it, in the same folder: `<doc>.summary.md` (a short précis),
511
+ `<doc>.flashcards.yaml` (a recall deck) and `<doc>.quiz.yaml` (a
512
+ multiple-choice check). The summary appears as a second tab beside the
513
+ document's own words; the deck and the quiz appear at the END of that
514
+ document's page. None of them appears anywhere else in the site.
514
515
 
515
516
  An attachment is **part of its document**, not a document. It has no URL of
516
517
  its own, no sidebar row, no line in `llms.txt`, and no identity an agent can
@@ -549,6 +550,51 @@ CI — and a first deploy without it serves an empty record. Full walkthrough:
549
550
  kept in the reader's own browser, so it is per-person and per-device, and it
550
551
  is not part of the record.
551
552
 
553
+ A **quiz** is the same idea with an answer to choose. Each question carries
554
+ two to six options, `answer` as the zero-based index of the correct one, a
555
+ required `explanation`, and an optional `source` naming where in the document
556
+ the answer lives:
557
+
558
+ ```yaml
559
+ quiz:
560
+ title: Expense approvals
561
+ questionsPerRound: 10 # optional; the default
562
+ questions:
563
+ - question: Who approves a purchase above the threshold?
564
+ options:
565
+ - A second approver, independent of the requester
566
+ - The requester's own manager, in every case
567
+ - The finance team, after the purchase completes
568
+ answer: 0
569
+ explanation: Why that is right, and why the others are not.
570
+ source: Approvals — thresholds
571
+ ```
572
+
573
+ The reader sees whether they were right **immediately**, then the
574
+ explanation — a quiz here teaches through the mistake, and it does not
575
+ certify anybody, so there is no pass mark. Answers stay in the reader's
576
+ browser and are sent nowhere. A bank larger than `questionsPerRound` is drawn
577
+ from at random and offers another round; a smaller one is simply asked in
578
+ full.
579
+
580
+ **`pnpm check` and `pnpm build` both refuse a quiz a reader could pass
581
+ without reading**, and this is worth knowing before you write one, because
582
+ it is easy to trip by accident:
583
+
584
+ | Refusal | Means |
585
+ | -------------------------- | ----------------------------------------------------- |
586
+ | `ksor-quiz-answer-bias` | more than 60% of answers sit at one option position |
587
+ | `ksor-quiz-length-bias` | picking the longest (or shortest) option usually wins |
588
+ | `ksor-quiz-answer-run` | four or more questions in a row share an answer |
589
+ | `ksor-quiz-contradiction` | an explanation calls the marked answer wrong |
590
+ | `ksor-quiz-duplicate-stem` | two questions open with the same 60 characters |
591
+
592
+ So: move the correct answer around, and keep the options close in length.
593
+ The ratio rules do not apply below five questions, where spreading answers
594
+ would mean the checker choosing them for you. These checks come from the
595
+ predecessor, where the same mistakes shipped and were found by readers — one
596
+ quiz had every correct answer in the same position across 451 questions.
597
+
552
598
  - Copy load-bearing values (numbers, thresholds, dates) exactly from their
553
599
  source, and name the source in `provenance`.
554
600
 
@@ -31,13 +31,13 @@ Postgres store (with pgvector) and an embedding provider key, so it is not
31
31
  part of `pnpm dev`. The ordered path is:
32
32
 
33
33
  ```sh
34
- cp .env.example .env # fill in KSOR_DB_URL, GEMINI_API_KEY, KSOR_AUTH_DISABLED=1
34
+ cp .env.example .env # fill in KSOR_DB_URL, GEMINI_API_KEY, KSOR_AUTH=disabled-local
35
35
  pnpm provision # once: apply the schema, authorize ingest
36
36
  pnpm refresh # ingest the record, collect retired generations
37
37
  pnpm serve # the MCP server
38
38
  ```
39
39
 
40
- `ksor` reads `.env` automatically — nothing to export. `KSOR_AUTH_DISABLED=1`
40
+ `ksor` reads `.env` automatically — nothing to export. `KSOR_AUTH=disabled-local`
41
41
  is required for a local run: serve refuses to boot unauthenticated on purpose,
42
42
  so a server is never open by accident.
43
43
 
@@ -56,7 +56,7 @@ effect of starting a process. A rerun on an unchanged record
56
56
  costs nothing: no new generation, no embedding, no rows. Edit a document and
57
57
  the next run picks up exactly that change. `AGENTS.md` → "Serving to agents" is the
58
58
  full runbook; your coding agent reads it first. `pnpm serve` refuses to boot
59
- unauthenticated: a local run declares `KSOR_AUTH_DISABLED=1` (already in
59
+ unauthenticated: a local run declares `KSOR_AUTH=disabled-local` (already in
60
60
  `.env.example`) and binds loopback, so a server is never left open by accident;
61
61
  a public bind needs a configured SSO door instead. Any other operation is
62
62
  `pnpm exec ksor <verb>`.
@@ -12,11 +12,23 @@ KSOR_DB_URL=postgresql://user:password@host:5432/dbname
12
12
  # The embedding provider key. instance.md defaults to gemini-embedding-001.
13
13
  GEMINI_API_KEY=
14
14
 
15
- # Local development posture. `ksor serve` REFUSES to boot unauthenticated
16
- # without this — deliberately, so a server is never open by accident. It binds
17
- # loopback, where auth off is the intended dev shape.
15
+ # ── Who may ask ─────────────────────────────────────────────────────────────
16
+ # ONE variable, and its VALUE is the decision. `ksor serve` refuses to boot
17
+ # without either this or a configured SSO door — a server is never open by
18
+ # accident.
18
19
  #
19
- # For a PUBLIC deployment, delete this line and configure the SSO door instead:
20
+ # disabled-local no auth, loopback only. A PUBLIC bind REFUSES, so copying
21
+ # this file into a hosting dashboard cannot quietly open your
22
+ # record to the internet. This is the dev posture.
23
+ # disabled-public no auth, and serve the whole record to anyone who can reach
24
+ # the port. A deliberate choice, correct for a genuinely
25
+ # public record or behind your own gateway. NOT a way to make
26
+ # a deploy go green.
27
+ #
28
+ # A container sets $PORT, so the door binds 0.0.0.0 — that is a public bind, and
29
+ # `disabled-local` will refuse there. That refusal is the point.
30
+ #
31
+ # For a real deployment, delete this line and configure the SSO door instead:
20
32
  # KSOR_SSO_URL=https://your-sso.example.com
21
33
  # KSOR_MCP_RESOURCE_URL=https://your-host.example.com/mcp
22
34
  # KSOR_JWT_ALLOWED_AUDIENCES=https://your-host.example.com/mcp
@@ -27,15 +39,19 @@ GEMINI_API_KEY=
27
39
  # answered and where the keys came from.
28
40
  # Set this only to override discovery, or when your SSO publishes no metadata:
29
41
  # KSOR_JWKS_URL=https://your-sso.example.com/.well-known/jwks.json
30
- # Serving a public bind with auth off additionally requires
31
- # KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1, which serves your whole record to anyone
32
- # who can reach the port.
33
- KSOR_AUTH_DISABLED=1
42
+ KSOR_AUTH=disabled-local
34
43
 
35
44
  # ── Production knobs ────────────────────────────────────────────────────────
36
45
  # Unset is fine for a local run; each one matters once this serves for real.
37
46
 
38
47
  # Snapshot-token signing keys, "kid=secret[,kid2=secret2]" (first is active).
48
+ # SET THIS ON ANY CONTAINER HOST. Unset mints an EPHEMERAL per-process key, so a
49
+ # generation pin issued by one instance is unverifiable by the next: `read`
50
+ # silently drops to the active generation and reports "refreshed (invalid)".
51
+ # It fails SOFT, so nothing errors and nothing logs — the only symptom is an
52
+ # agent reading a generation it did not search. Generate one with
53
+ # `openssl rand -hex 32`; the secret is used as literal text, never hex-decoded,
54
+ # and must be IDENTICAL across every replica.
39
55
  # WITHOUT this the key is generated per PROCESS. Two deployments need it, not
40
56
  # just one: any MULTI-REPLICA deploy (a token minted by one replica is refused
41
57
  # by another), and any SCALE-TO-ZERO host, where a single replica mints a new