@panaversity/ksor 0.0.53 → 0.0.55

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.
@@ -171,7 +171,50 @@ That is the whole required config. `embedding:` defaults to Gemini at 1536
171
171
  dimensions, and `retrieval:` is written for you by step 3's `calibrate`. Change
172
172
  the variable name here only if you want a different one.
173
173
 
174
- ### 2. Fill in the environment
174
+ ### 2. Get a database — your agent can do this one
175
+
176
+ `.mcp.json` at the repo root declares the MCP servers this project may reach.
177
+ It ships with two, and both are yours to keep or delete — it is your file:
178
+
179
+ | server | what it is |
180
+ | -------------------------------- | ------------------------------------------------------------------------------------------------ |
181
+ | `Neon` | Neon's own hosted MCP server, for provisioning the Postgres the agent surface needs (step 2) |
182
+ | `agentfactory-system-of-record` | a KSoR record Panaversity operates, served over MCP — an example of the surface you are building |
183
+
184
+ The second is **not** your record and is not needed to run this project. It is
185
+ read-only and it answers about the Agent Factory curriculum, not about your
186
+ knowledge. Delete the entry if you would rather your agent not have it; nothing
187
+ here depends on it.
188
+
189
+ **Before you connect Neon, know what you are granting.** The Neon MCP server
190
+ acts on your Neon *account*, not on one database: an agent holding it can create
191
+ and delete projects and branches. Point it at an account you are willing to let
192
+ an agent change, review the plan it shows you before approving, and read Neon's
193
+ own documentation on the server's scopes and permissions rather than taking this
194
+ paragraph as the whole of it.
195
+
196
+ With it connected, ask your coding agent:
197
+
198
+ > Using the Neon MCP server, create a project called `<your-record>` and enable
199
+ > the pgvector extension on it. Then create a branch called `dev`, and save that
200
+ > branch's connection string to `.env` as `KSOR_DB_URL`. Never print my API key.
201
+ > Show me the plan before you run anything.
202
+
203
+ Prefer the OAuth flow. `.mcp.json` is committed and carries no secret; an API
204
+ key pasted into it would be.
205
+
206
+ Any Postgres with pgvector works — Neon is the path that has an MCP server, not
207
+ a requirement. Locally:
208
+ `docker run -e POSTGRES_PASSWORD=x -p 5432:5432 pgvector/pgvector:pg17`.
209
+
210
+ **`GEMINI_API_KEY` is the one step no agent can do for you** — no vendor mints
211
+ an API key over a protocol. Get it from
212
+ [aistudio.google.com/apikey](https://aistudio.google.com/apikey) and paste it.
213
+ **The free tier costs nothing and is enough**: embedding input on
214
+ `gemini-embedding-001` is free of charge, and a first corpus is a few thousand
215
+ tokens. This is a signup, not a bill.
216
+
217
+ ### 3. Fill in the environment
175
218
 
176
219
  ```sh
177
220
  cp .env.example .env
@@ -191,7 +234,7 @@ refusal tells you to _export_ a variable, putting it in `.env` is the same
191
234
  thing. `KSOR_AUTH=disabled-local` is required for a local run: serve refuses to
192
235
  boot unauthenticated on purpose, so a server is never open by accident.
193
236
 
194
- ### 3. Bring it up
237
+ ### 4. Bring it up
195
238
 
196
239
  ```sh
197
240
  pnpm provision # once: apply the schema, authorize ingest
@@ -230,17 +273,14 @@ separately](#the-agent-surface-deploys-separately). Any other operation is
230
273
  ### Test the door with an actual agent
231
274
 
232
275
  The MCP door is meant to be read by agents, so check it with one rather than
233
- with `curl`. With `pnpm serve` running, write `.mcp.json` at the repo root:
276
+ with `curl`. With `pnpm serve` running, **add** an entry to the `.mcp.json` you
277
+ already have — alongside `Neon`, not in place of it:
234
278
 
235
279
  ```json
236
- {
237
- "mcpServers": {
238
280
  "test-record": {
239
281
  "type": "http",
240
282
  "url": "http://127.0.0.1:8080/mcp"
241
283
  }
242
- }
243
- }
244
284
  ```
245
285
 
246
286
  **If you skipped `calibrate`, expect answers where this test wants refusals** —
@@ -264,7 +304,9 @@ Question 2 is the one that matters. Anything can answer questions it has the
264
304
  text for; refusing a plausible near-miss is the property that makes a system of
265
305
  record worth trusting, and it is the one that breaks quietly.
266
306
 
267
- Delete `.mcp.json`, or keep it — it holds no secret.
307
+ Keep the `test-record` entry or remove it — it points at loopback and holds no
308
+ secret either way. Do not delete `.mcp.json` itself: it is where `Neon` is
309
+ declared, and step 2 above needs it.
268
310
 
269
311
  ---
270
312
 
@@ -273,7 +315,15 @@ Delete `.mcp.json`, or keep it — it holds no secret.
273
315
  Both surfaces on one domain, in about ten minutes:
274
316
 
275
317
  1. **Push the repository to GitHub.**
276
- 2. **Import it in Vercel**, then **set Root Directory to `./`.** Vercel
318
+ 2. **Import it in Vercel**, then set **Framework Preset** to **`Services`** and
319
+ **Root Directory** to `./`. The preset is not cosmetic: Vercel's own guide
320
+ says a project builds as services only when the preset is `Services` AND
321
+ `vercel.json` carries a `services` key, and that "if either is missing,
322
+ Vercel falls back to its default framework detection and ignores your
323
+ services configuration" — which is the silent 404 below. No file in this
324
+ repository can set it for you.
325
+
326
+ As for Root Directory: Vercel
277
327
  auto-fills it with `system/site`, because that is where it finds a framework
278
328
  — and the build then reads `system/site/vercel.json`, which does not exist,
279
329
  and fails with `Project framework is set to "services", but no services are
@@ -282,6 +332,19 @@ declared`. The services ARE declared, in `vercel.json` at the repo root,
282
332
  container from the root `Dockerfile`.
283
333
  3. **Set three environment variables** in Vercel: `KSOR_DB_URL`,
284
334
  `GEMINI_API_KEY`, and `KSOR_AUTH=disabled-public`.
335
+ 4. **Check it actually serves**, before you tell anyone the URL. A Ready
336
+ deployment that answers 404 everywhere looks identical to a good one from
337
+ the dashboard:
338
+
339
+ ```sh
340
+ B=https://your-record.vercel.app
341
+ curl -o /dev/null -w '%{http_code}\n' "$B/" # expect 200
342
+ curl -o /dev/null -w '%{http_code}\n' "$B/llms.txt" # expect 200
343
+ curl -sI "$B/mcp" | head -1 # expect 405
344
+ ```
345
+
346
+ `/mcp` answering 405 is the door refusing a GET — that is how you know it is
347
+ routed at all, and a 404 there means the `services` block was ignored.
285
348
 
286
349
  Three things catch people here. Two are the system being deliberate; the first
287
350
  is not, and it is the one that fails without saying so:
@@ -289,10 +352,13 @@ is not, and it is the one that fails without saying so:
289
352
  - **A deployment can report Ready and serve nothing.** The build succeeds,
290
353
  Vercel collects nothing, and the deployment takes your domain and answers
291
354
  `404: NOT_FOUND` everywhere — with one build-log line as the only signal:
292
- `WARNING! Build output contains no "functions" or "static" directory`. Seen
293
- once, on a large record, and **the cause is not established**; it is *not* the
294
- Application Preset, which was measured. The emitted `vercel.json` itself is
295
- verified working on the Git path. If you hit this, the fallback is the
355
+ `WARNING! Build output contains no "functions" or "static" directory`. That
356
+ warning is the FALLBACK collector finding nothing, which is what step 2's
357
+ preset exists to prevent check it first. One measurement of ours disagrees
358
+ with the vendor's rule and is recorded in
359
+ `node_modules/@panaversity/ksor/docs/deploying.md`; the honest state is that
360
+ the preset is necessary by the vendor's documentation and has once appeared
361
+ not to be. If you hit this, the fallback is the
296
362
  classic-keys form in `node_modules/@panaversity/ksor/docs/deploying.md` — read
297
363
  it there rather than guessing, because it **moves the door off your domain**
298
364
  and `KSOR_MCP_RESOURCE_URL` and your SSO API Identifier both have to move with
@@ -10,8 +10,13 @@
10
10
  # explicitly instead of relying on a driver default that is due to change.
11
11
  KSOR_DB_URL=postgresql://user:password@host:5432/dbname
12
12
 
13
- # The embedding provider key. instance.md defaults to gemini-embedding-001.
13
+ # The embedding provider key ONE of these, whichever `embedding.provider` in
14
+ # instance.md names. It defaults to gemini, so that is the one uncommented; on
15
+ # `provider: openai`, comment this out and set OPENAI_API_KEY instead. A record
16
+ # reads exactly one of them, and `ksor serve` names the one it wanted when it
17
+ # is missing.
14
18
  GEMINI_API_KEY=
19
+ # OPENAI_API_KEY=
15
20
 
16
21
  # ── Who may ask ─────────────────────────────────────────────────────────────
17
22
  # ONE variable, and its VALUE is the decision. `ksor serve` refuses to boot
@@ -1,7 +1,8 @@
1
1
  # ksor's working directory — build output and scratch, never the record.
2
- # The two governance files inside it ARE the record (the policy and the
3
- # takedown ledger) and are un-ignored by name: the directory form `.ksor/`
4
- # cannot be negated, so the glob is `.ksor/*`.
2
+ # The governance files inside it ARE the record (the policy, the takedown
3
+ # ledger, and the phone book the site publishes names from) and are un-ignored
4
+ # by name: the directory form `.ksor/` cannot be negated, so the glob is
5
+ # `.ksor/*`.
5
6
  .ksor/*
6
7
  !.ksor/governance.yaml
7
8
  !.ksor/people.yaml
@@ -0,0 +1,13 @@
1
+ {
2
+ "mcpServers": {
3
+ "Neon": {
4
+ "type": "http",
5
+ "url": "https://mcp.neon.tech/mcp"
6
+ },
7
+ "agentfactory-system-of-record": {
8
+ "type": "http",
9
+ "url": "https://sor.panaversity.org/mcp",
10
+ "oauth": { "clientId": "zia-tutor-ai", "callbackPort": 3118 }
11
+ }
12
+ }
13
+ }
@@ -73,6 +73,8 @@ export interface ControlTexts {
73
73
  readonly policy: string;
74
74
  /** Null when `.ksor/takedowns.yaml` is not there — an empty ledger. */
75
75
  readonly ledger: string | null;
76
+ /** Null when `.ksor/people.yaml` is not there — no natural names declared. */
77
+ readonly people: string | null;
76
78
  }
