@panaversity/ksor 0.0.22 → 0.0.24

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.
@@ -0,0 +1,144 @@
1
+ import { CallToolResult, McpServer as McpServer$1, StandardSchemaWithJSON } from "@modelcontextprotocol/server";
2
+ import { z as z$1 } from "zod";
3
+ import pg from "pg";
4
+ //#region ../content-gateway/dist/gateway-api-D9COH1tR.d.mts
5
+ //#region src/instructions.d.ts
6
+ /**
7
+ * Has the owner said what this record is FOR yet?
8
+ *
9
+ * The MCP door already answers honestly when they have not — it replaces the
10
+ * template with a plain statement that the scope is unstated. But the operator
11
+ * starting the server was told nothing, so a record serving with no declared
12
+ * identity looked exactly like one that had been described. The boot report is
13
+ * where that belongs, beside the abstention posture: both are answers to "how
14
+ * much should I trust what this thing says".
15
+ */
16
+ declare function recordIsUndescribed(authored: string): boolean;
17
+ declare function composeInstructions(authored: string): string;
18
+ //#endregion
19
+ //#region ../content/dist/index.d.mts
20
+ //#endregion
21
+ //#region src/lib/abstain.d.ts
22
+ /**
23
+ * The abstention gates (oracle SC/lib/abstain.py, exact). "Not in this
24
+ * corpus" is a correct answer, never an error, never a licence to fall back
25
+ * on model knowledge.
26
+ *
27
+ * Floors come from the instance frontmatter, never module globals, and are
28
+ * calibrated per corpus per embedding space — never copy a calibrated
29
+ * constant between corpora.
30
+ */
31
+ interface AbstainConfig {
32
+ /**
33
+ * A calibrated number gates (abstain below it); `null` = no gate declared,
34
+ * honest absence surfaced on /health; `"uncalibrated"` = a floor was
35
+ * DECLARED but not measured, which REFUSES every serve until it is pasted
36
+ * (the "fail closed once a floor is declared" invariant, representable).
37
+ */
38
+ readonly vectorFloor: number | null | "uncalibrated";
39
+ /**
40
+ * Degraded-path ts_rank_cd floor; null = abstain only on zero matches.
41
+ * Recorded negative result (oracle, measured on 416 in-corpus gold + 38
42
+ * OOC probes): ts_rank_cd does NOT separate in/out-of-corpus — every
43
+ * leak-stopping floor false-abstained 67–98% (0.1333 → FA 255/379;
44
+ * 0.7 → FA 355/379). The shipped instance keeps keyword_floor null;
45
+ * never set one by intuition, recalibrate per corpus.
46
+ */
47
+ readonly keywordFloor: number | null;
48
+ }
49
+ interface ContentInstance {
50
+ /** The corpus identity — instance.md's `name:` (the path/name IS the identity). */
51
+ readonly name: string;
52
+ readonly corpusId: string;
53
+ readonly tenantId: string;
54
+ /** The NAME of the env var holding the DSN; the composition root resolves it. */
55
+ readonly dsnEnv: string;
56
+ readonly abstain: AbstainConfig;
57
+ readonly maximumResponseCharacters: number;
58
+ /** The authored agent-surface instructions (the body, edge-trimmed). */
59
+ readonly instructions: string;
60
+ /** Transport name (registry key, never persisted). */
61
+ readonly embeddingProvider: string;
62
+ /** The record's reader audiences, least- to most-restricted; empty = no model. */
63
+ /** The Postgres text-search configuration the keyword arm stems with. */
64
+ readonly textSearchConfig: string;
65
+ readonly audiences: readonly string[];
66
+ /** The tier a document takes when it declares none; null = none declared. */
67
+ readonly defaultVisibility: string | null;
68
+ /** model + dim are the persisted IDENTITY of the embedding space. */
69
+ readonly embeddingModel: string;
70
+ readonly embeddingDim: number;
71
+ }
72
+ interface KeyRing {
73
+ readonly keys: ReadonlyMap<string, Buffer>;
74
+ /** Mint with the active key; keep validating old ids until their tokens age out (≤ TTL). */
75
+ readonly active: string;
76
+ }
77
+ declare const MAX_SEARCH_K = 50;
78
+ interface ServiceContext {
79
+ readonly pool: pg.Pool;
80
+ readonly instance: ContentInstance;
81
+ readonly ring: KeyRing;
82
+ /** sha256 of instance.md — the deployment binding snapshots carry. */
83
+ readonly instanceDigest: string;
84
+ /** The query-embed pipeline (cache + breaker + timeout live behind it); returns a pgvector literal or a raw vector. */
85
+ readonly embedQuery: (query: string) => Promise<readonly number[] | string>;
86
+ /** The verified caller, or null → audited as "anonymous". */
87
+ readonly actor?: () => string | null;
88
+ /**
89
+ * The audience tier this door serves. null = the record's least-privileged
90
+ * tier, which is the safe default: a door that cannot establish who is asking
91
+ * must not hand out the restricted half of the record. Ignored entirely when
92
+ * the instance declares no `audiences:` model.
93
+ */
94
+ readonly audience?: string | null;
95
+ }
96
+ /**
97
+ * The largest outline a caller may ASK for. The tool schema and the service
98
+ * both derive from it, so the ceiling is one number rather than three
99
+ * hand-copied ones.
100
+ */
101
+ declare const MAX_OUTLINE_LIMIT = 5e3;
102
+ //#endregion
103
+ //#region src/tools.d.ts
104
+ /**
105
+ * The framework text every tool description must carry.
106
+ *
107
+ * A registration file puts its own prose ABOVE one of these. It is a template
108
+ * literal in adopter code, so nothing stops someone omitting it — which is why
109
+ * the door verifies its own served surface at boot rather than trusting that
110
+ * nobody did.
111
+ */
112
+ declare const FLOOR: Readonly<Record<"search" | "outline" | "read", string>>;
113
+ /** Every ksor tool is a read: no writes, safe to retry, closed world. */
114
+ declare const READ_ONLY: {
115
+ readonly readOnlyHint: true;
116
+ readonly destructiveHint: false;
117
+ readonly idempotentHint: true;
118
+ readonly openWorldHint: false;
119
+ };
120
+ declare const SEARCH_OUTPUT: StandardSchemaWithJSON;
121
+ declare const OUTLINE_OUTPUT: StandardSchemaWithJSON;
122
+ declare const READ_OUTPUT: StandardSchemaWithJSON;
123
+ interface SearchArgs {
124
+ readonly query: string;
125
+ readonly k: number;
126
+ }
127
+ declare function searchHandler(ctx: ServiceContext): (args: SearchArgs) => Promise<CallToolResult>;
128
+ interface OutlineArgs {
129
+ readonly node?: string | undefined;
130
+ readonly depth?: number | undefined;
131
+ readonly limit: number;
132
+ readonly offset?: number | undefined;
133
+ }
134
+ declare function outlineHandler(ctx: ServiceContext): (args: OutlineArgs) => Promise<CallToolResult>;
135
+ interface ReadArgs {
136
+ readonly slug: string;
137
+ readonly heading?: string | undefined;
138
+ readonly from_heading?: string | undefined;
139
+ readonly snapshot_token?: string | undefined;
140
+ readonly token_budget?: number | undefined;
141
+ }
142
+ declare function readHandler(ctx: ServiceContext): (args: ReadArgs) => Promise<CallToolResult>;
143
+ //#endregion
144
+ export { FLOOR, MAX_OUTLINE_LIMIT, MAX_SEARCH_K, McpServer$1 as McpServer, OUTLINE_OUTPUT, type OutlineArgs, READ_ONLY, READ_OUTPUT, type ReadArgs, SEARCH_OUTPUT, type SearchArgs, type ServiceContext, composeInstructions, outlineHandler, readHandler, recordIsUndescribed, searchHandler, z$1 as z };
@@ -0,0 +1,2 @@
1
+ import { L as z$1, O as readHandler, a as MAX_OUTLINE_LIMIT, d as READ_OUTPUT, f as SEARCH_OUTPUT, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, o as MAX_SEARCH_K, r as FLOOR, s as McpServer$1, u as READ_ONLY, w as outlineHandler, y as composeInstructions } from "./gateway-api-BF06IsJ--D-eI--yB.mjs";
2
+ export { FLOOR, MAX_OUTLINE_LIMIT, MAX_SEARCH_K, McpServer$1 as McpServer, OUTLINE_OUTPUT, READ_ONLY, READ_OUTPUT, SEARCH_OUTPUT, composeInstructions, outlineHandler, readHandler, recordIsUndescribed, searchHandler, z$1 as z };
@@ -0,0 +1,233 @@
1
+ ---
2
+ title: Deploying
3
+ status: draft
4
+ ---
5
+
6
+ # Deploying a Knowledge System of Record
7
+
8
+ A KSoR publishes two surfaces from one record, and they deploy differently
9
+ because they are different things:
10
+
11
+ | surface | what it is | how it deploys |
12
+ | ------------ | ------------------------------------------------ | -------------------------------------- |
13
+ | **the site** | a fully static export — HTML, `llms.txt`, search | upload a folder to any static host |
14
+ | **the door** | the MCP server, a live process reading Postgres | run a container that listens on a port |
15
+
16
+ `ksor init` emits everything both need: `Dockerfile` and `.dockerignore` for the
17
+ door, and a `vercel.json` that puts the two behind one domain. This page is the
18
+ walkthrough, executed rather than described.
19
+
20
+ **Publishing is a third thing, and it is not on this page's critical path.**
21
+ Neither surface ingests. The door serves whatever generation is already active
22
+ in the database, and the site renders `knowledge/` from the repository. Getting
23
+ content INTO the database is [ingesting.md](./ingesting.md), and it is a deploy
24
+ step you run, never something a booting container does.
25
+
26
+ ## The shape
27
+
28
+ ```
29
+ ┌───────────────────────────────┐
30
+ a reader ────────▶ / the site │ static files
31
+ │ /docs/… │
32
+ ├───────────────────────────────┤
33
+ an agent ────────▶ /mcp the door │ container ──▶ Postgres
34
+ │ /health /ready │
35
+ │ /.well-known/oauth-… │
36
+ └───────────────────────────────┘
37
+ ```
38
+
39
+ One domain, two services. An agent that finds `https://your-host/mcp` and a
40
+ person who opens `https://your-host/` are reading the same record.
41
+
42
+ ## The container is the portable artifact
43
+
44
+ The emitted `Dockerfile` names no host. It installs the pinned
45
+ `@panaversity/ksor` from your `package.json`, honours `$PORT`, and runs
46
+ `ksor serve`:
47
+
48
+ ```sh
49
+ docker build -t my-record .
50
+ docker run --rm -p 8080:80 --env-file .env my-record
51
+ ```
52
+
53
+ That runs on Cloud Run, Fly, Render, ECS, Kubernetes, or a VPS with no changes.
54
+ `vercel.json` **points at this same file** rather than replacing it, which is
55
+ what keeps the host a choice — the artifact is yours, and moving it is a
56
+ redeploy, not a rewrite.
57
+
58
+ What the image deliberately does NOT contain (see `.dockerignore`):
59
+
60
+ - **`.env`** — configuration arrives from the environment at run time. A DSN
61
+ baked into a layer is published to anyone who can pull the image.
62
+ - **`knowledge/`** — the door reads Postgres. Copying the corpus in would
63
+ suggest the container reads it, and it never does.
64
+ - **`system/`** — that is the other surface, built and hosted separately.
65
+
66
+ ## Deploying both surfaces to Vercel
67
+
68
+ The emitted `vercel.json` declares both services and routes between them:
69
+
70
+ ```json
71
+ {
72
+ "services": {
73
+ "site": { "root": ".", "buildCommand": "pnpm build", "outputDirectory": "system/site/out" },
74
+ "door": { "root": ".", "runtime": "container", "entrypoint": "Dockerfile" }
75
+ },
76
+ "rewrites": [
77
+ { "source": "/mcp(.*)", "destination": { "service": "door" } },
78
+ { "source": "/.well-known/oauth-protected-resource(.*)", "destination": { "service": "door" } },
79
+ { "source": "/health", "destination": { "service": "door" } },
80
+ { "source": "/ready", "destination": { "service": "door" } },
81
+ { "source": "/(.*)", "destination": { "service": "site" } }
82
+ ]
83
+ }
84
+ ```
85
+
86
+ Deploy from the repository **root**, never from `system/site/`:
87
+
88
+ ```sh
89
+ vercel deploy --prod
90
+ ```
91
+
92
+ Two things about this file are worth knowing before you edit it.
93
+
94
+ **The catch-all must stay last.** Rewrites match in order, so a `/(.*)` rule
95
+ moved above the door's routes silently sends `POST /mcp` to the static site,
96
+ where it becomes a 404 and reads like the door is down.
97
+
98
+ **Do not add a project-level `trailingSlash`.** The site's own Next config
99
+ already sets it and exports `index.html` directories, so it buys nothing — and
100
+ at the project level it applies to the door too, where it redirects `POST /mcp`
101
+ to `/mcp/` with a 308. Found exactly that way: every door route 308ed until the
102
+ setting came out.
103
+
104
+ ## Configuration
105
+
106
+ The door takes everything from the environment. Set these on the deployment, not
107
+ in a file:
108
+
109
+ | variable | why |
110
+ | ----------------------- | ------------------------------------------------------------------------ |
111
+ | `KSOR_DB_URL` | the record's Postgres store |
112
+ | `GEMINI_API_KEY` | embeds the incoming query, so retrieval works at all |
113
+ | `KSOR_SNAPSHOT_KEYS` | `kid=secret` — **required in practice**, see below |
114
+ | `KSOR_ALLOWED_HOSTS` | the host you serve on (DNS-rebind defence) |
115
+ | `KSOR_MCP_RESOURCE_URL` | this record's canonical URL, e.g. `https://your-host/mcp` |
116
+ | auth, one of two | `KSOR_SSO_URL` + audiences, **or** `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` |
117
+
118
+ `KSOR_SNAPSHOT_KEYS` is listed as a production knob but behaves as a
119
+ requirement on any host that scales to zero. Unset, the signing key is generated
120
+ **per process** — so a citation minted before a scale-down stops validating
121
+ after it, with a single instance and no replicas involved. Generate one:
122
+
123
+ ```sh
124
+ KSOR_SNAPSHOT_KEYS="k1=$(openssl rand -hex 32)"
125
+ ```
126
+
127
+ The value is `kid=secret`, not `kid:secret`, and the first entry is the active
128
+ one. Multiple entries let you rotate without invalidating outstanding
129
+ citations.
130
+
131
+ ### The site build needs the DSN too
132
+
133
+ Once `instance.md` declares a `database:` block, `pnpm build` runs
134
+ `pnpm export-denylist` first — it asks the database what has been withdrawn and
135
+ writes `.ksor-denylist.json`. Without `KSOR_DB_URL` the build **refuses**:
136
+
137
+ ```
138
+ KSOR_DB_URL is unset, and instance.md declares a database
139
+ why: a takedown lives in that database. Without it this build cannot tell
140
+ 'nothing is denied' from 'nobody asked'
141
+ ```
142
+
143
+ That refusal is the design working. A takedown reaches the door instantly (it is
144
+ a row) and reaches the site at its next build (it reads a file), so a site built
145
+ without the DSN would keep publishing what the door already refuses. Set
146
+ `KSOR_DB_URL` on the site build as well as on the door.
147
+
148
+ ## Authorization, or the deliberate absence of it
149
+
150
+ `ksor serve` **refuses to boot unauthenticated on a public bind.** There is no
151
+ auth-off default, and that refusal is the last real step of a deployment. Two
152
+ ways past it:
153
+
154
+ - **Configure the SSO door** — `KSOR_SSO_URL`, `KSOR_MCP_RESOURCE_URL`,
155
+ `KSOR_JWT_ALLOWED_AUDIENCES`. Worked recipes for two different authorization
156
+ servers: [authorization.md](./authorization.md).
157
+ - **Set `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1`** — a deliberate decision that
158
+ serves your whole record to anyone who can reach the port. Correct for a
159
+ genuinely public record, or behind your own gateway. Never as a way to get a
160
+ deploy green.
161
+
162
+ Check which one you got. `/health` says so plainly:
163
+
164
+ ```json
165
+ {
166
+ "corpus_id": "book",
167
+ "abstain_gate": "OFF (no floor declared — will not refuse out-of-corpus questions)",
168
+ "embedding_space": "gemini-embedding-001/d1536 ok",
169
+ "auth": "disabled"
170
+ }
171
+ ```
172
+
173
+ `"auth":"disabled"` on a public host means the second option is in effect.
174
+
175
+ ## What a cold start costs
176
+
177
+ The door is built for a runtime that suspends it. It holds **no idle database
178
+ connections** — the pool minimum is 0 and an unused connection closes after 10s
179
+ — so a quiet instance keeps nothing open against a serverless Postgres, and the
180
+ first request after a suspend both wakes the database and retries the connect
181
+ rather than failing.
182
+
183
+ Measured against a live deployment on Vercel, Neon behind it, an 81-document
184
+ record of 6,963 chunks:
185
+
186
+ | | |
187
+ | -------------------------------------- | --------- |
188
+ | warm request | **~1.7s** |
189
+ | cold start (container + database wake) | **~9.1s** |
190
+
191
+ Most of the cold number is the two wakes, not ksor. If that matters for your
192
+ readers, the levers are your host's minimum instance count and your database's
193
+ suspend delay — both outside this tool.
194
+
195
+ `SIGTERM` drains and exits within 8s (`KSOR_DRAIN_TIMEOUT_MS`), inside the ~10s
196
+ a scale-to-zero runtime usually allows before `SIGKILL`.
197
+
198
+ ## Verifying a deployment
199
+
200
+ In order, because each answers a different question:
201
+
202
+ ```sh
203
+ B=https://your-host
204
+
205
+ curl -s $B/health # the door booted, and on which posture
206
+ curl -s $B/ready # the database answers
207
+ curl -s -o /dev/null -w '%{http_code}\n' $B/ # the site is served
208
+
209
+ curl -s -X POST $B/mcp \
210
+ -H 'content-type: application/json' \
211
+ -H 'accept: application/json, text/event-stream' \
212
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
213
+ "protocolVersion":"2025-11-25","capabilities":{},
214
+ "clientInfo":{"name":"probe","version":"1"}}}'
215
+ ```
216
+
217
+ If `/health` answers but a search returns nothing, the door is fine and the
218
+ **record was never published** — go to [ingesting.md](./ingesting.md). A first
219
+ deploy with no ingest serves an empty record, which is the single most common
220
+ "it deployed but does not work".
221
+
222
+ If `/mcp` returns HTML, the catch-all rewrite is matching before the door's
223
+ route.
224
+
225
+ ## Deploying anywhere else
226
+
227
+ Nothing above is Vercel-specific except `vercel.json`. On any other host:
228
+
229
+ 1. Build the site — `pnpm build` — and upload `system/site/out/` as static files.
230
+ 2. Build and run the container, giving it the environment above.
231
+ 3. Put the door at `/mcp` on the same domain, or a different one; if it is a
232
+ different domain, set `KSOR_MCP_RESOURCE_URL` to the door's real URL, since
233
+ that value is the record's identity in a token's audience, not a guess.
package/docs/index.md CHANGED
@@ -32,6 +32,18 @@ instead of their training memory. The corpus grows with each implemented verb.
32
32
  export the manifest the site build reads), `ksor calibrate` (measure the
33
33
  abstention floor) and `ksor gc` (reap retired generations). Only `ksor dev` and `ksor build` remain designed, not
