@panaversity/ksor 0.0.21 → 0.0.23
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 +61 -0
- package/dist/cli.mjs +5 -1
- package/docs/deploying.md +233 -0
- package/docs/index.md +8 -0
- package/docs/ingesting.md +159 -0
- package/package.json +1 -1
- package/templates/scaffold/AGENTS.md +29 -0
- package/templates/scaffold/Dockerfile +36 -0
- package/templates/scaffold/README.md +58 -1
- package/templates/scaffold/dockerignore +26 -0
- package/templates/scaffold/gitignore +3 -0
- package/templates/scaffold/pnpm-workspace.yaml +5 -9
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +193 -40
- package/templates/scaffold/vercel.json +23 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# @panaversity/ksor
|
|
2
2
|
|
|
3
|
+
## 0.0.23
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6d306a2: Ship the deployment artifacts: `ksor init` now emits a `Dockerfile` and
|
|
8
|
+
`.dockerignore`, and its `vercel.json` declares both surfaces — the static site
|
|
9
|
+
and the MCP door — behind one domain.
|
|
10
|
+
|
|
11
|
+
The served MCP rung is a core surface, not an optional extra, so a scaffolded
|
|
12
|
+
project should be able to reach a host without anyone hand-writing a container
|
|
13
|
+
recipe first. The emitted `Dockerfile` names no vendor: it installs the pinned
|
|
14
|
+
`@panaversity/ksor`, honours `$PORT`, and runs `ksor serve`, so the same image
|
|
15
|
+
runs on Cloud Run, Fly, Render, ECS, Kubernetes or a VPS. `vercel.json` points
|
|
16
|
+
AT that file rather than replacing it, which is what keeps the host a choice —
|
|
17
|
+
moving is a redeploy, not a rewrite. A test asserts that neutrality directly,
|
|
18
|
+
and CI now builds the emitted image, boots it against real Postgres and asks it
|
|
19
|
+
a question over MCP, with no hosting vendor involved.
|
|
20
|
+
|
|
21
|
+
Verified live before shipping, and the verification paid for itself: a
|
|
22
|
+
project-level `trailingSlash: true` — harmless while the config was static-only
|
|
23
|
+
— 308-redirected every door route including `POST /mcp`. It is removed (the
|
|
24
|
+
site's own Next config already sets it where it belongs); shipping it would have
|
|
25
|
+
broken the MCP endpoint of every adopter who deployed.
|
|
26
|
+
|
|
27
|
+
Two new documents: `docs/deploying.md` (both surfaces onto a host, the
|
|
28
|
+
configuration each needs, and what a cold start costs — measured) and
|
|
29
|
+
`docs/ingesting.md` (why serving does not publish, so a first deploy with no
|
|
30
|
+
ingest serves an empty record; where ingest belongs, which is never inside the
|
|
31
|
+
container; and how the abstention gate gets turned on).
|
|
32
|
+
|
|
33
|
+
Also drops the scaffold's build-script denials for `@google/genai` and
|
|
34
|
+
`protobufjs`. The embedding provider speaks the vendor's REST API directly now,
|
|
35
|
+
so neither package is installed at all and the entries described a dependency
|
|
36
|
+
that no longer exists.
|
|
37
|
+
|
|
38
|
+
## 0.0.22
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- ef538fa: Stage the record under a lock, so a build that evaluates its config more than
|
|
43
|
+
once cannot publish a short site.
|
|
44
|
+
|
|
45
|
+
A site build evaluates `source.config.ts` in more than one process — seven of
|
|
46
|
+
them staged the record in one measured build — and staging was destructive on
|
|
47
|
+
every evaluation: delete the whole per-audience stage, refill it. Two of those
|
|
48
|
+
overlapping deleted a tree the other was copying into. Six
|
|
49
|
+
concurrent evaluations of a 150-document record failed 42 of 48 runs — `ENOENT`
|
|
50
|
+
and `EINVAL` out of `copyFileSync`, `ENOTEMPTY` out of `rmSync` despite its
|
|
51
|
+
retries, and, in 27 of the 48, no error at all: staging returned success and
|
|
52
|
+
handed the build a stage a third of the record short. That last shape is the one
|
|
53
|
+
that matters — a crash fails a build, a short stage publishes one, with
|
|
54
|
+
documents missing from `/docs`, `llms.txt` and the search index and nothing
|
|
55
|
+
saying so.
|
|
56
|
+
|
|
57
|
+
Staging now takes a lock file (`system/site/.staged-knowledge.lock`, gitignored,
|
|
58
|
+
stamped with the holder's pid so a killed build's lock is broken rather than
|
|
59
|
+
waited on), and an evaluation that finds the stage already holding exactly its
|
|
60
|
+
plan — byte for byte — leaves it alone instead of rebuilding it. Together those
|
|
61
|
+
mean the destructive path runs once per build, alone. No behaviour changes for a
|
|
62
|
+
build that was already succeeding.
|
|
63
|
+
|
|
3
64
|
## 0.0.21
|
|
4
65
|
|
|
5
66
|
### Patch Changes
|
package/dist/cli.mjs
CHANGED
|
@@ -9644,7 +9644,11 @@ function isEnvironmentError(value) {
|
|
|
9644
9644
|
}
|
|
9645
9645
|
//#endregion
|
|
9646
9646
|
//#region src/init/materialize.ts
|
|
9647
|
-
const EMITTED_NAMES = /* @__PURE__ */ new Map([
|
|
9647
|
+
const EMITTED_NAMES = /* @__PURE__ */ new Map([
|
|
9648
|
+
["gitignore", ".gitignore"],
|
|
9649
|
+
["env.example", ".env.example"],
|
|
9650
|
+
["dockerignore", ".dockerignore"]
|
|
9651
|
+
]);
|
|
9648
9652
|
const TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
9649
9653
|
".md",
|
|
9650
9654
|
".json",
|
|
@@ -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,14 @@ 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
|
+
- **[deploying.md](./deploying.md)** — getting both surfaces onto a host. The
|
|
36
|
+
scaffold emits a `Dockerfile` that names no vendor, and a `vercel.json` that
|
|
37
|
+
points at it to put the site and the MCP door behind one domain. Includes
|
|
38
|
+
what a cold start costs, measured.
|
|
39
|
+
- **[ingesting.md](./ingesting.md)** — publishing the record and keeping it
|
|
40
|
+
current. Serving does not publish, so a first deploy with no ingest serves
|
|
41
|
+
an empty record; this is the page that explains why, where ingest belongs
|
|
42
|
+
(never inside the container), and how the abstention gate gets turned on.
|
|
35
43
|
- **[authorization.md](./authorization.md)** — putting the record behind an
|
|
36
44
|
authorization server, with worked recipes for two of them, executed rather
|
|
37
45
|
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`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panaversity/ksor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.23",
|
|
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",
|
|
@@ -356,6 +356,35 @@ refuses rather than publish a document someone took down.
|
|
|
356
356
|
behind that audience's own access control, never on a public host.
|
|
357
357
|
Details in README → Deploying.
|
|
358
358
|
|
|
359
|
+
### The MCP door is a container
|
|
360
|
+
|
|
361
|
+
The other surface is a live process, so it ships as one. `Dockerfile` and
|
|
362
|
+
`.dockerignore` are yours, at the repo root, and they name no host:
|
|
363
|
+
|
|
364
|
+
```sh
|
|
365
|
+
docker build -t my-record .
|
|
366
|
+
docker run --rm -p 8080:80 --env-file .env my-record
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
That image runs on Cloud Run, Fly, Render, ECS, Kubernetes or a VPS unchanged.
|
|
370
|
+
`vercel.json` declares BOTH surfaces — a `site` service built from
|
|
371
|
+
`system/site/out/` and a `door` service pointing at that same `Dockerfile` —
|
|
372
|
+
with rewrites putting the door on `/mcp`, `/health`, `/ready` and the
|
|
373
|
+
`/.well-known/oauth-protected-resource` document, and the site on everything
|
|
374
|
+
else. Two rules if you edit it: the `/(.*)` catch-all must stay LAST, and do
|
|
375
|
+
not add a project-level `trailingSlash` — the site's Next config already sets
|
|
376
|
+
it, and at project level it 308-redirects `POST /mcp`, which breaks the door.
|
|
377
|
+
|
|
378
|
+
The image deliberately excludes `.env` (a baked DSN is published to anyone who
|
|
379
|
+
can pull the image), `knowledge/` (the door reads Postgres, never the folder)
|
|
380
|
+
and `system/` (the other surface).
|
|
381
|
+
|
|
382
|
+
**Deploying does not publish.** A container that ingested on boot would pay the
|
|
383
|
+
whole record's embedding cost on every cold start and need write credentials at
|
|
384
|
+
runtime. So `pnpm refresh` is a DEPLOY step you run — from your machine or from
|
|
385
|
+
CI — and a first deploy without it serves an empty record. Full walkthrough:
|
|
386
|
+
`node_modules/@panaversity/ksor/docs/deploying.md` and `…/docs/ingesting.md`.
|
|
387
|
+
|
|
359
388
|
## Writing knowledge
|
|
360
389
|
|
|
361
390
|
- One document per file under `knowledge/`; the path is the document's
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# The MCP door, as an ordinary container.
|
|
2
|
+
#
|
|
3
|
+
# Nothing here names a host. It installs the pinned `@panaversity/ksor` from
|
|
4
|
+
# package.json, listens on $PORT, and runs `ksor serve` — which is all Cloud Run,
|
|
5
|
+
# Fly, Render, ECS, Kubernetes or a plain VPS asks for. `vercel.json` points AT
|
|
6
|
+
# this file rather than replacing it, so the artifact stays portable and the host
|
|
7
|
+
# stays a choice.
|
|
8
|
+
#
|
|
9
|
+
# Build and run it anywhere:
|
|
10
|
+
# docker build -t my-record .
|
|
11
|
+
# docker run --rm -p 8080:80 --env-file .env my-record
|
|
12
|
+
#
|
|
13
|
+
# This image serves; it does not publish. `ksor ingest` is a write plane that
|
|
14
|
+
# runs from CI or your machine against the same database — see the deployment
|
|
15
|
+
# guide in node_modules/@panaversity/ksor/docs/deploying.md.
|
|
16
|
+
|
|
17
|
+
FROM node:24-alpine
|
|
18
|
+
|
|
19
|
+
WORKDIR /app
|
|
20
|
+
|
|
21
|
+
# Only the manifest first, so this layer caches until the ksor pin changes.
|
|
22
|
+
COPY package.json ./
|
|
23
|
+
RUN npm install --omit=dev --no-audit --no-fund
|
|
24
|
+
|
|
25
|
+
# The record's identity and configuration. The CORPUS is deliberately absent:
|
|
26
|
+
# the door serves from Postgres, and knowledge/ belongs to the build that
|
|
27
|
+
# published it — see .dockerignore.
|
|
28
|
+
COPY instance.md ./
|
|
29
|
+
|
|
30
|
+
# Most container hosts inject PORT; 80 is a sane default when nothing does.
|
|
31
|
+
ENV PORT=80
|
|
32
|
+
EXPOSE 80
|
|
33
|
+
|
|
34
|
+
# `ksor serve` refuses to boot unauthenticated on a public bind. That posture
|
|
35
|
+
# belongs to the record, not to the host, so it travels inside the image.
|
|
36
|
+
CMD ["node_modules/.bin/ksor", "serve", "--instance", "instance.md"]
|
|
@@ -60,6 +60,43 @@ unauthenticated: a local run declares `KSOR_AUTH_DISABLED=1` (already in
|
|
|
60
60
|
a public bind needs a configured SSO door instead. Any other operation is
|
|
61
61
|
`pnpm exec ksor <verb>`.
|
|
62
62
|
|
|
63
|
+
### Test the agent surface with an actual agent
|
|
64
|
+
|
|
65
|
+
The MCP door is meant to be read by agents, so check it with one rather than
|
|
66
|
+
with `curl`. With `pnpm serve` running, write `.mcp.json` at the repo root:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"mcpServers": {
|
|
71
|
+
"test-record": {
|
|
72
|
+
"type": "http",
|
|
73
|
+
"url": "http://127.0.0.1:8080/mcp"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Open a new session of your coding agent, confirm it lists the server, then ask
|
|
80
|
+
it three questions **in this order** — the order is the test:
|
|
81
|
+
|
|
82
|
+
1. Something the record covers, **phrased in words the document never uses**.
|
|
83
|
+
Retrieval is semantic, so this should still find it, and every answer should
|
|
84
|
+
arrive with a citation.
|
|
85
|
+
2. Something **adjacent but not covered** — your record's own subject area, a
|
|
86
|
+
question it genuinely does not answer. It should decline.
|
|
87
|
+
3. Something far outside the record. It should decline, and must not answer
|
|
88
|
+
from its own knowledge.
|
|
89
|
+
|
|
90
|
+
Question 2 is the one that matters. Anything can answer questions it has the
|
|
91
|
+
text for; refusing a plausible near-miss is the property that makes a system of
|
|
92
|
+
record worth trusting, and it is the one that breaks quietly.
|
|
93
|
+
|
|
94
|
+
**On a fresh record, 2 and 3 will not refuse — and that is honest, not broken.**
|
|
95
|
+
The abstention gate is off until you measure a floor for this corpus, which the
|
|
96
|
+
server says out loud at boot (`abstain OFF`) and in every search envelope
|
|
97
|
+
(`gate: "off"`). Run `pnpm exec ksor calibrate --instance instance.md` first if
|
|
98
|
+
you want to test refusal. Delete `.mcp.json`, or keep it — it holds no secret.
|
|
99
|
+
|
|
63
100
|
Then talk to your coding agent — `AGENTS.md` carries the working rules, and
|
|
64
101
|
the agent kit in `.agents/skills/` knows how to interview you
|
|
65
102
|
(`intake-interview`), convert your source material (`add-sources`), and keep
|
|
@@ -118,7 +155,9 @@ and anything that can serve files can serve it.
|
|
|
118
155
|
- **Vercel** — connect the repository (or run `vercel`); the shipped
|
|
119
156
|
`vercel.json` answers the setup interview: deploy from the repo root
|
|
120
157
|
(never pin `system/site` as the root directory — the record lives
|
|
121
|
-
outside it), build with `pnpm build`, serve `system/site/out/`.
|
|
158
|
+
outside it), build with `pnpm build`, serve `system/site/out/`. It also
|
|
159
|
+
declares the MCP **door** as a second service built from the shipped
|
|
160
|
+
`Dockerfile`, so `/mcp` and the site share one domain. If the
|
|
122
161
|
build image's pnpm predates the `packageManager` pin, set the
|
|
123
162
|
`ENABLE_EXPERIMENTAL_COREPACK=1` build environment variable.
|
|
124
163
|
**Once `instance.md` declares a `database:`, the BUILD needs the DSN too.**
|
|
@@ -143,6 +182,24 @@ writes "nothing denied" and exits 0.
|
|
|
143
182
|
- **Verify any deploy** the same way: the home page, one document page,
|
|
144
183
|
and `/llms.txt` all load; nothing else is required.
|
|
145
184
|
|
|
185
|
+
### The agent surface deploys separately
|
|
186
|
+
|
|
187
|
+
The site is files; the MCP door is a process. `Dockerfile` and `.dockerignore`
|
|
188
|
+
at the repo root build it, and they name no host — the same image runs on
|
|
189
|
+
Cloud Run, Fly, Render, ECS, Kubernetes or a VPS:
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
docker build -t my-record .
|
|
193
|
+
docker run --rm -p 8080:80 --env-file .env my-record
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
One thing surprises people: **deploying does not publish.** The door serves
|
|
197
|
+
whatever generation is already in the database, so a first deploy with no
|
|
198
|
+
`pnpm refresh` serves an empty record. Publishing is a step you run — from your
|
|
199
|
+
machine or from CI — and it is deliberately not something a booting container
|
|
200
|
+
does. The full walkthrough, including what a cold start costs and where ingest
|
|
201
|
+
belongs, is in `node_modules/@panaversity/ksor/docs/deploying.md`.
|
|
202
|
+
|
|
146
203
|
If `instance.md` declares `audiences:`, what you deploy is a **tier**.
|
|
147
204
|
Plain `pnpm build` always builds the public tier — safe for any host.
|
|
148
205
|
`KSOR_AUDIENCE=<audience> pnpm build` builds a wider tier for that
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Keep the serving image to what serving needs.
|
|
2
|
+
|
|
3
|
+
# Secrets. The image takes its configuration from the environment at RUN time;
|
|
4
|
+
# baking a .env into a layer publishes it to anyone who can pull the image.
|
|
5
|
+
.env
|
|
6
|
+
.env.*
|
|
7
|
+
!.env.example
|
|
8
|
+
|
|
9
|
+
# The corpus. The door serves from Postgres — knowledge/ was published by
|
|
10
|
+
# `ksor ingest` before this container ever started, and copying it in would
|
|
11
|
+
# suggest the container reads it. It does not.
|
|
12
|
+
knowledge/
|
|
13
|
+
|
|
14
|
+
# The website. It is the OTHER surface, built and hosted separately.
|
|
15
|
+
system/
|
|
16
|
+
|
|
17
|
+
# Build and tooling noise.
|
|
18
|
+
node_modules/
|
|
19
|
+
.git/
|
|
20
|
+
.github/
|
|
21
|
+
.agents/
|
|
22
|
+
.claude/
|
|
23
|
+
.gemini/
|
|
24
|
+
*.log
|
|
25
|
+
.DS_Store
|
|
26
|
+
.ksor-denylist.json
|
|
@@ -8,6 +8,9 @@ system/site/out/
|
|
|
8
8
|
# the per-audience copy of the record a build stages — a filtered derivative,
|
|
9
9
|
# never a second record; committing it would publish what a build excluded
|
|
10
10
|
system/site/.staged-knowledge/
|
|
11
|
+
# and the lock that keeps one evaluation of a build staging at a time; it only
|
|
12
|
+
# outlives a build that was killed mid-stage, and the next build clears it
|
|
13
|
+
system/site/.staged-knowledge.lock
|
|
11
14
|
*.tsbuildinfo
|
|
12
15
|
|
|
13
16
|
# secrets never enter the record — system/ is their future home (serve)
|
|
@@ -19,17 +19,13 @@ minimumReleaseAgeExclude:
|
|
|
19
19
|
# Dependency install scripts are denied by default. Flip an entry to true
|
|
20
20
|
# only with a comment naming what breaks without it. pnpm 11 exits 1 on every
|
|
21
21
|
# install until each build script is explicitly decided (found live:
|
|
22
|
-
# fresh-scaffold pnpm dev, 2026-08-18).
|
|
23
|
-
# denied:
|
|
22
|
+
# fresh-scaffold pnpm dev, 2026-08-18). Both below are reviewed and stay denied:
|
|
24
23
|
# esbuild, sharp — prebuilt platform binaries ship as optionalDependencies,
|
|
25
24
|
# so their install scripts are download fallbacks the site never needs.
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# workspace, 2026-08-19; serve works with both denied — verified live.)
|
|
25
|
+
#
|
|
26
|
+
# `@google/genai` and `protobufjs` were listed here too, as deps of the serve
|
|
27
|
+
# tool. The embedding provider now speaks the vendor's REST API directly, so
|
|
28
|
+
# neither package is installed at all and denying them described nothing.
|
|
31
29
|
allowBuilds:
|
|
32
30
|
esbuild: false
|
|
33
31
|
sharp: false
|
|
34
|
-
"@google/genai": false
|
|
35
|
-
protobufjs: false
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
copyFileSync,
|
|
3
|
+
existsSync,
|
|
3
4
|
mkdirSync,
|
|
4
5
|
readFileSync,
|
|
5
6
|
readdirSync,
|
|
6
7
|
rmSync,
|
|
7
8
|
statSync,
|
|
8
9
|
watch,
|
|
10
|
+
writeFileSync,
|
|
9
11
|
} from "node:fs";
|
|
10
12
|
import path from "node:path";
|
|
11
13
|
|
|
@@ -343,47 +345,191 @@ function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
|
|
|
343
345
|
return { files: [...documents, ...assets], documents: documents.length, total };
|
|
344
346
|
}
|
|
345
347
|
|
|
348
|
+
/** How often a waiter looks again. */
|
|
349
|
+
const LOCK_POLL_MS = 25;
|
|
350
|
+
/** How long a wait goes unexplained. A build that looks hung must say why. */
|
|
351
|
+
const LOCK_ANNOUNCE_MS = 10_000;
|
|
352
|
+
|
|
353
|
+
/** Synchronous, because everything on this path is: a bundler cannot await. */
|
|
354
|
+
function sleepSync(ms: number): void {
|
|
355
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function isAlive(pid: number): boolean {
|
|
359
|
+
try {
|
|
360
|
+
process.kill(pid, 0);
|
|
361
|
+
return true;
|
|
362
|
+
} catch (error) {
|
|
363
|
+
// EPERM is a process that exists and is not ours to signal.
|
|
364
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Is this lock abandoned — stamped with a process that no longer exists?
|
|
370
|
+
*
|
|
371
|
+
* Blank is the one ambiguous read: the holder writes its pid in the same call
|
|
372
|
+
* that creates the file, so a blank lock is either a holder caught between the
|
|
373
|
+
* two (microseconds) or one that died there (forever). Looking twice tells
|
|
374
|
+
* them apart, and only the second look may break a lock.
|
|
375
|
+
*/
|
|
376
|
+
function lockIsAbandoned(lockFile: string): boolean {
|
|
377
|
+
for (const look of [0, 1]) {
|
|
378
|
+
let stamp: string;
|
|
379
|
+
try {
|
|
380
|
+
stamp = readFileSync(lockFile, "utf8").trim();
|
|
381
|
+
} catch {
|
|
382
|
+
// Released while we read it; the next acquire attempt takes it.
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
const pid = Number(stamp);
|
|
386
|
+
if (Number.isInteger(pid) && pid > 0) return !isAlive(pid);
|
|
387
|
+
if (look === 0) sleepSync(LOCK_POLL_MS * 2);
|
|
388
|
+
}
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Hold the stage lock for the duration of `work`: ONE evaluation writes the
|
|
394
|
+
* stage at a time, and this file says which.
|
|
395
|
+
*
|
|
396
|
+
* A build evaluates `source.config.ts` in more than one process — SEVEN of
|
|
397
|
+
* them staged the record in one measured `next build` of a scaffolded site
|
|
398
|
+
* (2026-08-23) — and staging was destructive on every evaluation: delete the
|
|
399
|
+
* whole stage, refill it. Two of those overlapping is not a rare interleaving,
|
|
400
|
+
* it is what seven of them do — six concurrent evaluations of a 150-document
|
|
401
|
+
* record failed 42 of 48 runs, in four shapes: `ENOENT` and `EINVAL` out of `copyFileSync` (the reported one,
|
|
402
|
+
* issue #100), `ENOTEMPTY` out of `rmSync` *with* its retries already in
|
|
403
|
+
* place, and — 27 of the 48, the majority — no error at all: staging returned
|
|
404
|
+
* success and handed the build a stage a third of the record short.
|
|
405
|
+
*
|
|
406
|
+
* The silent shape is why this is a lock and not another retry. A crash fails
|
|
407
|
+
* a build; a short stage PUBLISHES one, with documents missing from /docs,
|
|
408
|
+
* llms.txt and the search index, and nothing anywhere saying so.
|
|
409
|
+
*
|
|
410
|
+
* `wx` is the whole primitive: create-if-absent, atomically, on every
|
|
411
|
+
* filesystem Node supports — and it stamps the holder's pid in the same call,
|
|
412
|
+
* so a waiter can tell a live holder from a killed one.
|
|
413
|
+
*
|
|
414
|
+
* Waiting on a LIVE holder is unbounded on purpose: it is another evaluation
|
|
415
|
+
* of the same build, staging the same bytes from the same record, and this
|
|
416
|
+
* build is not finished until it has. Unbounded is not silent, though — a wait
|
|
417
|
+
* long enough to look like a hang names what it is waiting for.
|
|
418
|
+
*/
|
|
419
|
+
function withStageLock<T>(stageDir: string, work: () => T): T {
|
|
420
|
+
const lockFile = `${stageDir}.lock`;
|
|
421
|
+
let waited = 0;
|
|
422
|
+
let announced = false;
|
|
423
|
+
for (;;) {
|
|
424
|
+
try {
|
|
425
|
+
writeFileSync(lockFile, String(process.pid), { flag: "wx" });
|
|
426
|
+
break;
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
429
|
+
if (lockIsAbandoned(lockFile)) {
|
|
430
|
+
rmSync(lockFile, { force: true });
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
sleepSync(LOCK_POLL_MS);
|
|
434
|
+
waited += LOCK_POLL_MS;
|
|
435
|
+
if (waited >= LOCK_ANNOUNCE_MS && !announced) {
|
|
436
|
+
announced = true;
|
|
437
|
+
console.warn(
|
|
438
|
+
`[ksor] waiting on ${path.basename(lockFile)} — another evaluation of this build is ` +
|
|
439
|
+
"staging the record. Delete that file if no build is running.",
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
return work();
|
|
446
|
+
} finally {
|
|
447
|
+
rmSync(lockFile, { force: true });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
346
451
|
/**
|
|
347
452
|
* Remove the stage, asking for the retries this exact failure needs.
|
|
348
453
|
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
* the same bytes.
|
|
454
|
+
* Callers hold the stage lock, so no OTHER evaluation is writing here — but
|
|
455
|
+
* `force: true` suppresses ENOENT and does NOT retry anything, and Node
|
|
456
|
+
* retries EBUSY / EMFILE / ENFILE / ENOTEMPTY / EPERM only when `maxRetries`
|
|
457
|
+
* is set (it defaults to zero). Those are what a Windows indexer or an
|
|
458
|
+
* antivirus scanner holding a handle looks like — not ksor, and not something
|
|
459
|
+
* the lock can serialise. Losing that race is safe: the stage is a
|
|
460
|
+
* deterministic function of the record and the denylist, so redoing it
|
|
461
|
+
* produces the same bytes.
|
|
358
462
|
*/
|
|
359
463
|
function removeStage(stageDir: string): void {
|
|
360
464
|
rmSync(stageDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
|
361
465
|
}
|
|
362
466
|
|
|
363
|
-
/**
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
);
|
|
467
|
+
/**
|
|
468
|
+
* Does the stage already hold EXACTLY this plan, byte for byte?
|
|
469
|
+
*
|
|
470
|
+
* The wipe-and-refill is the destructive half of staging, and it is pure waste
|
|
471
|
+
* whenever the answer is yes — which is every evaluation after the first in
|
|
472
|
+
* one build, since the plan is a deterministic function of the record and the
|
|
473
|
+
* denylist. Skipping it is not an optimisation: while a wipe is running there
|
|
474
|
+
* is a window in which the stage is not the record, and an evaluation that has
|
|
475
|
+
* already returned is reading it. The lock stops two writers colliding; this
|
|
476
|
+
* stops the second writer existing at all.
|
|
477
|
+
*
|
|
478
|
+
* Bytes, not names and not timestamps: the alternative is serving a previous
|
|
479
|
+
* build's copy of a document that has since been edited.
|
|
480
|
+
*/
|
|
481
|
+
function stageHolds(recordDir: string, stageDir: string, plan: StagePlan): boolean {
|
|
482
|
+
let staged: string[];
|
|
483
|
+
try {
|
|
484
|
+
staged = walkFiles(stageDir);
|
|
485
|
+
} catch {
|
|
486
|
+
return false;
|
|
381
487
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
488
|
+
if (staged.length !== plan.files.length) return false;
|
|
489
|
+
const expected = new Map(
|
|
490
|
+
plan.files.map((from) => [path.join(stageDir, path.relative(recordDir, from)), from]),
|
|
491
|
+
);
|
|
492
|
+
for (const file of staged) {
|
|
493
|
+
const from = expected.get(file);
|
|
494
|
+
if (from === undefined) return false;
|
|
495
|
+
if (!readFileSync(from).equals(readFileSync(file))) return false;
|
|
386
496
|
}
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Fill a clean stage with exactly the set this build may publish. */
|
|
501
|
+
function fillStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
|
|
502
|
+
withStageLock(stageDir, () => {
|
|
503
|
+
let plan: StagePlan;
|
|
504
|
+
try {
|
|
505
|
+
plan = planStage(recordDir, denied);
|
|
506
|
+
// An empty record is its own problem, reported by the page that renders
|
|
507
|
+
// it; an empty AUDIENCE is a misconfiguration that would otherwise
|
|
508
|
+
// surface as "the record has no documents" against a record full of them.
|
|
509
|
+
if (plan.documents === 0 && plan.total > 0) {
|
|
510
|
+
refuse(
|
|
511
|
+
"ksor-audience-empty",
|
|
512
|
+
`no document in the record is visible to the ${buildAudience} build (${plan.total} document${plan.total === 1 ? "" : "s"}, all above that tier)`,
|
|
513
|
+
"a site with nothing on it is a deploy that looks successful and serves nobody — and the record is not empty, this audience's slice of it is",
|
|
514
|
+
"build a wider audience with KSOR_AUDIENCE, lower default_visibility in instance.md, or give at least one document this tier",
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
} catch (error) {
|
|
518
|
+
// No refusal may leave the previous, more permissive stage on disk: it
|
|
519
|
+
// hands the next careless build a filtered copy nothing governs (review
|
|
520
|
+
// finding, 2026-08-19). The removal used to lead this function, which is
|
|
521
|
+
// why nothing could ask whether the stage was already correct.
|
|
522
|
+
removeStage(stageDir);
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
if (stageHolds(recordDir, stageDir, plan)) return;
|
|
526
|
+
removeStage(stageDir);
|
|
527
|
+
for (const from of plan.files) {
|
|
528
|
+
const to = path.join(stageDir, path.relative(recordDir, from));
|
|
529
|
+
mkdirSync(path.dirname(to), { recursive: true });
|
|
530
|
+
copyFileSync(from, to);
|
|
531
|
+
}
|
|
532
|
+
});
|
|
387
533
|
}
|
|
388
534
|
|
|
389
535
|
/**
|
|
@@ -421,13 +567,17 @@ function refuseVisibilityWithoutAudiences(recordDir: string): void {
|
|
|
421
567
|
* published build is always staged from scratch.
|
|
422
568
|
*/
|
|
423
569
|
function refreshStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
570
|
+
// Under the lock like every other write here: a save landing while another
|
|
571
|
+
// evaluation is refilling the stage is the same race from the other side.
|
|
572
|
+
withStageLock(stageDir, () => {
|
|
573
|
+
const permitted = new Set(planStage(recordDir, denied).files);
|
|
574
|
+
for (const staged of walkFiles(stageDir)) {
|
|
575
|
+
const from = path.join(recordDir, path.relative(stageDir, staged));
|
|
576
|
+
if (!permitted.has(from)) continue;
|
|
577
|
+
if (readFileSync(from).equals(readFileSync(staged))) continue;
|
|
578
|
+
copyFileSync(from, staged);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
431
581
|
}
|
|
432
582
|
|
|
433
583
|
let watching = false;
|
|
@@ -484,8 +634,11 @@ export function knowledgeSourceDir(): string {
|
|
|
484
634
|
// Nothing to filter — serve the record itself, the level-0 fast path.
|
|
485
635
|
// A stage left behind by an earlier model would be a filtered copy of the
|
|
486
636
|
// record nothing governs any more — removed before the refusal below can
|
|
487
|
-
// throw, so a refused build never leaves one behind either.
|
|
488
|
-
|
|
637
|
+
// throw, so a refused build never leaves one behind either. Under the lock,
|
|
638
|
+
// because two evaluations removing one tree is the `ENOTEMPTY` shape of the
|
|
639
|
+
// same race; the existence check keeps a record that never stages from
|
|
640
|
+
// taking a lock on every build.
|
|
641
|
+
if (existsSync(stageDir)) withStageLock(stageDir, () => removeStage(stageDir));
|
|
489
642
|
refuseVisibilityWithoutAudiences(recordDir);
|
|
490
643
|
return RECORD_DIR;
|
|
491
644
|
}
|
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
"services": {
|
|
4
|
+
"site": {
|
|
5
|
+
"root": ".",
|
|
6
|
+
"installCommand": "pnpm install --no-frozen-lockfile",
|
|
7
|
+
"buildCommand": "pnpm build",
|
|
8
|
+
"outputDirectory": "system/site/out"
|
|
9
|
+
},
|
|
10
|
+
"door": {
|
|
11
|
+
"root": ".",
|
|
12
|
+
"runtime": "container",
|
|
13
|
+
"entrypoint": "Dockerfile"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"rewrites": [
|
|
17
|
+
{ "source": "/mcp(.*)", "destination": { "service": "door" } },
|
|
18
|
+
{
|
|
19
|
+
"source": "/.well-known/oauth-protected-resource(.*)",
|
|
20
|
+
"destination": { "service": "door" }
|
|
21
|
+
},
|
|
22
|
+
{ "source": "/health", "destination": { "service": "door" } },
|
|
23
|
+
{ "source": "/ready", "destination": { "service": "door" } },
|
|
24
|
+
{ "source": "/(.*)", "destination": { "service": "site" } }
|
|
25
|
+
]
|
|
8
26
|
}
|