@panaversity/ksor 0.0.10 → 0.0.11

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,116 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.11
4
+
5
+ ### Patch Changes
6
+
7
+ - 0a0dd27: A record describes itself on the surface agents discover it through
8
+
9
+ `/.well-known/mcp/server.json` carried one hard-coded sentence — "The <name>
10
+ Knowledge System of Record: governed markdown served with citations and honest
11
+ abstention." — byte-identical in every ksor record ever scaffolded. An agent
12
+ choosing between records in a registry learned nothing from any of them, which
13
+ is the opposite of what that document exists for.
14
+
15
+ The description now comes from the record's own prose: its display title and the
16
+ first real sentence of `instance.md`, which is what the intake interview writes.
17
+ A record whose owner has not described it yet SAYS so rather than borrowing a
18
+ confident sentence it has not earned — the same answer the MCP door already
19
+ gives an agent that connects, so the two surfaces do not disagree about whether
20
+ this record knows what it is.
21
+
22
+ The scaffold's opening paragraphs are authoring guidance, not scope, so the
23
+ template is detected across the whole body rather than paragraph by paragraph:
24
+ publishing instructions-to-the-author as a description would be worse than
25
+ admitting there is none.
26
+
27
+ - 0fe759d: Three defects found by auditing 0.0.10 against a live record
28
+
29
+ **A repeated `sslmode` was read the wrong end.** `pinnedTlsDsn` took the FIRST
30
+ value of a repeated parameter; `pg` takes the LAST. So on
31
+ `?sslmode=require&sslmode=disable` — whose effective mode is `disable` — the pin
32
+ saw a weak mode, collapsed the duplicates into one `verify-full`, turned TLS on,
33
+ and printed "TLS verified" at an operator whose DSN ended in `disable`. The
34
+ direction was safe; silently overruling an explicit opt-out and then misreporting
35
+ it was not. All three TLS functions now read the mode the driver will use.
36
+
37
+ The same sweep asserted the larger worry the pin creates — that re-serializing a
38
+ connection string could alter a credential. Seventeen DSNs with the passwords
39
+ people actually paste (raw `@`, spaces, `%`, `+`, brackets, non-ASCII,
40
+ percent-encoded separators) are now checked field by field against `pg`'s own
41
+ resolved view: everything the driver derives is byte-identical, and so is the
42
+ TLS decision.
43
+
44
+ **The outline's `position` disclosed documents an audience may not see.** It was
45
+ the rank in the whole record, so a public caller received 1, 3, 4 — a gap exactly
46
+ where an internal sibling sat, telling them something exists and roughly where.
47
+ The same row's `child_count` was already computed over visible children only, so
48
+ one response object disagreed with itself. `position` is now the rank among the
49
+ siblings the caller can see, computed as a window over the filtered set so it
50
+ stays correct across pages and at every depth, and both it and `depth` say what
51
+ they are in the tool schema.
52
+
53
+ **`ksor serve` now says when the record has no identity yet.** The MCP door
54
+ already refused to pass an unedited `instance.md` to agents as instructions —
55
+ it substitutes a plain statement that the scope is unstated — but the operator
56
+ starting the server was told nothing, so a record serving with no declared
57
+ identity looked exactly like one that had been described. It is a boot line now,
58
+ beside the abstention posture: both answer "how much should I trust this".
59
+
60
+ - f5cd885: The bearer door's key line joins the boot block instead of interrupting it
61
+
62
+ In bearer mode the line naming where the signing keys were discovered printed
63
+ before the aligned posture block and in a different shape, so it read as a stray
64
+ log line rather than as part of what the server was telling you about itself. It
65
+ is a `keys` row in the block now, under `auth`, resolved at boot exactly as
66
+ before.
67
+
68
+ - 5f30b5f: The site build no longer fails when two evaluations of the record staging overlap
69
+
70
+ The scaffold stages a per-audience copy of the record before the site build
71
+ reads it, removing the previous stage first. `rmSync(..., { force: true })`
72
+ suppresses ENOENT but retries nothing: Node retries EBUSY / EMFILE / ENFILE /
73
+ ENOTEMPTY / EPERM only when `maxRetries` is set, and it defaults to zero. The
74
+ bundler evaluates the source config more than once when it wants it in more than
75
+ one place, so one run could remove the stage while another was still copying
76
+ into it — surfacing as `ENOTEMPTY` and failing the entire site build (seen once
77
+ in CI, 2026-08-21).
78
+
79
+ The removal now asks for those retries. Losing that race is safe: the stage is a
80
+ deterministic function of the record and the denylist, so redoing it produces
81
+ the same bytes.
82
+
83
+ Three claims in the scaffold's `AGENTS.md` that recent releases made false are
84
+ also corrected: `--actor` no longer "defaults to the operating user" (it is
85
+ required, and there is no default by design); the signing keys are discovered
86
+ from the SSO's own metadata rather than fetched from Better Auth's path; and the
87
+ `order:` key now drives the MCP `outline` tool alongside the sidebar and
88
+ `llms.txt`, which is what "one order drives every surface" was always supposed
89
+ to mean.
90
+
91
+ - 4a1c154: The shrink guard guards `ksor ingest --flip` again — it had stopped
92
+
93
+ `.env.example` documents `KSOR_MAX_SHRINK` as "a corpus that shrinks by more
94
+ than this FRACTION refuses to flip". In 0.0.10 it did not. Deleting eight of ten
95
+ documents and running `ksor ingest --flip` published the two that were left,
96
+ silently, exit 0.
97
+
98
+ The cause was the fix that stopped a refused ingest from publishing. That moved
99
+ the flip out of `buildGeneration` and into the command, so the governance gate
100
+ could run against the new generation BEFORE it became the active one — and the
101
+ shrink check, which lived inside the build's flip branch, was stepped straight
102
+ over. The library test that covers the guard stayed green throughout, because it
103
+ drives `buildGeneration` directly with `flip: true`, which is no longer the path
104
+ the CLI takes.
105
+
106
+ There is now one answer to "may this generation be activated" — `flipRefusal` —
107
+ and both flip paths ask it, in the same transaction as the flip itself. The new
108
+ test drives the command rather than the library, so a guard that only one of two
109
+ paths performs fails the tier that proves it.
110
+
111
+ Verified against a live record: a 10 → 2 node build now names all eight removed
112
+ documents, refuses with exit 1, and leaves the previous generation serving.
113
+
3
114
  ## 0.0.10