34
34
  implemented: each prints an honest notice and exits `2`.
35
+ - **[tool-surface.md](./tool-surface.md)** — shaping what agents see.
36
+ `system/gateways/content.ts` is emitted, adopter-owned and deletable; it
37
+ decides tool names, what the record says it covers, and how much of the
38
+ caller's context an answer costs. Includes the measurements.
39
+ - **[deploying.md](./deploying.md)** — getting both surfaces onto a host. The
40
+ scaffold emits a `Dockerfile` that names no vendor, and a `vercel.json` that
41
+ points at it to put the site and the MCP door behind one domain. Includes
42
+ what a cold start costs, measured.
43
+ - **[ingesting.md](./ingesting.md)** — publishing the record and keeping it
44
+ current. Serving does not publish, so a first deploy with no ingest serves
45
+ an empty record; this is the page that explains why, where ingest belongs
46
+ (never inside the container), and how the abstention gate gets turned on.
35
47
  - **[authorization.md](./authorization.md)** — putting the record behind an
36
48
  authorization server, with worked recipes for two of them, executed rather
37
49
  than written. `ksor serve` refuses to boot unauthenticated on a public bind,
@@ -0,0 +1,159 @@
1
+ ---
2
+ title: Ingesting
3
+ status: draft
4
+ ---
5
+
6
+ # Publishing the record — `ksor ingest`
7
+
8
+ Serving does not publish. That split is deliberate and it is the thing most
9
+ worth understanding before a first deployment: `ksor serve` opens a port against
10
+ whatever generation is already active, and `ksor ingest` is what makes a
11
+ generation exist. A container that ingested on boot would pay the whole record's
12
+ embedding cost on every cold start and would need write credentials at runtime.
13
+
14
+ So **a first deploy with no ingest serves an empty record.** It is not broken;
15
+ nothing was ever published to it.
16
+
17
+ ## The order, once
18
+
19
+ ```sh
20
+ pnpm provision # ksor schema --apply, then ksor grant
21
+ pnpm refresh # ksor ingest --flip, then ksor gc
22
+ ```
23
+
24
+ `provision` is separate because applying DDL and granting ingest are acts an
25
+ operator performs, not side effects of starting a server. Both are re-runnable
26
+ and report what they found — an applied schema says "already applied", an
27
+ existing grant says "already granted".
28
+
29
+ Then, after every change to `knowledge/`:
30
+
31
+ ```sh
32
+ pnpm refresh
33
+ ```
34
+
35
+ ## What a generation is
36
+
37
+ Each ingest builds a **fresh generation** — invisible until activated — and
38
+ carries every unchanged embedding forward from the last complete one, matched by
39
+ content hash. Only changed or previously-failed chunks are re-embedded.
40
+
41
+ `--flip` swaps the active pointer. The previous generation stays as a rollback
42
+ target; `ksor gc` reaps the ones nothing points at any more.
43
+
44
+ Three consequences worth knowing:
45
+
46
+ - **Re-ingest is cheap.** An ordinary edit makes a handful of provider calls,
47
+ not a corpus-worth.
48
+ - **An unchanged record costs nothing at all.** Ingest compares what it just
49
+ read against the generation already serving and, when they are identical at
50
+ the same commit, consumes no generation and writes no rows:
51
+ `unchanged — generation N already serves this corpus`.
52
+ - **Reordering is free.** Changing `order:` frontmatter re-ingests with no
53
+ embedding at all; only the ordering moved.
54
+
55
+ A flip that would drop more than `KSOR_MAX_SHRINK` of the record (default
56
+ `0.15`, i.e. 15%) **refuses**. When a large deletion is intended, say so:
57
+ `KSOR_ALLOW_SHRINK=1`.
58
+
59
+ ## Where ingest runs — not on the host
60
+
61
+ Ingest is a long job. It embeds every new chunk through the provider, and that
62
+ is bounded by the provider's throughput rather than by anything ksor does.
63
+
64
+ **Measured:** an 81-document book — 6,963 chunks — took **about 50 minutes**
65
+ against a remote Postgres on a first, cold ingest with nothing to carry forward.
66
+
67
+ Compare that with the request timeouts of the platforms people reach for first:
68
+ a serverless function caps out in the region of 300–800 seconds depending on
69
+ plan. Ingest is **an order of magnitude past that**, so it cannot be a route, a
70
+ build step, or anything else the platform is allowed to kill.
71
+
72
+ Run it where nothing is watching a clock:
73
+
74
+ - **From your machine**, with `KSOR_DB_URL` pointing at the deployment's
75
+ database. This is the honest default for a record one person maintains.
76
+ - **From CI**, as a job triggered on changes to `knowledge/` — the right shape
77
+ once more than one person edits, because it makes publishing an auditable
78
+ event rather than something someone did locally.
79
+
80
+ It does not matter whether the process that ingests is the process that serves.
81
+ They meet in the database and nowhere else.
82
+
83
+ ### If an ingest is interrupted
84
+
85
+ Nothing is corrupted — an unactivated generation is invisible by construction.
86
+ Run it again. The next attempt finds the incomplete generation's work and
87
+ carries forward everything that was already embedded, including from a
88
+ generation that was still `building` when it died, so a resumed run pays only
89
+ for what the first one had not reached.
90
+
91
+ ## Endpoints, poolers, and what actually matters
92
+
93
+ If your provider offers both a **pooled** and a **direct** endpoint (Neon's
94
+ `-pooler` host, or anything on port 6432), ksor detects which one you gave it
95
+ and says so in the boot report. That line is **informational**: it classifies,
96
+ it never transforms. The hazard it descends from — a transaction pooler and
97
+ server-side prepared statements — cannot arise here, because node-postgres does
98
+ not auto-prepare.
99
+
100
+ For the record: the 6,963-chunk ingest above ran through a **pooled** endpoint
101
+ without incident, and the same DSN serves. Use whichever your provider gives
102
+ you, and reach for the direct endpoint only if you actually hit pooler
103
+ connection limits under a parallel ingest — not pre-emptively.
104
+
105
+ `KSOR_DB_POOLED_ENDPOINT=1` forces the classification when your host name does
106
+ not announce itself.
107
+
108
+ ## Turning the abstention gate on
109
+
110
+ This is a separate act, done once, **after** the record is serving — because it
111
+ is a measurement of this corpus in this embedding space, and there is nothing to
112
+ measure until the corpus is in there.
113
+
114
+ ```sh
115
+ pnpm exec ksor calibrate --instance instance.md
116
+ ```
117
+
118
+ It prints a recommended `vector_floor`. Paste it in with the date you measured
119
+ it, and restart:
120
+
121
+ ```yaml
122
+ retrieval:
123
+ vector_floor: 0.55 # measured by ksor calibrate on 2026-08-23
124
+ ```
125
+
126
+ Until you do, `/health` reports the gate as `OFF (no floor declared — will not
127
+ refuse out-of-corpus questions)` and every search envelope carries
128
+ `gate: "off"`. That is honest absence, and an agent reading the envelope knows
129
+ an answer is not evidence of coverage.
130
+
131
+ **Never copy a floor from another corpus.** The number means nothing away from
132
+ the corpus and embedding space it was measured in.
133
+
134
+ If calibrate reports `NOT separable`, read what it names underneath: it lists
135
+ the out-of-corpus probes that scored at or above your weakest in-corpus
136
+ question. Usually one of them is a question your record actually answers, and it
137
+ belongs on the in-corpus side — moving it separates the measurement. Sometimes
138
+ it is a genuine near-miss the corpus cannot separate, and then the floor
139
+ correctly stays uncalibrated.
140
+
141
+ ## Withdrawing a document
142
+
143
+ A takedown is a row, not a file, so it reaches the door immediately:
144
+
145
+ ```sh
146
+ pnpm exec ksor takedown --instance instance.md <stable-id> \
147
+ --reason "legal request 2026-08" --actor "j.smith"
148
+ ```
149
+
150
+ `--actor` is required, and there is no default. A name taken from the
151
+ environment reads like a person and is whatever the shell happened to be
152
+ (`runner` under CI, `root` in a container) — worse than no name at all in the
153
+ one row that exists to record who did this.
154
+
155
+ **The site stops at its next build.** It reads `.ksor-denylist.json`, which
156
+ `pnpm build` refreshes via `pnpm export-denylist`. So after a takedown, rebuild
157
+ and redeploy the site, or the human surface keeps publishing what the agent
158
+ surface already refuses. See [deploying.md](./deploying.md) for why that build
159
+ needs `KSOR_DB_URL`.