77
79
 
78
80
  function sha256Text(text: string): string {
@@ -139,12 +141,17 @@ export function readLock(
139
141
  ["instance.md", lock.data.instance_sha256, sha256Text(control.instance)],
140
142
  [".ksor/governance.yaml", lock.data.policy_sha256, sha256Text(control.policy)],
141
143
  [".ksor/takedowns.yaml", lock.data.ledger_sha256, sha256Text(control.ledger ?? "")],
144
+ // The phone book publishes NAMES in place of stored actors, so an edit here
145
+ // changes the approver printed on every page. Left out, the site could
146
+ // publish one approver while the `/md/` twin stamped with the same
147
+ // `build_id` published another (review, 2026-09-01).
148
+ [".ksor/people.yaml", lock.data.people_sha256, sha256Text(control.people ?? "")],
142
149
  ] as const) {
143
150
  if (want === have) continue;
144
151
  refuse(
145
152
  "ksor-lock-stale",
146
153
  `${file} changed since ${LOCK_FILE} was written`,
147
- "the lock's build_id is a hash over the record AND the three files that govern it, so a projection under a control file the lock never saw publishes what nothing checked — a denial lifted by deleting a line would otherwise leave the lock valid",
154
+ "the lock's build_id is a hash over the record AND the four files that govern it, so a projection under a control file the lock never saw publishes what nothing checked — a denial lifted by deleting a line would otherwise leave the lock valid",
148
155
  "run `ksor build` again and commit the lock with the change; lift a denial with `ksor takedown --revoke <id>`, never by editing the ledger",
149
156
  );
150
157
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What a phone book file DECLARES, as a pure function of its text.
3
+ *
4
+ * A LEAF, like `actor-display.ts`: `people.ts` roots itself at the project via
5
+ * `shared.ts`, which reads `instance.md` at module load, so a rule living
6
+ * there could only be exercised by building a whole record on disk first. That
7
+ * is a test nobody writes, and the duplicate-key rule below went four days
8
+ * asserted only by a comment because of it.
9
+ *
10
+ * A map comes back for every malformed shape rather than an error, because a
11
+ * phone book is DECORATION: a record with an unreadable one publishes
12
+ * identifiers, which is exactly what it did before the file existed.
13
+ */
14
+
15
+ import { parseAllDocuments } from "yaml";
16
+
17
+ /**
18
+ * The phone book a file DECLARES, as a pure function of its text.
19
+ *
20
+ * Separated from the read so the rule can be exercised without a record on
21
+ * disk: the loader below is a filesystem act rooted at `projectRoot`, and a
22
+ * test that has to build a whole scaffold to ask "what do two entries for one
23
+ * actor mean?" is a test nobody writes. A map is handed back for every
24
+ * malformed shape rather than an error, because a phone book is decoration:
25
+ * a record with an unreadable one publishes identifiers, which is what it did
26
+ * before the file existed.
27
+ */
28
+ export function parsePeople(text: string): ReadonlyMap<string, string> {
29
+ try {
30
+ const docs = parseAllDocuments(text.replace(/^\ufeff/, ""), {
31
+ schema: "core",
32
+ uniqueKeys: true,
33
+ logLevel: "silent",
34
+ });
35
+ const doc = docs[0];
36
+ if (doc === undefined) return new Map();
37
+ // `uniqueKeys: true` RECORDS a duplicate rather than refusing one — `toJS()`
38
+ // still resolves last-wins. Reading the option and not the errors is what
39
+ // made the claim above false: two entries for one actor silently published
40
+ // the second person's name on the first person's governance act.
41
+ if (doc.errors.length > 0) return new Map();
42
+ const value: unknown = doc.toJS();
43
+ if (typeof value !== "object" || value === null) return new Map();
44
+ const table = (value as { people?: unknown }).people;
45
+ if (typeof table !== "object" || table === null || Array.isArray(table)) return new Map();
46
+ const out = new Map<string, string>();
47
+ for (const [actor, name] of Object.entries(table as Record<string, unknown>)) {
48
+ // A blank value is an entry someone started and left; printing "" would
49
+ // erase the identifier rather than replace it.
50
+ if (typeof name === "string" && name.trim() !== "") out.set(actor.trim(), name.trim());
51
+ }
52
+ return out;
53
+ } catch {
54
+ return new Map();
55
+ }
56
+ }
@@ -8,8 +8,9 @@
8
8
  * `human:mjs` — most of the actors in a real record — had no expressible name
9
9
  * at all. It also collided: "Bashir Aziz" and "Bashira Ziz" both derive
10
10
  * `bashiraziz`, which would print one person's name on the other's governance
11
- * act. A map has neither problem, and duplicate keys are refused by the parser
12
- * rather than resolved by whichever came last.
11
+ * act. A map has neither problem, and a duplicate key drops the whole book
12
+ * rather than resolving to whichever came last — `uniqueKeys` only REPORTS the
13
+ * duplicate, so the errors have to be read for that to be true.
13
14
  *
14
15
  * ONE-WAY. The identifier is what the record stores, cites and checks against
15
16
  * the policy; this is only what a page prints. Nothing reads a name back into
@@ -34,8 +35,7 @@
34
35
  import { readFileSync } from "node:fs";
35
36
  import path from "node:path";
36
37
 
37
- import { parseAllDocuments } from "yaml";
38
-
38
+ import { parsePeople } from "./people-rule";
39
39
  import { projectRoot } from "./shared";
40
40
 
41
41
  const PEOPLE_YAML = path.join(projectRoot, ".ksor", "people.yaml");
@@ -48,26 +48,7 @@ function loadPeople(): ReadonlyMap<string, string> {
48
48
  // Optional: its absence means "no natural names declared".
49
49
  return new Map();
50
50
  }
51
- try {
52
- const docs = parseAllDocuments(text.replace(/^/, ""), {
53
- schema: "core",
54
- uniqueKeys: true,
55
- logLevel: "silent",
56
- });
57
- const value: unknown = docs[0]?.toJS();
58
- if (typeof value !== "object" || value === null) return new Map();
59
- const table = (value as { people?: unknown }).people;
60
- if (typeof table !== "object" || table === null || Array.isArray(table)) return new Map();
61
- const out = new Map<string, string>();
62
- for (const [actor, name] of Object.entries(table as Record<string, unknown>)) {
63
- // A blank value is an entry someone started and left; printing "" would
64
- // erase the identifier rather than replace it.
65
- if (typeof name === "string" && name.trim() !== "") out.set(actor.trim(), name.trim());
66
- }
67
- return out;
68
- } catch {
69
- return new Map();
70
- }
51
+ return parsePeople(text);
71
52
  }
