@panaversity/ksor 0.0.11 → 0.0.13

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,205 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.13
4
+
5
+ ### Patch Changes
6
+
7
+ - 8c5013b: A provider outage is never reported as "the record does not cover this"
8
+
9
+ When the embedding provider is down, the vector arm does not run — so an empty
10
+ result says nothing about coverage. It says we could not look. That distinction
11
+ was fixed once for records with a calibrated floor, and the condition was the
12
+ bug: it left the case out that matters most.
13
+
14
+ An **uncalibrated** record is the default state of every fresh scaffold. There
15
+ the emptiness came from the keyword arm, which abstains when it returns no rows
16
+ — and it returns nothing for almost every natural-language question, because
17
+ `websearch_to_tsquery` ANDs its terms (measured 12 of 12 on real questions). So
18
+ during any outage an uncalibrated record answered every question with
19
+ `abstained: true`, while the tool description instructs the agent to state that
20
+ as fact and never fall back on its own knowledge.
21
+
22
+ It reached this release because the existing test asked a question the keyword
23
+ arm could answer, so the degraded path served real hits and looked correct. Ask
24
+ the way a person asks and it did not. That case is now covered.
25
+
26
+ Found live against the published 0.0.12 with an invalid key — the same state a
27
+ rejected CI key had produced that morning, which is how likely this is.
28
+
29
+ **`ksor calibrate` also stops blessing a floor on far-domain evidence alone.**
30
+ The built-in out-of-corpus probes are all far-domain — dinner, taxes, boiling an
31
+ egg — and a shipped set cannot be scope-adjacent, because adjacency depends on a
32
+ corpus it has never seen. Far-domain probes score low against anything, so the
33
+ margin comes out inflated. Measured on one record, changing only the probe set:
34
+ built-ins reported "separable, margin 0.072" and recommended a floor; eight
35
+ scope-adjacent near-misses reported "NOT separable, margin -0.030" — and that
36
+ floor then answered six of the eight live, with citations. The tool already knew
37
+ to say "widen the probe set", but said it only on the not-separable branch, which
38
+ is when it is least needed. It now says it whenever the built-ins are used.
39
+
40
+ - 692d296: `ksor ingest` says how much of the record no search will return
41
+
42
+ A chunk shorter than the navigation threshold is stored, embedded and readable —
43
+ and excluded from every retrieval arm by the serving predicate. That rule exists
44
+ for a good reason: a "See also: [a] [b] [c]" block should never be a search hit.
45
+ But it decides by LENGTH ALONE, so a short _substantive_ paragraph is caught by
46
+ it too — and a policy handbook is made of short substantive statements.
47
+
48
+ Measured on a realistic five-document operations handbook with real embeddings:
49
+ **10 of 16 chunks unsearchable, and one entire document that `outline` lists and
50
+ `read` returns in full but `search` can never find.** A complete policy
51
+ statement — "Probation: six months, with a written review at three and six" —
52
+ is 191 characters, so the record treats it as navigation. The ingest line
53
+ reported a cheerful `16 chunks; embedded 16` and said nothing.
54
+
55
+ It says it now:
56
+
57
+ ```
58
+ ingest: generation 1 — 2 nodes, 4 chunks; embedded 4, carried 0, failed 0
59
+ not searchable: 3 of 4 chunk(s) (75%) are shorter than the navigation
60
+ threshold — stored and readable, but no search returns them
61
+ FOUND ONLY BY NAME: knowledge/onboarding:prose — no searchable chunk at all
62
+ ```
63
+
64
+ This does **not** change the threshold, and nothing that was searchable stops
65
+ being so. Where that line belongs needs a gold-set measurement, which is issue
66
+ #55. What is fixed here is the silence — because the silence is what let a
67
+ record ship most of itself unfindable, and told its owner everything was fine.
68
+
69
+ The count is computed with the serving predicate's own admission test, and a db
70
+ test compares it against what the SQL actually admits: a report the database
71
+ disagrees with would be worse than none.
72
+
73
+ - c4976dd: Two defects introduced in 0.0.12, found by verifying the published package live
74
+
75
+ **The discovery document became invalid exactly when a record became real.** The
76
+ MCP registry schema caps `ServerDetail.description` at **100 characters**
77
+ (2025-12-11). 0.0.12 started generating that description from the record's own
78
+ prose — a real improvement over the hard-coded sentence it replaced — and capped
79
+ it at 300. The unfilled placeholder is 88 characters and validates; a described
80
+ record's title plus scope sentence is routinely 150-350 and does not. So
81
+ `/.well-known/mcp/server.json` passed validation until the owner did the thing
82
+ the scaffold asks for, then silently stopped, with nothing in the build to say
83
+ so. It is now assembled inside the schema's budget and trimmed at a word
84
+ boundary rather than mid-word.
85
+
86
+ **The boot report reassured the operator in the one configuration that needs a
87
+ warning.** `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` permits an unauthenticated
88
+ public bind, and the auth line kept printing `DISABLED — 0.0.0.0 only, and a
89
+ public bind will refuse to boot` — false on both counts, at the moment the whole
90
+ record is being served to anyone who can reach the port. It now says that,
91
+ naming the variable responsible.
92
+
93
+ Both shipped in 0.0.12 and both were mine; the aligned boot report and the
94
+ self-describing discovery document are otherwise unchanged.
95
+
96
+ ## 0.0.12
97
+
98
+ ### Patch Changes
99
+
100
+ - 36e4a4c: The scaffold documents what a CLIENT has to do to reach a public MCP door
101
+
102
+ `ksor serve` implements the OAuth Resource Server handshake — an
103
+ unauthenticated request gets a 401 carrying
104
+ `WWW-Authenticate: Bearer resource_metadata="…"`, and that document names the
105
+ record's resource identifier and its authorization server, so a client discovers
106
+ where to authenticate instead of being told. None of it was written down
107
+ anywhere an adopter or their agent reads. The operator half was documented (the
108
+ three environment variables); the half their agents actually execute was not.
109
+
110
+ The scaffold's `AGENTS.md` now walks the three steps, and names the failure that
111
+ goes wrong quietly: a token minted for a different audience is a perfectly valid
112
+ token, and this door rejects it, so `aud` against `KSOR_JWT_ALLOWED_AUDIENCES` is
113
+ the first thing to compare when a client authenticates fine and still gets 401.
114
+ It also records the two behaviours a client author has to know and could not have
115
+ guessed — RS256 only, no opaque-token introspection, and an unknown key id
116
+ answering 503 rather than 401, because during a key rotation the token is
117
+ probably good and retrying beats sending the user back through a login.
118
+
119
+ This closes one of the three items named in issue #26; the worked provider
120
+ recipes and the introspection/rotation policy remain open there.
121
+
122
+ - 125970c: Every 401 from the MCP door carries its `WWW-Authenticate` challenge, not just the first
123
+
124
+ Only the missing-token branch emitted `WWW-Authenticate: Bearer
125
+ resource_metadata="…"`. A token that failed verification — expired, wrong
126
+ audience, no subject, bad signature — came back as a bare 401. That is the most
127
+ common 401 a real client will ever see, because tokens expire mid-conversation,
128
+ and it left the client with no pointer back to the resource-metadata document:
129
+ it could not re-discover the authorization server it had just been talking to.
130
+ Only a caller that had never sent a token was told where to go.
131
+
132
+ The MCP authorization spec requires `WWW-Authenticate` on a 401 without
133
+ qualification. Every 401 now carries it, with RFC 6750's `error="invalid_token"`
134
+ so a client refreshes rather than retrying the dead token.
135
+
136
+ A **503** stays deliberately unchallenged: an unreachable key set is our outage,
137
+ not the token's fault, and challenging there would send a user whose token is
138
+ perfectly good back through a login over a key-fetch failure.
139
+
140
+ Found by adversarially checking the release that documented this door. The
141
+ adversarial auth suite missed it by asserting the STATUS of each rejection and
142
+ never the header — it now sweeps every 401-producing token and asserts the
143
+ challenge on each, with the 503 as the negative control.
144
+
145
+ - 0a94e31: A 503 refusal no longer puts the database host and user on the wire
146
+
147
+ When the deferred boot checks fail, `/mcp` refuses with the thrown error's
148
+ message in full under `data.detail`. For the three authored failures that is the
149
+ point — a too-old schema, a governance violation and a text-search mismatch each
150
+ carry a multi-line remedy the operator has to act on. But the catch treated every
151
+ error alike, and `pg` writes the host, its resolved address, the port and the
152
+ database user into its connection and authentication failures. Those went out
153
+ verbatim to any caller who could reach the door.
154
+
155
+ What may leave is now decided in one place and by TYPE, not by inspecting
156
+ message text: a class we wrote is a class whose words we control. A driver error
157
+ is refused with its class named and its text withheld, and the caller is told
158
+ which kind of failure it is — infrastructure, not their request.
159
+
160
+ The full text still reaches the operator, deliberately: the refusal says the
161
+ reason is in the server's logs, and the deferred-boot line recorded only the
162
+ error's NAME, so before this the real message existed nowhere. That is also why
163
+ the boot checks are not sanitised at their source — reducing a driver error to a
164
+ class name early would destroy the one copy anyone can act on.
165
+
166
+ **The test that covered this was holding it in place.** It asserted that
167
+ `http.ts` contains the literal string `data: { detail: message }` — so the leak
168
+ was pinned by an assertion with reasoning attached. Grepping source is the right
169
+ instrument for "does this check run before dispatch", because position is a
170
+ property of source, and the wrong one for "what does the response contain".
171
+ Response contents are now asserted against real bodies, including a `pg`-shaped
172
+ connection failure whose host, address, port and user must all be absent.
173
+
174
+ Verified live: a gateway pointed at an unreachable database answers
175
+ `the content store is unavailable (Error)` with no host, port, user or database
176
+ name anywhere in the body, while the server log carries
177
+ `connect ECONNREFUSED 127.0.0.1:59999` in full.
178
+
179
+ - 1dd6211: The dimension ceiling says which shape it applies to, instead of blaming pgvector
180
+
181
+ `ksor schema` refuses an embedding dimension above 2000 with
182
+ "(pgvector vector + HNSW ceiling)". The refusal is right and the reason was
183
+ wrong: pgvector indexes a `vector` to 2000, but a **`halfvec` to 4000**, via an
184
+ expression index on the cast — verified live against a real database, where
185
+ `hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops)` plans an Index Scan.
186
+
187
+ The old wording read as pgvector's own limit, so an adopter whose model emits
188
+ more than 2000 dimensions could conclude it was unusable here, over a wall that
189
+ is not one. The message now names the shape the ceiling belongs to — this schema
190
+ declares `VECTOR(dim)` columns and indexes one directly — and the constant
191
+ carries why raising it is a decision rather than an edit: every query site would
192
+ have to use the same cast as the index or fall silently back to a sequential
193
+ scan, and the halfvec arm's float16 rounding lands on the score the abstention
194
+ gate reads.
195
+
196
+ The same claim is corrected in the scaffold's `AGENTS.md`, which gains the reason
197
+ `dim: 1536` is the shipped default: `gemini-embedding-001` emits 3072 and ksor
198
+ asks it for 1536, which per Google's published MTEB table costs nothing
199
+ measurable — 1536 scores 68.17 against 2048's 68.16.
200
+
201
+ The 2000 refusal is unchanged. Issue #49 records the decision it now points at.
202
+
3
203
  ## 0.0.11