4
115
 
5
116
  ### Patch Changes
package/dist/cli.mjs CHANGED
@@ -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-Mjgb3l_o.mjs
19
+ //#region ../content-gateway/dist/main-3DN4fOiE.mjs
20
20
  /**
21
21
  * A connection could not be ESTABLISHED in time — retryable.
22
22
  *
@@ -93,6 +93,20 @@ function isLoopbackHost$1(hostname) {
93
93
  return host === "" || host === "localhost" || host === "127.0.0.1" || host === "::1";
94
94
  }
95
95
  /**
96
+ * The sslmode the DRIVER will use, which is the LAST one written.
97
+ *
98
+ * `URLSearchParams.get` returns the FIRST value; `pg` takes the last. On
99
+ * `?sslmode=require&sslmode=disable` those disagree, and reading the first made
100
+ * the pin treat an explicitly disabled connection as a weak one — collapsing the
101
+ * duplicates into a single `verify-full`, turning TLS on, and printing "verified"
102
+ * at an operator whose DSN ended in `disable`. The direction was safe; silently
103
+ * overruling an explicit opt-out and then misreporting it is not (found by
104
+ * sweeping the driver's own parser, 2026-08-21).
105
+ */
106
+ function effectiveSslMode$1(url) {
107
+ return (url.searchParams.getAll("sslmode").at(-1) ?? "").toLowerCase();
108
+ }
109
+ /**
96
110
  * The DSN ksor actually connects with — the weak sslmode SPELLED OUT.
97
111
  *
98
112
  * pg 8 treats `sslmode=require|prefer|verify-ca` as aliases for `verify-full`,
@@ -116,8 +130,7 @@ function pinnedTlsDsn$1(dsn) {
116
130
  return dsn;
117
131
  }
118
132
  if (isLoopbackHost$1(url.hostname)) return dsn;
119
- const mode = (url.searchParams.get("sslmode") ?? "").toLowerCase();
120
- if (!WEAK_SSLMODES$1.includes(mode)) return dsn;
133
+ if (!WEAK_SSLMODES$1.includes(effectiveSslMode$1(url))) return dsn;
121
134
  url.searchParams.set("sslmode", "verify-full");
122
135
  return url.toString();
123
136
  }
@@ -133,7 +146,7 @@ function tlsPosture(dsn) {
133
146
  return null;
134
147
  }
135
148
  if (isLoopbackHost$1(url.hostname)) return null;
136
- const mode = (url.searchParams.get("sslmode") ?? "").toLowerCase();
149
+ const mode = effectiveSslMode$1(url);
137
150
  if (mode === "disable") return "TLS off (sslmode=disable)";
138
151
  if (mode === "no-verify") return "TLS UNVERIFIED (sslmode=no-verify)";
139
152
  if (WEAK_SSLMODES$1.includes(mode)) return `TLS verified (sslmode=${mode} pinned to verify-full)`;
@@ -189,7 +202,7 @@ function tlsOptionsFor$1(dsn) {
189
202
  return;
190
203
  }
191
204
  if (isLoopbackHost$1(url.hostname)) return void 0;
192
- const mode = (url.searchParams.get("sslmode") ?? "").toLowerCase();
205
+ const mode = effectiveSslMode$1(url);
193
206
  if (mode === "disable" || mode === "no-verify") return void 0;
194
207
  return { rejectUnauthorized: true };
195
208
  }
@@ -2058,7 +2071,22 @@ walk AS (
2058
2071
  -- gates each row on its OWN visibility (round-9 review of PR 43).
2059
2072
  WHERE n.tenant_id = $1 AND n.status = 'published' AND w.depth < $5
2060
2073
  )
2061
- SELECT w.slug, w.kind, w.title, w.heading_path, w.position, w.depth,
2074
+ -- The rank among the siblings THIS CALLER CAN SEE, not the stored one.
2075
+ --
2076
+ -- content_nodes.position is the rank in the whole record, so a tier that
2077
+ -- cannot see a sibling saw a GAP where it sat -- 1, 3, 4 -- which discloses
2078
+ -- that a document exists and roughly where, to a caller the record refuses to
2079
+ -- show it to. The same row's child_count was already computed over visible
2080
+ -- children only, so one response object disagreed with itself about whether
2081
+ -- hidden siblings are disclosed (found live 2026-08-21).
2082
+ --
2083
+ -- Computed as a WINDOW over the filtered set: window functions run after WHERE
2084
+ -- and before LIMIT/OFFSET, so the rank is the true visible sibling rank on
2085
+ -- every page and at every depth. Doing it in JS would have to renumber a page
2086
+ -- at a time -- which is how this query already produced two paging defects.
2087
+ SELECT w.slug, w.kind, w.title, w.heading_path,
2088
+ row_number() OVER (PARTITION BY w.parent_id ORDER BY w.sort_key)::int AS position,
2089
+ w.depth,
2062
2090
  (SELECT count(*) FROM content_nodes ch
2063
2091
  WHERE ch.tenant_id = $1 AND ch.generation = w.generation
2064
2092
  AND ch.parent_id = w.node_id AND ch.status = 'published'
@@ -2903,9 +2931,23 @@ const FRAMEWORK_INSTRUCTIONS = `You are answering from a Knowledge System of Rec
2903
2931
  * replaced it with "has not yet been described" (review of PR #43).
2904
2932
  */