72
53
 
73
54
  let cached: ReadonlyMap<string, string> | null = null;
@@ -48,6 +48,7 @@ import type { Refusal } from "../record/refusal";
48
48
  const KNOWLEDGE = "knowledge/";
49
49
  const LEDGER_PATH = ".ksor/takedowns.yaml";
50
50
  const POLICY_PATH = ".ksor/governance.yaml";
51
+ const PEOPLE_PATH = ".ksor/people.yaml";
51
52
  /**
52
53
  * Everything this build may publish, as bytes at bundle-relative paths: the
53
54
  * admitted concepts (copied), their companions (copied), ONLY the assets those
@@ -177,6 +178,7 @@ function planStage(recordDir: string, development: boolean): StagePlan {
177
178
  instance: record.files.get("instance.md") ?? "",
178
179
  policy: record.files.get(POLICY_PATH) ?? "",
179
180
  ledger: record.files.get(LEDGER_PATH) ?? null,
181
+ people: record.files.get(PEOPLE_PATH) ?? null,
180
182
  },
181
183
  { draftsRequested },
182
184
  );
@@ -11,7 +11,17 @@ import path from "node:path";
11
11
  import type { RecordFiles } from "./check";
12
12
  import type { ScaffoldStructure } from "./hygiene";
13
13
 
14
- const CONTROL_FILES = ["instance.md", ".ksor/governance.yaml", ".ksor/takedowns.yaml"] as const;
14
+ // `.ksor/people.yaml` is here because the SITE reads it and publishes what it
15
+ // says — it rewrites the approver, owner and verifier printed on every document
16
+ // page. A file that changes published bytes has to reach `build_id`, or the
17
+ // human surface and the machine surface of one build can disagree about who
18
+ // approved a document with nothing going red (found by review, 2026-09-01).
19
+ const CONTROL_FILES = [
20
+ "instance.md",
21
+ ".ksor/governance.yaml",
22
+ ".ksor/people.yaml",
23
+ ".ksor/takedowns.yaml",
24
+ ] as const;
15
25
  /** Files the operating system writes behind the author's back: ignored, never reported. */