4
204
 
5
205
  ### 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-3DN4fOiE.mjs
19
+ //#region ../content-gateway/dist/main-B1G1VdMp.mjs
20
20
  /**
21
21
  * A connection could not be ESTABLISHED in time — retryable.
22
22
  *
@@ -448,6 +448,8 @@ const CHUNK_POLICY$1 = "heading-aware-1500-content-only-v5";
448
448
  * The Markdown BODY below the frontmatter is the authored agent-surface
449
449
  * instructions — byte-preserved, stripped only at the edges.
450
450
  */
451
+ /** Mirrors `schema.ts`'s ceiling, so a bad `dim:` is refused when instance.md is
452
+ * PARSED rather than when the DDL is rendered. The why lives there. */
451
453
  const EMBED_DIM_MAX$1$1 = 2e3;
452
454
  const SUPPORTED_FORMATS$1$1 = [1];
453
455
  var InstanceParseError$1 = class extends Error {
@@ -541,7 +543,7 @@ const groupSchemas$1 = {
541
543
  embedding: z.object({
542
544
  provider: z.string().min(1).default("gemini"),
543
545
  model: z.string().min(1).default(EMBED_MODEL$1),
544
- dim: z.coerce.number().int().min(1).max(EMBED_DIM_MAX$1$1).default(EMBED_DIM$1)
546
+ dim: z.coerce.number().int().min(1).max(EMBED_DIM_MAX$1$1, { error: `at most ${EMBED_DIM_MAX$1$1}: this schema declares VECTOR columns and indexes one directly, and pgvector's HNSW takes a vector to ${EMBED_DIM_MAX$1$1}` }).default(EMBED_DIM$1)
545
547
  }),
546
548
  retrieval: z.object({
547
549
  /**
@@ -1112,11 +1114,24 @@ g AS (
1112
1114
  WHERE tenant_id = $1 AND corpus_id = $2)
1113
1115
  ) AS gen
1114
1116
  )`;
1115
- const ARM_WHERE$1 = `
1117
+ /**
1118
+ * The arm predicate, built for the parameter numbering of the query that uses
1119
+ * it — a FUNCTION, not a string the caller renumbers afterwards.
1120
+ *
1121
+ * It used to be derived with `ARM_WHERE.replaceAll("$5", "$4")`, which works
1122
+ * only while the predicate happens to contain exactly one placeholder and no
1123
+ * other text matching it. Adding any second parameter to the predicate breaks
1124
+ * every derived query silently — and when it breaks, the failure arrives as a
1125
+ * driver error that the serving layer correctly reduces to "content store
1126
+ * temporarily unavailable", which tells you nothing about the cause. Found by
1127
+ * tripping over it while trying a fix for issue #59; taking the number as an
1128
+ * argument makes the coupling visible instead of textual.
1129
+ */
1130
+ const armWhere$1 = (kindsParam) => `
1116
1131
  c.tenant_id = $1 AND c.generation = g.gen
1117
1132
  AND c.embedding_status = 'embedded' AND ${SERVABLE$1}
1118
1133
  AND n.status = 'published'
1119
- AND ($5::text[] IS NULL OR n.kind = ANY($5::text[]))
1134
+ AND (${kindsParam}::text[] IS NULL OR n.kind = ANY(${kindsParam}::text[]))
1120
1135
  AND ${DENY$1}
1121
1136
  AND ${AUDIENCE_ALLOWED$1}`;
1122
1137
  const JOINS$1 = `
@@ -1125,6 +1140,15 @@ const JOINS$1 = `
1125
1140
  JOIN sources s ON s.source_id = c.source_id AND s.tenant_id = c.tenant_id
1126
1141
  AND s.generation = c.generation
1127
1142
  JOIN content_nodes n ON n.node_id = s.node_id AND n.tenant_id = s.tenant_id`;
1143
+ /**
1144
+ * Exported ONLY so a test can EXPLAIN the real thing.
1145
+ *
1146
+ * The index regression this fixes was announced as fixed once before, and came
1147
+ * back through a different clause, because nothing ever asserted the RESULT —
1148
+ * that the plan opens `idx_chunks_hnsw`. A timing threshold would be flaky and
1149
+ * would not have caught it either; the plan shape is the property that matters
1150
+ * (issue #59).
1151
+ */
1128
1152
  const HYBRID_SQL$1 = `
1129
1153
  WITH RECURSIVE ${GEN_CTE$1}, ${DENIED_CTE$1},
1130
1154
  -- The top-k is taken by a PLAIN \`ORDER BY <distance> LIMIT\`, and the rank
@@ -1145,7 +1169,7 @@ WITH RECURSIVE ${GEN_CTE$1}, ${DENIED_CTE$1},
1145
1169
  FROM (
1146
1170
  SELECT c.chunk_id, g.gen, (c.embedding <=> $3::vector) AS dist
1147
1171
  ${JOINS$1}
1148
- WHERE ${ARM_WHERE$1}
1172
+ WHERE ${armWhere$1("$5")}
1149
1173
  ORDER BY c.embedding <=> $3::vector, c.chunk_id
1150
1174
  LIMIT $6
1151
1175
  ) ranked),
@@ -1154,7 +1178,7 @@ WITH RECURSIVE ${GEN_CTE$1}, ${DENIED_CTE$1},
1154
1178
  row_number() OVER (ORDER BY ts_rank_cd(c.search_tsv,
1155
1179
  websearch_to_tsquery($9::regconfig, $4)) DESC, c.chunk_id) AS r
1156
1180
  ${JOINS$1}
1157
- WHERE ${ARM_WHERE$1}
1181
+ WHERE ${armWhere$1("$5")}
1158
1182
  AND c.search_tsv @@ websearch_to_tsquery($9::regconfig, $4)
1159
1183
  ORDER BY r LIMIT $6),
1160
1184
  fused AS (
@@ -1177,10 +1201,10 @@ WITH RECURSIVE ${GEN_CTE$1.replace("$8", "$6")}, ${DENIED_CTE$1}
1177
1201
  ts_rank_cd(c.search_tsv, websearch_to_tsquery($7::regconfig, $3)) AS score,
1178
1202
  g.gen, n.permalink
1179
1203
  ${JOINS$1}
1180
- WHERE ${ARM_WHERE$1.replaceAll("$5", "$4")}
1204
+ WHERE ${armWhere$1("$4")}
1181
1205
  AND c.search_tsv @@ websearch_to_tsquery($7::regconfig, $3)
1182
1206
  ORDER BY score DESC, c.chunk_id LIMIT $5`;
1183
- `${GEN_CTE$1.replace("$8", "$5")}${DENIED_CTE$1}${JOINS$1}${ARM_WHERE$1.replaceAll("$5", "$4")}`;
1207
+ `${GEN_CTE$1.replace("$8", "$5")}${DENIED_CTE$1}${JOINS$1}${armWhere$1("$4")}`;
1184
1208
  const HIT_COLUMNS = 9;
1185
1209
  /** Serialize a query vector as a pgvector literal. */
1186
1210
  function vectorLiteral$1(vector) {
@@ -2602,7 +2626,7 @@ async function search(ctx, query, k = 10) {
2602
2626
  const r = await client.query("SELECT active_generation FROM corpora WHERE tenant_id = $1 AND corpus_id = $2", [inst.tenantId, inst.corpusId]);
2603
2627
  return Number(r.rows[0]?.active_generation ?? 0) === 0;
2604
2628
  }, audienceScope(ctx));
2605
- const reason = embedFailed && inst.abstain.vectorFloor !== null ? "unavailable" : unpublished ? "unpublished" : "abstained";
2629
+ const reason = embedFailed ? "unavailable" : unpublished ? "unpublished" : "abstained";
2606
2630
  return {
2607
2631
  ok: false,
2608
2632
  abstained: reason === "abstained",
@@ -3593,9 +3617,10 @@ function withoutSdkResponseModeWarning(body) {
3593
3617
  * it survivable (`buildAuth` refuses a non-loopback bind without auth, so the
3594
3618
  * only way to read this line is on a host that cannot be reached from outside).
3595
3619
  */
3596
- function authPosture(mode, host) {
3597
- if (mode === "disabled") return `DISABLED — ${host} only, and a public bind will refuse to boot`;
3598
- return "bearer tokens, verified against the record's authorization server";
3620
+ function authPosture(mode, host, publicUnauthenticated) {
3621
+ if (mode !== "disabled") return "bearer tokens, verified against the record's authorization server";
3622
+ 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`;
3623
+ return `DISABLED — ${host} only, and a public bind will refuse to boot`;
3599
3624
  }
3600
3625
  /**
3601
3626
  * What the record will refuse. `null` means no floor is declared and the gate
@@ -3723,6 +3748,54 @@ async function compose(instancePath, version) {
3723
3748
  };
3724
3749
  }
3725
3750
  /**
3751
+ * The body of a deferred-boot refusal, and the one decision inside it: whose
3752
+ * message may go on the wire.
3753
+ *
3754
+ * Three AUTHORED failures carry a remedy written for the operator —
3755
+ * `SchemaVersionError`, `GovernanceGateError`, `TextSearchConfigMismatch`. Their
3756
+ * whole multi-line message is the point; a caller that receives only the first
3757
+ * line has been told a problem exists and not how to end it.
3758
+ *
3759
+ * Everything else reaching this catch is infrastructure — most of it straight
3760
+ * from `pg`, whose connection and authentication failures name the host, the
3761
+ * resolved address, the port and the database user. Those went out verbatim
3762
+ * under `data.detail`, because the catch treated every error alike.
3763
+ *
3764
+ * So the split is by TYPE, not by message inspection: a class we wrote is a
3765
+ * class whose text we control. A driver error is refused with its class named
3766
+ * and its text withheld — the operator finds the real message in the server's
3767
+ * own logs, which is where an infrastructure fault belongs.
3768
+ */
3769
+ /**
3770
+ * Did WE write this error's text?
3771
+ *
3772
+ * Named classes only. Matching on message prose would put the decision back
3773
+ * inside the strings it is meant to police, and a reworded driver error would
3774
+ * quietly re-open the leak.
3775
+ */
3776
+ function isAuthored(error) {
3777
+ return error instanceof SchemaVersionError || error instanceof GovernanceGateError$1 || error instanceof TextSearchConfigMismatch;
3778
+ }
3779
+ function refusalBody(error) {
3780
+ if (isAuthored(error)) return {
3781
+ jsonrpc: "2.0",
3782
+ error: {
3783
+ code: -32001,
3784
+ message: `this record cannot be served: ${error.message.split("\n")[0] ?? ""}`,
3785
+ data: { detail: error.message }
3786
+ },
3787
+ id: null
3788
+ };
3789
+ return {
3790
+ jsonrpc: "2.0",
3791
+ error: {
3792
+ code: -32001,
3793
+ message: `this record cannot be served: the content store is unavailable (${error instanceof Error ? error.name : "Error"}). The reason is in this server's logs; it is withheld here because a driver error names the database host and user.`
3794
+ },
3795
+ id: null
3796
+ };
3797
+ }
3798
+ /**
3726
3799
  * The MCP door: the SDK v2 HTTP entry (Request → Response, stateless)
3727
3800
  * behind Hono, serving the 2026-07-28 revision with 2025-era clients still
3728
3801
  * answered through the stateless fallback. Modern exchanges are buffered JSON;
@@ -3886,16 +3959,8 @@ async function runHttp(composition) {
3886
3959
  if (verifyBoot !== null) try {
3887
3960
  await verifyBoot();
3888
3961
  } catch (error) {
3889
- const message = error instanceof Error ? error.message : String(error);
3890
- return new Response(JSON.stringify({
3891
- jsonrpc: "2.0",
3892
- error: {
3893
- code: -32001,
3894
- message: `this record cannot be served: ${message.split("\n")[0]}`,
3895
- data: { detail: message }
3896
- },
3897
- id: null
3898
- }), {
3962
+ console.error(`refusing requests boot checks failing: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
3963
+ return new Response(JSON.stringify(refusalBody(error)), {
3899
3964
  status: 503,
3900
3965
  headers: { "content-type": "application/json" }
3901
3966
  });
@@ -3939,7 +4004,7 @@ async function runHttp(composition) {
3939
4004
  identity = await auth.verify(token);
3940
4005
  } catch (error) {
3941
4006
  const transient = error instanceof TokenVerifyError && error.transient;
3942
- return c.json({ error: transient ? "token verification temporarily unavailable" : "invalid token" }, transient ? 503 : 401);
4007
+ return c.json({ error: transient ? "token verification temporarily unavailable" : "invalid token" }, transient ? 503 : 401, transient ? {} : { "www-authenticate": `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl}"` });
3943
4008
  }
3944
4009
  bearer = token;
3945
4010
  }
@@ -3979,7 +4044,7 @@ async function runHttp(composition) {
3979
4044
  s.once("error", reject);
3980
4045
  });
3981
4046
  if (recordIsUndescribed(instance.instructions)) console.error(bootLine("identity", UNDESCRIBED_RECORD));
3982
- console.error(bootLine("auth", authPosture(auth.mode, bind.host)));
4047
+ console.error(bootLine("auth", authPosture(auth.mode, bind.host, process.env["KSOR_ALLOW_PUBLIC_UNAUTHENTICATED"] === "1" && !loopback)));
3983
4048
  for (const line of keyLines) console.error(line);
3984
4049
  console.error(bootLine("abstain", abstainPosture(instance.abstain.vectorFloor)));
3985
4050
  console.error(bootLine("serving", `http://${bind.host}:${bind.port}/mcp`));
@@ -4389,7 +4454,7 @@ async function withPgRetry(op, options = {}) {
4389
4454
  throw lastError;
4390
4455
  }
4391
4456
  //#endregion
4392
- //#region ../content/dist/commands-ulU-h9ei.mjs
4457
+ //#region ../content/dist/commands-LOfF_iLT.mjs
4393
4458
  /**
4394
4459
  * EVAL-LOCKED constants, quarried verbatim from the oracle
4395
4460
  * (sor-agentfactory @ b554f91, config.py) — changing any of these is a
@@ -4425,6 +4490,8 @@ const HARD_MAX_CHARS = 4e3;
4425
4490
  * The Markdown BODY below the frontmatter is the authored agent-surface
4426
4491
  * instructions — byte-preserved, stripped only at the edges.
4427
4492
  */
4493
+ /** Mirrors `schema.ts`'s ceiling, so a bad `dim:` is refused when instance.md is
4494
+ * PARSED rather than when the DDL is rendered. The why lives there. */
4428
4495
  const EMBED_DIM_MAX$1 = 2e3;
4429
4496
  const SUPPORTED_FORMATS$1 = [1];
4430
4497
  var InstanceParseError = class extends Error {
@@ -4518,7 +4585,7 @@ const groupSchemas = {
4518
4585
  embedding: z.object({
4519
4586
  provider: z.string().min(1).default("gemini"),
4520
4587
  model: z.string().min(1).default(EMBED_MODEL),
4521
- dim: z.coerce.number().int().min(1).max(EMBED_DIM_MAX$1).default(EMBED_DIM)
4588
+ dim: z.coerce.number().int().min(1).max(EMBED_DIM_MAX$1, { error: `at most ${EMBED_DIM_MAX$1}: this schema declares VECTOR columns and indexes one directly, and pgvector's HNSW takes a vector to ${EMBED_DIM_MAX$1}` }).default(EMBED_DIM)
4522
4589
  }),
4523
4590
  retrieval: z.object({
4524
4591
  /**
@@ -4942,7 +5009,29 @@ async function runIngest(pool, tenantId, op) {
4942
5009
  * fresh DDL is rendered from the instance that will fill it, never
4943
5010
  * hand-edited.
4944
5011
  */
4945
- /** pgvector vector + HNSW ceiling. */
5012
+ /**
5013
+ * The largest embedding dimension this schema will render.
5014
+ *
5015
+ * It is the ceiling for the shape we USE, not pgvector's ceiling: `schema.sql`
5016
+ * declares two `VECTOR(dim)` columns and indexes one of them directly, and
5017
+ * pgvector's HNSW and IVFFlat take a `vector` to 2000. They take a `halfvec` to
5018
+ * **4000**, reachable by indexing an expression — `hnsw ((embedding::halfvec(N))
5019
+ * halfvec_cosine_ops)` — which we do not do, so 2000 binds here.
5020
+ *
5021
+ * Said precisely because the old wording ("pgvector vector + HNSW ceiling")
5022
+ * read as pgvector's own limit and sent a reader off to change providers over a
5023
+ * wall that is not one (verified live against a real database, 2026-08-21:
5024
+ * a halfvec(3072) expression index plans an Index Scan).
5025
+ *
5026
+ * Raising it is a decision, not a constant: every query site would have to use
5027
+ * the same cast as the index or fall silently back to a sequential scan, and
5028
+ * the halfvec arm's float16 rounding lands on the score the abstention gate
5029
+ * reads. Recorded in issue #49, along with the evidence for staying at 1536 —
5030
+ * Google's published MTEB table runs 128..2048 and is FLAT at the top of that
5031
+ * range (1536 scores 68.17, 2048 scores 68.16), so there is no gradient to
5032
+ * climb toward the ceiling. It carries no 3072 row, so the cost of the
5033
+ * truncation itself is unpublished; do not infer one.
5034
+ */
4946
5035
  const EMBED_DIM_MAX = 2e3;
4947
5036
  /** The schema version schema.sql declares — parsed from the DDL so code and
4948
5037
  * the applied database share ONE source (a drift test pins the coupling). */
@@ -4989,7 +5078,7 @@ function verifyTemplate(text, dim) {
4989
5078
  }
4990
5079
  /** The pure core: render the given template text at the given dimension. */
4991
5080
  function renderSchemaText(text, dim, textSearchConfig = SHIPPED_TS_CONFIG) {
4992
- if (!Number.isInteger(dim) || dim < 1 || dim > 2e3) throw new Error(`dim must be an integer in 1..${EMBED_DIM_MAX} (pgvector vector + HNSW ceiling), got ${JSON.stringify(dim)}`);
5081
+ if (!Number.isInteger(dim) || dim < 1 || dim > 2e3) throw new Error(`dim must be an integer in 1..${EMBED_DIM_MAX} this schema indexes a vector column directly, and pgvector's HNSW takes a vector to 2000 — got ${JSON.stringify(dim)}`);
4993
5082
  verifyTemplate(text, EMBED_DIM);
4994
5083
  const withTs = renderTsConfig(text, textSearchConfig);
4995
5084
  if (dim === 1536) return withTs;
@@ -5127,11 +5216,24 @@ g AS (
5127
5216
  WHERE tenant_id = $1 AND corpus_id = $2)
5128
5217
  ) AS gen
5129
5218
  )`;
5130
- const ARM_WHERE = `
5219
+ /**
5220
+ * The arm predicate, built for the parameter numbering of the query that uses
5221
+ * it — a FUNCTION, not a string the caller renumbers afterwards.
5222
+ *
5223
+ * It used to be derived with `ARM_WHERE.replaceAll("$5", "$4")`, which works
5224
+ * only while the predicate happens to contain exactly one placeholder and no
5225
+ * other text matching it. Adding any second parameter to the predicate breaks
5226
+ * every derived query silently — and when it breaks, the failure arrives as a
5227
+ * driver error that the serving layer correctly reduces to "content store
5228
+ * temporarily unavailable", which tells you nothing about the cause. Found by
5229
+ * tripping over it while trying a fix for issue #59; taking the number as an
5230
+ * argument makes the coupling visible instead of textual.
5231
+ */
5232
+ const armWhere = (kindsParam) => `
5131
5233
  c.tenant_id = $1 AND c.generation = g.gen
5132
5234
  AND c.embedding_status = 'embedded' AND ${SERVABLE}
5133
5235
  AND n.status = 'published'
5134
- AND ($5::text[] IS NULL OR n.kind = ANY($5::text[]))
5236
+ AND (${kindsParam}::text[] IS NULL OR n.kind = ANY(${kindsParam}::text[]))
5135
5237
  AND ${DENY}
5136
5238
  AND ${AUDIENCE_ALLOWED}`;
5137
5239
  const JOINS = `
@@ -5140,14 +5242,14 @@ const JOINS = `
5140
5242
  JOIN sources s ON s.source_id = c.source_id AND s.tenant_id = c.tenant_id
5141
5243
  AND s.generation = c.generation
5142
5244
  JOIN content_nodes n ON n.node_id = s.node_id AND n.tenant_id = s.tenant_id`;
5143
- `${GEN_CTE}${DENIED_CTE}${JOINS}${ARM_WHERE}${JOINS}${ARM_WHERE}`;
5144
- `${GEN_CTE.replace("$8", "$6")}${DENIED_CTE}${JOINS}${ARM_WHERE.replaceAll("$5", "$4")}`;
5245
+ `${GEN_CTE}${DENIED_CTE}${JOINS}${armWhere("$5")}${JOINS}${armWhere("$5")}`;
5246
+ `${GEN_CTE.replace("$8", "$6")}${DENIED_CTE}${JOINS}${armWhere("$4")}`;
5145
5247
  /** The calibrator's standalone top-1 signal (the read path gets it free from HYBRID_SQL). */
5146
5248
  const TOP_ONE_SQL = `
5147
5249
  WITH RECURSIVE ${GEN_CTE.replace("$8", "$5")}, ${DENIED_CTE}
5148
5250
  SELECT 1 - (c.embedding <=> $3::vector) AS score
5149
5251
  ${JOINS}
5150
- WHERE ${ARM_WHERE.replaceAll("$5", "$4")}
5252
+ WHERE ${armWhere("$4")}
5151
5253
  ORDER BY c.embedding <=> $3::vector, c.chunk_id
5152
5254
  LIMIT 1`;
5153
5255
  /** Serialize a query vector as a pgvector literal. */
@@ -5729,6 +5831,29 @@ const BUILT_IN_OOC = [
5729
5831
  * measurement had an easier question set than production would.
5730
5832
  */
5731
5833
  const SYNTHESIZED_CAVEAT = "CAVEAT: synthesized queries are written FROM the passages they are then scored against, so they share vocabulary a reader's question will not. This door measures an UPPER BOUND on separation — treat the floor below as provisional until it has been checked against questions the corpus did not write (--queries-file), and re-run if real questions score under it.";
5834
+ /**
5835
+ * What a separability verdict is worth when the probes came from the binary.
5836
+ *
5837
+ * Every entry in BUILT_IN_OOC is far-domain — dinner, taxes, football, boiling
5838
+ * an egg. Those score low against ANY corpus, so max-OOC comes out artificially
5839
+ * low and the margin is inflated from that end, exactly as synthesized in-corpus
5840
+ * queries inflate it from the other. Measured on one record, changing ONLY the
5841
+ * probe set: built-ins reported "separable, margin 0.072" and recommended a
5842
+ * floor; eight scope-adjacent near-misses on the same corpus and the same
5843
+ * in-corpus questions reported "NOT separable, margin -0.030". The recommended
5844
+ * floor then answered six of those eight near-misses live, with citations
5845
+ * (2026-08-21).
5846
+ *
5847
+ * The tool already knows this — the not-separable branch tells the operator to
5848
+ * "widen the probe set (scope-adjacent near-misses, not only far-domain
5849
+ * questions)". It said so only AFTER weak probes had failed to bless a floor,
5850
+ * which is the one case where the advice is least needed.
5851
+ *
5852
+ * A shipped set cannot be scope-adjacent, because adjacency depends on a corpus
5853
+ * the binary has never seen. So this is stated whenever built-ins are used, on
5854
+ * BOTH branches, rather than pretending a better default exists.
5855
+ */
5856
+ const BUILT_IN_OOC_CAVEAT = "CAVEAT: the out-of-corpus probes are the BUILT-IN set, which is entirely far-domain — a shipped set cannot be scope-adjacent, because adjacency depends on a corpus it has never seen. Far-domain probes score low against anything, so this margin is an OVER-estimate and a floor it blesses may still answer near-misses just outside your scope. Re-run with --ooc-file naming questions a reader might plausibly ask that this record does NOT cover, and trust that verdict over this one.";
5732
5857
  const QUERIES_FILE_CAVEAT = "CAVEAT: --queries-file floors are measured on human/gold-derived queries — section-weighted eval targets, NOT per-node passage samples — so this floor's low tail is a different distribution than the synthesized door's; record 'door: queries-file' beside the number and never compare the two doors' floors as interchangeable.";
5733
5858
  /**
5734
5859
  * The report dict, assembled from every scored query. `in_corpus_queries` /
@@ -5770,6 +5895,7 @@ function buildReport(detail, meta, targetPrecision = .95, now = /* @__PURE__ */
5770
5895
  paste_why,
5771
5896
  margin: pythonRound(marginOf(points), 4),
5772
5897
  separable,
5898
+ ooc_source: meta.oocSource,
5773
5899
  target: rec.target,
5774
5900
  measured_at: now.toISOString().slice(0, 10),
5775
5901
  low_tail,
@@ -5790,6 +5916,7 @@ function renderReport(report) {
5790
5916
  const gen = report.generation === null ? "unknown (no generation pinned)" : String(report.generation);
5791
5917
  lines.push(`\nmeasured on generation ${gen} (${how}), model ${report.model}, door: ${report.door}`);
5792
5918
  lines.push(report.door === "queries-file" ? QUERIES_FILE_CAVEAT : SYNTHESIZED_CAVEAT);
5919
+ if (report.ooc_source === "built-in") lines.push(BUILT_IN_OOC_CAVEAT);
5793
5920
  lines.push(`AURC = ${pythonFloatRepr(report.aurc)} (lower = better separation)`);
5794
5921
  lines.push(`separation margin: ${pythonFormatFixed(report.margin, 3)} (over ${report.in_corpus_queries} in-corpus / ${report.ooc_probes} out-of-corpus probes)`);
5795
5922
  if (z) lines.push(`zero-FA floor (never refuse a real question): ${pythonFormatFixed(z.floor, 3)} -> coverage ${pythonFormatFixed(z.coverage, 3)}, ooc leak ${pythonFormatFixed(z.risk, 3)}`);
@@ -6044,13 +6171,15 @@ async function runCalibration(pool, options) {
6044
6171
  }
6045
6172
  inQueries = normalizeQueries(synthesized);
6046
6173
  }
6174
+ const oocSource = options.oocProbes === void 0 ? "built-in" : "provided";
6047
6175
  const ooc = normalizeQueries(options.oocProbes ?? BUILT_IN_OOC);
6048
6176
  return buildReport([...await scoreQueries(pool, scope, options.provider, inQueries, true), ...await scoreQueries(pool, scope, options.provider, ooc, false)], {
6049
6177
  generation: counted.measured,
6050
6178
  pinned: generation !== null,
6051
6179
  model: options.provider.modelId,
6052
6180
  dim: options.provider.dim,
6053
- door
6181
+ door,
6182
+ oocSource
6054
6183
  }, options.targetPrecision ?? .95);
6055
6184
  }
6056
6185
  /**
@@ -7996,6 +8125,15 @@ async function drain(pending, io) {
7996
8125
  */
7997
8126
  const CHUNK_INSERT_PREFIX = "INSERT INTO chunks (tenant_id, generation, source_id, ordinal, content, chunk_hash, heading_path, heading_path_text, anchor, labels, embedding_status) VALUES ";
7998
8127
  const CHUNK_PARAMS = 10;
8128
+ /**
8129
+ * Dense character count — whitespace removed, exactly as the serving
8130
+ * predicate's `length(regexp_replace(c.content, '\s', '', 'g'))` computes it.
8131
+ * Written here rather than approximated so the ingest report and the SQL admit
8132
+ * the same chunks.
8133
+ */
8134
+ function denseLength(content) {
8135
+ return content.replace(/\s/g, "").length;
8136
+ }
7999
8137
  /** 500 rows × 10 params stays far under Postgres's 65535 bind-parameter cap. */
8000
8138
  const CHUNK_ROWS_PER_STATEMENT = 500;
8001
8139
  /**
@@ -8040,6 +8178,8 @@ async function buildStructure(client, opts) {
8040
8178
  const treeRoot = resolve(opts.treeRoot);
8041
8179
  let nSources = 0;
8042
8180
  let nChunks = 0;
8181
+ let nUnsearchable = 0;
8182
+ const unsearchableSources = [];
8043
8183
  const chunkRows = [];
8044
8184
  for (const f of manifest.files) {
8045
8185
  const src = opts.files.get(f.path);
@@ -8063,6 +8203,7 @@ async function buildStructure(client, opts) {
8063
8203
  manifest.source_commit
8064
8204
  ]);
8065
8205
  nSources += 1;
8206
+ let sourceServable = 0;
8066
8207
  for (const chunk of chunkText(body)) {
8067
8208
  chunkRows.push([
8068
8209
  tenantId,
@@ -8077,7 +8218,10 @@ async function buildStructure(client, opts) {
8077
8218
  JSON.stringify({ source_type: chunk.sourceType })
8078
8219
  ]);
8079
8220
  nChunks += 1;
8221
+ if (chunk.sourceType === "prose" && denseLength(chunk.content) >= 24) sourceServable += 1;
8222
+ else nUnsearchable += 1;
8080
8223
  }
8224
+ if (sourceServable === 0 && nChunks > 0) unsearchableSources.push(sid);
8081
8225
  }
8082
8226
  for (let i = 0; i < chunkRows.length; i += CHUNK_ROWS_PER_STATEMENT) {
8083
8227
  const slice = chunkRows.slice(i, i + CHUNK_ROWS_PER_STATEMENT);
@@ -8116,7 +8260,9 @@ async function buildStructure(client, opts) {
8116
8260
  sources: nSources,
8117
8261
  chunks: nChunks,
8118
8262
  carried,
8119
- pending: health.pending
8263
+ pending: health.pending,
8264
+ unsearchable: nUnsearchable,
8265
+ unsearchableSources
8120
8266
  };
8121
8267
  }
8122
8268
  /**
@@ -8299,6 +8445,8 @@ async function buildGeneration(pool, instance, options) {
8299
8445
  ready: true,
8300
8446
  centroids: 0,
8301
8447
  flipped: false,
8448
+ unsearchable: 0,
8449
+ unsearchableSources: [],
8302
8450
  refusal: null,
8303
8451
  health: {
8304
8452
  ok: true,
@@ -8403,6 +8551,8 @@ async function buildGeneration(pool, instance, options) {
8403
8551
  ready: fin.ready,
8404
8552
  centroids: fin.centroids,
8405
8553
  flipped: fin.flipped,
8554
+ unsearchable: stats.unsearchable,
8555
+ unsearchableSources: stats.unsearchableSources,
8406
8556
  refusal: fin.refusal,
8407
8557
  health: fin.health,
8408
8558
  unchanged: false
@@ -8742,6 +8892,16 @@ async function ingestCommand(args) {
8742
8892
  }
8743
8893
  process.stdout.write(sourceCommit === "unspecified" ? "source: unspecified — knowledge/ is not in a git repository, so this generation cannot be traced back to a reviewed commit\n" : `source: ${sourceCommit}\n`);
8744
8894
  process.stdout.write(`ingest: generation ${report.generation} — ${report.nodes} nodes, ${report.chunks} chunks; embedded ${report.embedded}, carried ${report.carried}, failed ${report.failed}\n`);
8895
+ if (report.unsearchable > 0) {
8896
+ const pct = Math.round(report.unsearchable / Math.max(report.chunks, 1) * 100);
8897
+ process.stdout.write(` not searchable: ${report.unsearchable} of ${report.chunks} chunk(s) (${pct}%) are shorter than the navigation threshold — stored and readable, but no search returns them\n`);
8898
+ if (report.unsearchableSources.length > 0) {
8899
+ const named = report.unsearchableSources.slice(0, 10).join(", ");
8900
+ const more = report.unsearchableSources.length - Math.min(10, report.unsearchableSources.length);
8901
+ process.stdout.write(` FOUND ONLY BY NAME: ${named}${more > 0 ? `, and ${more} more` : ""} — no searchable chunk at all; lengthen these sections or read them by slug
8902
+ `);
8903
+ }
8904
+ }
8745
8905
  if (report.refusal !== null) return fail$1(REFUSED, report.refusal);
8746
8906
  const governance = await withPool(dsn, (pool) => assertGovernanceServable(pool, instance, report.generation).then(() => null, (error) => error instanceof Error ? error.message : String(error)));
8747
8907
  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.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
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",
@@ -65,8 +65,13 @@ Stand it up in this order (each step's errors explain how to fix themselves):
65
65
  `provider: gemini`, `model: gemini-embedding-001`, `dim: 1536`; write it out
66
66
  only to pin the space explicitly or to change it — and note that model and
67
67
  dim are the PERSISTED identity of the embedding space, so changing either
68
- later means re-embedding the whole corpus. Keep `dim` at or below 2000: the
69
- pgvector HNSW index refuses more, and `gemini-embedding-001` can emit 3072.
68
+ later means re-embedding the whole corpus. Keep `dim` at or below 2000 the
69
+ schema indexes a `vector` column directly and pgvector's HNSW takes a
70
+ `vector` to 2000. `gemini-embedding-001` emits 3072 by default, so ksor asks
71
+ it for 1536. Google's published MTEB table runs 128–2048 and is flat at the
72
+ top of it — 1536 scores 68.17 against 2048's 68.16 — so there is no gradient
73
+ to climb toward the ceiling; going the other way, 768 costs 0.18 if you want
74
+ the storage back.
70
75
 
71
76
  Leave `retrieval:` out for now — the gate is off and the server says so.
72
77
  Turning it on is step 4, AFTER the record is serving.
@@ -227,7 +232,7 @@ bind, set `KSOR_ALLOWED_HOSTS` / `KSOR_ALLOWED_ORIGINS`; on more than one
227
232
  replica, set a shared `KSOR_SNAPSHOT_KEYS` (unset ⇒ a per-process key, so a
228
233
  search token minted by one replica fails on another).
229
234
 
230
- Two things worth being deliberate about:
235
+ Three things worth being deliberate about:
231
236
 
232
237
  - **`KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` serves your whole record to anyone
233
238
  who can reach the port.** It exists for deployments fronted by your own
@@ -244,6 +249,62 @@ Two things worth being deliberate about:
244
249
  `KSOR_JWKS_URL` only to override that, or when your SSO publishes no metadata
245
250
  at all.
246
251
 
252
+ ### What a CLIENT has to do
253
+
254
+ Once the SSO door is configured (the three variables above), the server is an
255
+ OAuth **Resource Server**, which means a client is not told the authorization
256
+ server — it discovers it. Nothing here needs configuring beyond those variables;
257
+ this is what your agents will experience, and what to check when one cannot
258
+ connect. With `KSOR_AUTH_DISABLED=1` — the local default `.env.example` ships —
259
+ none of it applies: there is no challenge and the metadata document answers 404,
260
+ because there is no authorization server to point at.
261
+
262
+ 1. The client calls `POST /mcp` with no token and gets **401** carrying
263
+
264
+ ```
265
+ WWW-Authenticate: Bearer resource_metadata="https://<your-host>/.well-known/oauth-protected-resource/mcp"
266
+ ```
267
+
268
+ That header is the whole handshake: it names a DOCUMENT, not the resource.
269
+
270
+ 2. The client fetches that document and finds the record's resource identifier
271
+ and its authorization server:
272
+
273
+ ```json
274
+ {
275
+ "resource": "https://<your-host>/mcp",
276
+ "authorization_servers": ["https://your-sso.example.com"]
277
+ }
278
+ ```
279
+
280
+ Those two values are `KSOR_MCP_RESOURCE_URL` and `KSOR_SSO_URL`.
281
+
282
+ 3. The client gets a token from that authorization server, asking for THIS
283
+ record as the resource (RFC 8707: `resource=https://<your-host>/mcp`), and
284
+ sends it as `Authorization: Bearer <token>`.
285
+
286
+ Every 401 carries that same header, not just the first one — including the one
287
+ your clients will hit most often, a token that expired mid-conversation. It
288
+ arrives as `Bearer error="invalid_token", resource_metadata="…"`, so a client
289
+ knows to refresh rather than to retry the dead token. A **503** is deliberately
290
+ _not_ challenged: an unreachable key set is our outage, not your token's fault,
291
+ and telling a client to re-authenticate over it would send a perfectly good user
292
+ back through a login.
293
+
294
+ The one thing that goes wrong here goes wrong quietly: a token minted for a
295
+ different audience is a perfectly valid token, and this door rejects it. The
296
+ `aud` claim must match one of `KSOR_JWT_ALLOWED_AUDIENCES` — normally the same
297
+ value as `KSOR_MCP_RESOURCE_URL` — because a bearer accepted for any audience is
298
+ a bearer stolen from one service and replayed against this one. If a client
299
+ authenticates fine and still gets 401, compare its token's `aud` against that
300
+ list before looking anywhere else.
301
+
302
+ Tokens must be signed **RS256**; nothing else is accepted, and opaque tokens are
303
+ not supported (there is no introspection call). When your SSO rotates its
304
+ signing keys, an unknown key id answers **503**, not 401 — the token may well be
305
+ good and the door's key set merely stale, so a client should retry rather than
306
+ send the user back through a login.
307
+
247
308
  ## Withdrawing a document — `ksor takedown`
248
309
 
249
310
  A takedown is the one governance act that must reach EVERY surface at once.
@@ -89,12 +89,38 @@ function readInstanceScope(): string | null {
89
89
  for (const para of afterHeading.split(/\n[ \t]*\n/)) {
90
90
  const one = para.trim().replace(/\s+/g, " ");
91
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;
92
+ return sentence(one);
94
93
  }
95
94
  return null;
96
95
  }
97
96
 
97
+ /**
98
+ * The MCP registry schema caps `ServerDetail.description` at **100 characters**
99
+ * (`ServerDetail.description.maxLength`, 2025-12-11). Over it, the document a
100
+ * validating client reads is invalid — and the failure was shaped exactly
101
+ * wrong: the unfilled placeholder is 88 characters and validates, so the
102
+ * document became invalid the moment an owner did the thing the scaffold asks
103
+ * for and wrote a real scope sentence. Silent, and only for real records.
104
+ *
105
+ * A hard truncation would publish a sentence cut mid-word, so this trims at a
106
+ * word boundary and marks it, and the whole description is assembled inside the
107
+ * budget rather than clipped after the fact.
108
+ */
109
+ const DESCRIPTION_MAX = 100;
110
+
111
+ function sentence(one: string): string {
112
+ const first = /^(.+?[.!?])(\s|$)/.exec(one)?.[1] ?? one;
113
+ return first;
114
+ }
115
+
116
+ /** Fit `text` inside `max`, breaking on a word rather than mid-word. */
117
+ function fit(text: string, max: number): string {
118
+ if (text.length <= max) return text;
119
+ const cut = text.slice(0, max - 1);
120
+ const at = cut.lastIndexOf(" ");
121
+ return `${(at > max / 2 ? cut.slice(0, at) : cut).replace(/[,;:.\s]+$/, "")}\u2026`;
122
+ }
123
+
98
124
  /** null until the owner has written one — never a guess. */
99
125
  export const appScope: string | null = readInstanceScope();
100
126
 
@@ -103,9 +129,11 @@ export const appScope: string | null = readInstanceScope();
103
129
  * registry document and anything else that needs one cannot drift apart.
104
130
  */
105
131
  export function recordDescription(): string {
106
- return appScope === null
107
- ? `${appTitle} — its owner has not yet described what this record covers.`
108
- : `${appTitle} — ${appScope}`;
132
+ const whole =
133
+ appScope === null
134
+ ? `${appTitle} — its owner has not yet described what this record covers.`
135
+ : `${appTitle} — ${appScope}`;
136
+ return fit(whole, DESCRIPTION_MAX);
109
137
  }
110
138
 
111
139
  /**