2905
2933
  const TEMPLATE_MARKER = "_fill this in; it is";
2934
+ /**
2935
+ * Has the owner said what this record is FOR yet?
2936
+ *
2937
+ * The MCP door already answers honestly when they have not — it replaces the
2938
+ * template with a plain statement that the scope is unstated. But the operator
2939
+ * starting the server was told nothing, so a record serving with no declared
2940
+ * identity looked exactly like one that had been described. The boot report is
2941
+ * where that belongs, beside the abstention posture: both are answers to "how
2942
+ * much should I trust what this thing says".
2943
+ */
2944
+ function recordIsUndescribed(authored) {
2945
+ const body = authored.trim();
2946
+ return body === "" || body.includes(TEMPLATE_MARKER);
2947
+ }
2906
2948
  function composeInstructions(authored) {
2907
2949
  const body = authored.trim();
2908
- return body === "" || body.includes(TEMPLATE_MARKER) ? `${FRAMEWORK_INSTRUCTIONS}
2950
+ return recordIsUndescribed(authored) ? `${FRAMEWORK_INSTRUCTIONS}
2909
2951
 
2910
2952
  (This record has not yet been described by its owner — instance.md still carries the scaffold template. Treat its scope as unstated.)` : `${FRAMEWORK_INSTRUCTIONS}
2911
2953
 
@@ -2955,8 +2997,8 @@ const OUTLINE_OUTPUT = z.object({
2955
2997
  kind: z.string(),
2956
2998
  title: z.string(),
2957
2999
  heading_path: z.string(),
2958
- position: z.number().int(),
2959
- depth: z.number().int(),
3000
+ position: z.number().int().describe("Rank among the siblings YOU can see, from 1. Rows already arrive in reading order, so this is for citing a place, not for sorting."),
3001
+ depth: z.number().int().describe("Levels below the record's root, so rows are self-locating."),
2960
3002
  child_count: z.number().int(),
2961
3003
  permalink: z.string().nullable().describe("The page a person can open, when the record publishes one; null otherwise."),
2962
3004
  has_content: z.boolean()
@@ -3568,6 +3610,16 @@ function abstainPosture(floor) {
3568
3610
  return `floor ${floor} — below it, this record abstains`;
3569
3611
  }
3570
3612
  /**
3613
+ * What the boot report says when instance.md is still the scaffold template.
3614
+ *
3615
+ * Not a scolding: a level-0 record is allowed to be undescribed and this is not
3616
+ * an error. It is stated because the instance.md body IS the agent surface's
3617
+ * system prompt, so leaving it unwritten is a decision with a runtime effect —
3618
+ * every agent is told this record's scope is unstated — and an operator should
3619
+ * learn that from the server rather than from an agent's answer.
3620
+ */
3621
+ const UNDESCRIBED_RECORD = "instance.md is still the scaffold template — agents are told this record's scope is unstated; run the intake interview to describe it";
3622
+ /**
3571
3623
  * Composition (oracle main.py's boot order, adapted): instance → DSN via
3572
3624
  * the declared env NAME → provider → pool → space guard → service context.
3573
3625
  * Auth is built by the door that needs it (http.ts) — BEFORE the pool
@@ -3736,10 +3788,11 @@ function resolveSecurity(bind) {
3736
3788
  }
3737
3789
  async function runHttp(composition) {
3738
3790
  const auth = buildAuth(process.env);
3791
+ const keyLines = [];
3739
3792
  if (auth.mode === "public") {
3740
3793
  const keys = await auth.jwks();
3741
- console.error(`auth: signing keys via ${keys.source} — ${keys.url}`);
3742
- if (keys.advisory !== null) console.error(keys.advisory);
3794
+ keyLines.push(bootLine("keys", `${keys.source} — ${keys.url}`));
3795
+ if (keys.advisory !== null) keyLines.push(bootLine("", keys.advisory));
3743
3796
  }
3744
3797
  const resourceMetadataUrl = auth.mode === "public" ? new URL("/.well-known/oauth-protected-resource/mcp", auth.config.resourceUrl).toString() : "";
3745
3798
  const bind = resolveBind(process.env);
@@ -3925,7 +3978,9 @@ async function runHttp(composition) {
3925
3978
  });
3926
3979
  s.once("error", reject);
3927
3980
  });
3981
+ if (recordIsUndescribed(instance.instructions)) console.error(bootLine("identity", UNDESCRIBED_RECORD));
3928
3982
  console.error(bootLine("auth", authPosture(auth.mode, bind.host)));
3983
+ for (const line of keyLines) console.error(line);
3929
3984
  console.error(bootLine("abstain", abstainPosture(instance.abstain.vectorFloor)));
3930
3985
  console.error(bootLine("serving", `http://${bind.host}:${bind.port}/mcp`));
3931
3986
  let draining = false;
@@ -4080,6 +4135,20 @@ function isLoopbackHost(hostname) {
4080
4135
  return host === "" || host === "localhost" || host === "127.0.0.1" || host === "::1";
4081
4136
  }
4082
4137
  /**
4138
+ * The sslmode the DRIVER will use, which is the LAST one written.
4139
+ *
4140
+ * `URLSearchParams.get` returns the FIRST value; `pg` takes the last. On
4141
+ * `?sslmode=require&sslmode=disable` those disagree, and reading the first made
4142
+ * the pin treat an explicitly disabled connection as a weak one — collapsing the
4143
+ * duplicates into a single `verify-full`, turning TLS on, and printing "verified"
4144
+ * at an operator whose DSN ended in `disable`. The direction was safe; silently
4145
+ * overruling an explicit opt-out and then misreporting it is not (found by
4146
+ * sweeping the driver's own parser, 2026-08-21).
4147
+ */
4148
+ function effectiveSslMode(url) {
4149
+ return (url.searchParams.getAll("sslmode").at(-1) ?? "").toLowerCase();
4150
+ }
4151
+ /**
4083
4152
  * The DSN ksor actually connects with — the weak sslmode SPELLED OUT.
4084
4153
  *
4085
4154
  * pg 8 treats `sslmode=require|prefer|verify-ca` as aliases for `verify-full`,
@@ -4103,8 +4172,7 @@ function pinnedTlsDsn(dsn) {
4103
4172
  return dsn;
4104
4173
  }
4105
4174
  if (isLoopbackHost(url.hostname)) return dsn;
4106
- const mode = (url.searchParams.get("sslmode") ?? "").toLowerCase();
4107
- if (!WEAK_SSLMODES.includes(mode)) return dsn;
4175
+ if (!WEAK_SSLMODES.includes(effectiveSslMode(url))) return dsn;
4108
4176
  url.searchParams.set("sslmode", "verify-full");
4109
4177
  return url.toString();
4110
4178
  }
@@ -4158,7 +4226,7 @@ function tlsOptionsFor(dsn) {
4158
4226
  return;
4159
4227
  }
4160
4228
  if (isLoopbackHost(url.hostname)) return void 0;
4161
- const mode = (url.searchParams.get("sslmode") ?? "").toLowerCase();
4229
+ const mode = effectiveSslMode(url);
4162
4230
  if (mode === "disable" || mode === "no-verify") return void 0;
4163
4231
  return { rejectUnauthorized: true };
4164
4232
  }
@@ -4321,7 +4389,7 @@ async function withPgRetry(op, options = {}) {
4321
4389
  throw lastError;
4322
4390
  }
4323
4391
  //#endregion
4324
- //#region ../content/dist/commands-wfQycImj.mjs
4392
+ //#region ../content/dist/commands-ulU-h9ei.mjs
4325
4393
  /**
4326
4394
  * EVAL-LOCKED constants, quarried verbatim from the oracle
4327
4395
  * (sor-agentfactory @ b554f91, config.py) — changing any of these is a
@@ -8138,6 +8206,39 @@ async function sameCommit(c, tenantId, generation, sourceCommit) {
8138
8206
  const stored = r.rows[0]?.source_commit ?? null;
8139
8207
  return String(stored ?? "") === String(sourceCommit ?? "");
8140
8208
  }
8209
+ /**
8210
+ * May this generation be ACTIVATED? Returns the refusal, or null.
8211
+ *
8212
+ * Extracted so there is exactly ONE answer to that question. It used to live
8213
+ * inside `buildGeneration`'s flip branch, which made it unreachable the moment
8214
+ * a caller flipped separately — and `ksor ingest --flip` does, deliberately: the
8215
+ * governance gate has to run against the new generation BEFORE it becomes the
8216
+ * active one. That change silently retired this guard on the CLI path, so a
8217
+ * record that lost 80% of its documents published without a word, while the
8218
+ * library test that covers the guard stayed green because it drives
8219
+ * `buildGeneration` directly (found live 2026-08-21, auditing 0.0.10).
8220
+ *
8221
+ * A pre-flip check that only one of two flip paths performs is not a guard.
8222
+ */
8223
+ async function flipRefusal(client, options) {
8224
+ const { log } = options;
8225
+ const delta = await flipDelta(client, {
8226
+ tenantId: options.tenantId,
8227
+ corpusId: options.corpusId,
8228
+ newGeneration: options.newGeneration
8229
+ });
8230
+ const added = addedSlugs(delta);
8231
+ const removed = removedSlugs(delta);
8232
+ log(`pre-flip delta vs gen ${delta.priorGeneration}: ${delta.priorSlugs.size} -> ${delta.newSlugs.size} nodes (+${added.length} / -${removed.length})`);
8233
+ if (removed.length > 0) log(` removed: ${JSON.stringify(removed.slice(0, 20))}`);
8234
+ if (added.length > 0) log(` added: ${JSON.stringify(added.slice(0, 20))}`);
8235
+ const configuredShrink = envFloat("KSOR_MAX_SHRINK", .15, 0);
8236
+ const maxShrink = configuredShrink <= 1 ? configuredShrink : .15;
8237
+ if (configuredShrink > 1) log(`KSOR_MAX_SHRINK=${configuredShrink} is not a fraction in [0,1]; using ${maxShrink} (did you mean ${configuredShrink / 100}?)`);
8238
+ const allowed = options.force || process.env["KSOR_ALLOW_SHRINK"] === "1";
8239
+ if (!shrinkUnsafe(delta.priorSlugs.size, delta.newSlugs.size, maxShrink) || allowed) return null;
8240
+ return `REFUSING FLIP: corpus shrank ${pct(shrinkFraction(delta.priorSlugs.size, delta.newSlugs.size))} vs gen ${delta.priorGeneration} (> KSOR_MAX_SHRINK=${pct(maxShrink)}); ${removed.length} node(s) vanished. Generation ${options.newGeneration} is READY but NOT served — the old generation keeps serving. If the drop is intended, re-run with KSOR_ALLOW_SHRINK=1; otherwise fix the corpus and re-ingest.`;
8241
+ }
8141
8242
  /** Thrown inside the build transaction to roll it back when nothing changed. */
8142
8243
  var UnchangedCorpus = class extends Error {
8143
8244
  activeGeneration;
@@ -8262,26 +8363,19 @@ async function buildGeneration(pool, instance, options) {
8262
8363
  flipped: false,
8263
8364
  refusal: null
8264
8365
  };
8265
- const delta = await flipDelta(c, {
8366
+ const refusal = await flipRefusal(c, {
8266
8367
  tenantId: tenant,
8267
8368
  corpusId: instance.corpusId,
8268
- newGeneration: generation
8369
+ newGeneration: generation,
8370
+ force: options.force === true,
8371
+ log
8269
8372
  });
8270
- const added = addedSlugs(delta);
8271
- const removed = removedSlugs(delta);
8272
- log(`pre-flip delta vs gen ${delta.priorGeneration}: ${delta.priorSlugs.size} -> ${delta.newSlugs.size} nodes (+${added.length} / -${removed.length})`);
8273
- if (removed.length > 0) log(` removed: ${JSON.stringify(removed.slice(0, 20))}`);
8274
- if (added.length > 0) log(` added: ${JSON.stringify(added.slice(0, 20))}`);
8275
- const configuredShrink = envFloat("KSOR_MAX_SHRINK", .15, 0);
8276
- const maxShrink = configuredShrink <= 1 ? configuredShrink : .15;
8277
- if (configuredShrink > 1) log(`KSOR_MAX_SHRINK=${configuredShrink} is not a fraction in [0,1]; using ${maxShrink} (did you mean ${configuredShrink / 100}?)`);
8278
- const allowed = options.force === true || process.env["KSOR_ALLOW_SHRINK"] === "1";
8279
- if (shrinkUnsafe(delta.priorSlugs.size, delta.newSlugs.size, maxShrink) && !allowed) return {
8373
+ if (refusal !== null) return {
8280
8374
  ready,
8281
8375
  centroids,
8282
8376
  health,
8283
8377
  flipped: false,
8284
- refusal: `REFUSING FLIP: corpus shrank ${pct(shrinkFraction(delta.priorSlugs.size, delta.newSlugs.size))} vs gen ${delta.priorGeneration} (> KSOR_MAX_SHRINK=${pct(maxShrink)}); ${removed.length} node(s) vanished. Generation ${generation} is READY but NOT served — the old generation keeps serving. If the drop is intended, re-run with KSOR_ALLOW_SHRINK=1; otherwise fix the corpus and re-ingest.`
8378
+ refusal
8285
8379
  };
8286
8380
  await flip(c, {
8287
8381
  tenantId: tenant,
@@ -8652,11 +8746,23 @@ async function ingestCommand(args) {
8652
8746
  const governance = await withPool(dsn, (pool) => assertGovernanceServable(pool, instance, report.generation).then(() => null, (error) => error instanceof Error ? error.message : String(error)));
8653
8747
  if (governance !== null) return fail$1(REFUSED, `generation ${report.generation} was built and NOT activated — no surface could serve it\n ${governance.split("\n").join("\n ")}\n note: generation ${report.generation} is left behind, un-activated; \`ksor gc\` reaps it once the grace window passes. The previously active generation still serves.`);
8654
8748
  if (values.flip === true && !report.unchanged) {
8655
- await withPool(dsn, (pool) => runIngest(pool, instance.tenantId, (client) => flip(client, {
8656
- tenantId: instance.tenantId,
8657
- corpusId: instance.corpusId,
8658
- toGeneration: report.generation
8659
- })));
8749
+ const refusal = await withPool(dsn, (pool) => runIngest(pool, instance.tenantId, async (client) => {
8750
+ const stop = await flipRefusal(client, {
8751
+ tenantId: instance.tenantId,
8752
+ corpusId: instance.corpusId,
8753
+ newGeneration: report.generation,
8754
+ force: false,
8755
+ log: (line) => process.stdout.write(line + "\n")
8756
+ });
8757
+ if (stop !== null) return stop;
8758
+ await flip(client, {
8759
+ tenantId: instance.tenantId,
8760
+ corpusId: instance.corpusId,
8761
+ toGeneration: report.generation
8762
+ });
8763
+ return null;
8764
+ }));
8765
+ if (refusal !== null) return fail$1(REFUSED, refusal);
8660
8766
  process.stdout.write(`FLIPPED active generation -> ${report.generation}\n`);
8661
8767
  }
8662
8768
  if (values.flip !== true) process.stdout.write("ready; flip withheld (pass --flip to activate)\n");
package/docs/index.md CHANGED
@@ -45,8 +45,9 @@ instead of their training memory. The corpus grows with each implemented verb.
45
45
  Read the scaffold's own `AGENTS.md` first — it is the working contract.
46
46
  Knowledge lives in `knowledge/` and never inside the site; frontmatter uses
47
47
  a closed key set (`title` + `status` required); `pnpm check` explains any
48
- violation and how to fix it. Sidebar order is the governed `order:`
49
- frontmatter key — never `meta.json` or `sidebar_position`. If the
48
+ violation and how to fix it. Reading order is the governed `order:`
49
+ frontmatter key — never `meta.json` or `sidebar_position` and it drives
50
+ every surface: the sidebar, `llms.txt`, and the MCP `outline` tool. If the
50
51
  instance declares an `audiences:` model, documents may carry a
51
52
  `visibility:` key and per-audience builds (`KSOR_AUDIENCE=<tier> pnpm
52
53
  build`) stage only what that tier may see — publication, not authorship:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
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",
@@ -235,11 +235,14 @@ Two things worth being deliberate about:
235
235
  - **Set `KSOR_SSO_ISSUER` when your SSO stamps a stable `iss`.** Audience is
236
236
  always enforced against `KSOR_JWT_ALLOWED_AUDIENCES`; naming the issuer adds
237
237
  one more check for the cost of one variable.
238
- - **Set `KSOR_JWKS_URL` unless your SSO is Better Auth.** The signing keys are
239
- fetched from `<KSOR_SSO_URL>/api/auth/jwks` by default, which is Better
240
- Auth's layout. Auth0, Okta, Entra, Keycloak and Cognito publish theirs
241
- elsewhere, and a wrong JWKS URL fails as a transient fetch error — the door
242
- boots clean and every request 503s with nothing naming the cause.
238
+ - **The signing keys are DISCOVERED; you rarely set `KSOR_JWKS_URL`.** The door
239
+ reads your SSO's own metadata document RFC 8414
240
+ (`/.well-known/oauth-authorization-server`), then OpenID Discovery
241
+ (`/.well-known/openid-configuration`) so Auth0, Okta, Entra, Keycloak,
242
+ Cognito, Google and Better Auth all work unmodified. The boot report's `keys`
243
+ line names which document answered and where the keys came from; set
244
+ `KSOR_JWKS_URL` only to override that, or when your SSO publishes no metadata
245
+ at all.
243
246
 
244
247
  ## Withdrawing a document — `ksor takedown`
245
248
 
@@ -258,8 +261,12 @@ pnpm exec ksor takedown --instance instance.md --revoke <stable-id>
258
261
  The stable id is what a search result reports as `provenance.stable_id` — for
259
262
  most documents that is `knowledge/<path-without-.md>`. `--subtree` withdraws a
260
263
  section and everything beneath it, including documents added later.
261
- `--actor NAME` names who performed the act in the ledger; it defaults to the
262
- operating user.
264
+ `--actor NAME` names who performed the act in the ledger, and a denial or a
265
+ revocation is REFUSED without it. There is no default: a name taken from the
266
+ environment reads like a person and is whatever the shell happened to be
267
+ (`runner` under CI, `root` in a container), which is worse than no name at all
268
+ in the one row that exists to record who did this. Read-only modes
269
+ (`--list`, `--ledger`, `--export`) need nothing.
263
270
 
264
271
  **The MCP door stops serving it immediately. The SITE stops at its next
265
272
  build** — the site reads a file, not the database, and `pnpm build` refreshes
@@ -329,8 +336,10 @@ Details in README → Deploying.
329
336
  takes the position that page declares.
330
337
  - Sidebar position is the governed `order:` key: documents that declare it come
331
338
  first, ascending; the rest follow in name order.
332
- - One order drives the sidebar, `llms.txt`, and the home page's first-document
333
- link set it once and every surface agrees.
339
+ - One order drives every surface — the sidebar, `llms.txt`, the home page's
340
+ first-document link, and the MCP `outline` tool an agent reads to decide what
341
+ to read first. Set it once and they agree. The door picks up a reorder at the
342
+ next `pnpm refresh`, which costs no embedding: only the ordering changed.
334
343
  - Never `meta.json` or `sidebar_position`: the checker refuses framework files
335
344
  in the record, which has to read the same without the site.
336
345
 
@@ -42,3 +42,10 @@ act on it.
42
42
  Ask your coding agent to run the **intake interview** (it knows how — see
43
43
  `.agents/skills/intake-interview/`), answer its questions, and let it write
44
44
  this document with you.
45
+
46
+ Until you do, `ksor serve` says so — at boot, and to every agent that connects:
47
+ the MCP surface replaces this template with a plain statement that the record's
48
+ scope is unstated, rather than passing authoring guidance to a runtime agent as
49
+ if it were instructions. Nothing breaks, and the record still answers with
50
+ citations; it just cannot tell an agent what it is authoritative FOR, which is
51
+ the one thing that makes an answer worth trusting.
@@ -1,4 +1,4 @@
1
- import { appName, mcpEndpoint, mcpNamespace, recordVersion } from "@/lib/shared";
1
+ import { appName, mcpEndpoint, mcpNamespace, recordDescription, recordVersion } from "@/lib/shared";
2
2
 
3
3
  /**
4
4
  * `/.well-known/mcp/server.json` — how an agent DISCOVERS this record's MCP
@@ -33,7 +33,10 @@ export function GET(): Response {
33
33
  {
34
34
  $schema: SCHEMA,
35
35
  name: `${mcpNamespace()}/${appName}`,
36
- description: `The ${appName} Knowledge System of Record: governed markdown served with citations and honest abstention.`,
36
+ // The record's OWN account of itself see recordDescription. A
37
+ // description identical in every ksor record cannot help an agent choose
38
+ // one, and a record with no scope yet says so instead of guessing.
39
+ description: recordDescription(),
37
40
  version: recordVersion(),
38
41
  // Absent until the owner declares where the server runs — an invented
39
42
  // URL is worse than none, because an agent would try it and conclude the
@@ -59,6 +59,55 @@ function readInstanceTitle(): string {
59
59
 
60
60
  export const appTitle: string = readInstanceTitle();
61
61
 
62
+ /**
63
+ * How this record describes ITSELF, in one line — what an agent reads in a
64
+ * registry listing to decide whether this record can answer its question.
65
+ *
66
+ * It comes from the record's own prose (instance.md's first real paragraph,
67
+ * which the intake interview writes) because the alternative is what shipped
68
+ * before: one hard-coded sentence, byte-identical in every ksor record ever
69
+ * scaffolded, telling a discovering agent nothing that distinguishes this record
70
+ * from any other. "Discoverability determines whether agents find you at all" is
71
+ * a product principle, and a description that cannot discriminate is not
72
+ * discoverability (found live 2026-08-21).
73
+ *
74
+ * An UNDESCRIBED record says so rather than borrowing a confident sentence it
75
+ * has not earned — the same answer the MCP door already gives an agent that
76
+ * connects, so the two surfaces do not disagree about whether this record knows
77
+ * what it is. The marker is the template's own unfilled placeholder, matched on
78
+ * the WHOLE body: a scaffold's first paragraphs are authoring guidance, and
79
+ * reading one of those as the record's scope is worse than admitting there is
80
+ * none.
81
+ */
82
+ const TEMPLATE_MARKER = "_fill this in; it is";
83
+
84
+ function readInstanceScope(): string | null {
85
+ const text = readFileSync(findInstance(process.cwd()), "utf8");
86
+ const body = text.replace(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/, "");
87
+ if (body.includes(TEMPLATE_MARKER)) return null;
88
+ const afterHeading = body.replace(/^[\s\S]*?^#[ \t]+.+$/m, "");
89
+ for (const para of afterHeading.split(/\n[ \t]*\n/)) {
90
+ const one = para.trim().replace(/\s+/g, " ");
91
+ if (one === "" || one.startsWith("#") || one.startsWith("-") || one.startsWith(">")) continue;
92
+ const sentence = /^(.+?[.!?])(\s|$)/.exec(one)?.[1] ?? one;
93
+ return sentence.length > 300 ? `${sentence.slice(0, 297)}...` : sentence;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /** null until the owner has written one — never a guess. */
99
+ export const appScope: string | null = readInstanceScope();
100
+
101
+ /**
102
+ * The one-line description every discovery surface publishes. Built here so the
103
+ * registry document and anything else that needs one cannot drift apart.
104
+ */
105
+ export function recordDescription(): string {
106
+ return appScope === null
107
+ ? `${appTitle} — its owner has not yet described what this record covers.`
108
+ : `${appTitle} — ${appScope}`;
109
+ }
110
+
62
111
  /**
63
112
  * Where this record's MCP surface is published, if the owner has said.
64
113
  *
@@ -343,13 +343,30 @@ function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
343
343
  return { files: [...documents, ...assets], documents: documents.length, total };
344
344
  }
345
345
 
346
+ /**
347
+ * Remove the stage, asking for the retries this exact failure needs.
348
+ *
349
+ * `force: true` suppresses ENOENT; it does NOT retry anything. Node retries
350
+ * EBUSY / EMFILE / ENFILE / ENOTEMPTY / EPERM only when `maxRetries` is set,
351
+ * and it defaults to zero. The build evaluates `source.config.ts` more than
352
+ * once when the bundler wants it in more than one place, so two runs can
353
+ * overlap: one removing the stage while the other is still copying into it.
354
+ * That surfaced as `ENOTEMPTY` out of `rmSync` and failed the whole site build
355
+ * (CI, 2026-08-21) — a race that is safe to lose, because the stage is a
356
+ * deterministic function of the record and the denylist, so redoing it produces
357
+ * the same bytes.
358
+ */
359
+ function removeStage(stageDir: string): void {
360
+ rmSync(stageDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
361
+ }
362
+
346
363
  /** Fill a clean stage with exactly the set this build may publish. */
347
364
  function fillStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
348
365
  // The old stage goes first, before any refusal can throw: a refused build
349
366
  // that leaves the previous, more permissive stage on disk hands the next
350
367
  // careless build a filtered copy nothing governs (review finding,
351
368
  // 2026-08-19).
352
- rmSync(stageDir, { recursive: true, force: true });
369
+ removeStage(stageDir);
353
370
  const plan = planStage(recordDir, denied);
354
371
  // An empty record is its own problem, reported by the page that renders it;
355
372
  // an empty AUDIENCE is a misconfiguration that would otherwise surface as
@@ -468,7 +485,7 @@ export function knowledgeSourceDir(): string {
468
485
  // A stage left behind by an earlier model would be a filtered copy of the
469
486
  // record nothing governs any more — removed before the refusal below can
470
487
  // throw, so a refused build never leaves one behind either.
471
- rmSync(stageDir, { recursive: true, force: true });
488
+ removeStage(stageDir);
472
489
  refuseVisibilityWithoutAudiences(recordDir);
473
490
  return RECORD_DIR;
474
491
  }