@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 +200 -0
- package/dist/cli.mjs +195 -35
- package/package.json +1 -1
- package/templates/scaffold/AGENTS.md +64 -3
- package/templates/scaffold/system/site/lib/shared.ts +33 -5
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-
|
|
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
|
-
|
|
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 ($
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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}${
|
|
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
|
|
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
|
|
3598
|
-
return
|
|
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
|
-
|
|
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-
|
|
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
|
-
/**
|
|
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}
|
|
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
|
-
|
|
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 ($
|
|
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}${
|
|
5144
|
-
`${GEN_CTE.replace("$8", "$6")}${DENIED_CTE}${JOINS}${
|
|
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 ${
|
|
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.
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
/**
|