16
26
  const OS_JUNK = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
17
27
 
@@ -46,6 +46,7 @@ const lockSchema = z
46
46
  drafts: z.enum(["hidden", "shown"]),
47
47
  instance_sha256: hex64,
48
48
  policy_sha256: hex64,
49
+ people_sha256: hex64,
49
50
  ledger_sha256: hex64,
50
51
  ledger_entries: z.array(z.object({ id: z.string().min(1), digest: hex64 }).strict()),
51
52
  audiences: z
@@ -89,6 +90,8 @@ export interface Lock {
89
90
  readonly drafts: Drafts;
90
91
  readonly instance_sha256: string;
91
92
  readonly policy_sha256: string;
93
+ /** The phone book the SITE publishes names from. Empty string when absent. */
94
+ readonly people_sha256: string;
92
95
  readonly ledger_sha256: string;
93
96
  /** `(id, digest)` per ledger entry, sorted by id — the baseline the next build compares TEXT against. */
94
97
  readonly ledger_entries: readonly { readonly id: string; readonly digest: string }[];
@@ -154,6 +157,7 @@ export interface BuildIdInputs {
154
157
  readonly indexes: readonly { readonly path: string; readonly sha256: string }[];
155
158
  readonly instance_sha256: string;
156
159
  readonly policy_sha256: string;
160
+ readonly people_sha256: string;
157
161
  readonly ledger_sha256: string;
158
162
  readonly ksor_version: string;
159
163
  readonly drafts: Drafts;
@@ -181,6 +185,9 @@ export function buildIdOf(inputs: BuildIdInputs): string {
181
185
  .sort((a, b) => compare(a[0] ?? "", b[0] ?? "")),
182
186
  instance_sha256: inputs.instance_sha256,
183
187
  policy_sha256: inputs.policy_sha256,
188
+ // The site prints what this file says in place of the stored actor, so it
189
+ // is published content and belongs in the id like any other.
190
+ people_sha256: inputs.people_sha256,
184
191
  ledger_sha256: inputs.ledger_sha256,
185
192
  ksor_version: inputs.ksor_version,
186
193
  drafts: inputs.drafts,
@@ -233,6 +240,8 @@ export interface LockInput {
233
240
  readonly drafts: Drafts;
234
241
  readonly instanceText: string;
235
242
  readonly policyText: string;
243
+ /** Null when `.ksor/people.yaml` does not exist, which is every record until an owner writes one. */
244
+ readonly peopleText: string | null;
236
245
  /** Null when the ledger file does not exist. */
237
246
  readonly ledgerText: string | null;
238
247
  readonly ledgerEntries: readonly { readonly id: string; readonly digest: string }[];
@@ -269,6 +278,7 @@ export function composeLock(input: LockInput): Lock {
269
278
  .map((i) => ({ path: i.path, sha256: sha256Hex(i.text) }));
270
279
  const instance_sha256 = sha256Hex(input.instanceText);
271
280
  const policy_sha256 = sha256Hex(input.policyText);
281
+ const people_sha256 = sha256Hex(input.peopleText ?? "");
272
282
  const ledger_sha256 = sha256Hex(input.ledgerText ?? "");
273
283
  return {
274
284
  format: LOCK_FORMAT,
@@ -279,6 +289,7 @@ export function composeLock(input: LockInput): Lock {
279
289
  indexes,
280
290
  instance_sha256,
281
291
  policy_sha256,
292
+ people_sha256,
282
293
  ledger_sha256,
283
294
  ksor_version: input.ksorVersion,
284
295
  drafts: input.drafts,
@@ -291,6 +302,7 @@ export function composeLock(input: LockInput): Lock {
291
302
  drafts: input.drafts,
292
303
  instance_sha256,
293
304
  policy_sha256,
305
+ people_sha256,
294
306
  ledger_sha256,
295
307
  ledger_entries: [...input.ledgerEntries].sort((a, b) => compare(a.id, b.id)),
296
308
  audiences: { registry: [...input.audiences].sort(), viewers },