@panaversity/ksor 0.0.7 → 0.0.8

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.
@@ -31,32 +31,53 @@ part of `pnpm dev`. The ordered path is:
31
31
 
32
32
  ```sh
33
33
  cp .env.example .env # fill in KSOR_DB_URL, GEMINI_API_KEY, KSOR_AUTH_DISABLED=1
34
- pnpm serve # schema grant ingest → serve
34
+ pnpm provision # once: apply the schema, authorize ingest
35
+ pnpm refresh # ingest the record, collect retired generations
36
+ pnpm serve # the MCP server
35
37
  ```
36
38
 
37
39
  `ksor` reads `.env` automatically — nothing to export. `KSOR_AUTH_DISABLED=1`
38
40
  is required for a local run: serve refuses to boot unauthenticated on purpose,
39
41
  so a server is never open by accident.
40
42
 
41
- Add one block to `instance.md` first `database: { dsn_env: KSOR_DB_URL }`,
42
- the NAME of the variable, never the DSN. That is the whole required config:
43
+ Uncomment the `database:` block already in `instance.md` — it names the
44
+ VARIABLE holding your DSN, never the DSN itself. That is the whole required
45
+ config:
43
46
  `embedding:` already defaults to Gemini at 1536 dimensions, and leaving
44
47
  `retrieval:` out starts you with the abstention gate off and honest about it
45
48
  (turn it on afterwards with `ksor calibrate`, once the record is serving).
46
49
 
47
- `pnpm serve` is the only command this rung needs first run, after editing
48
- `knowledge/`, or just to bring the server back. A rerun on an unchanged record
50
+ `pnpm provision` runs once it applies the schema (or migrates it forward) and
51
+ authorizes ingest, the two privileged acts that should not happen on every
52
+ boot. After that: `pnpm refresh` publishes what you have edited, and `pnpm serve`
53
+ runs the server. They are separate because publishing is an act, not a side
54
+ effect of starting a process. A rerun on an unchanged record
49
55
  costs nothing: no new generation, no embedding, no rows. Edit a document and
50
56
  the next run picks up exactly that change. `AGENTS.md` → "Serving to agents" is the
51
- full runbook; your coding agent reads it first. `pnpm serve` binds loopback
52
- with auth off for local use; a public bind fails closed unless auth is
53
- configured. Any other operation is `pnpm exec ksor <verb>`.
57
+ full runbook; your coding agent reads it first. `pnpm serve` refuses to boot
58
+ unauthenticated: a local run declares `KSOR_AUTH_DISABLED=1` (already in
59
+ `.env.example`) and binds loopback, so a server is never left open by accident;
60
+ a public bind needs a configured SSO door instead. Any other operation is
61
+ `pnpm exec ksor <verb>`.
54
62
 
55
63
  Then talk to your coding agent — `AGENTS.md` carries the working rules, and
56
64
  the agent kit in `.agents/skills/` knows how to interview you
57
65
  (`intake-interview`), convert your source material (`add-sources`), and keep
58
66
  the record well-formed (`format-checker`, also `pnpm check`).
59
67
 
68
+ ### A note on the lockfile
69
+
70
+ The committed `pnpm-lock.yaml` covers the site. It cannot cover
71
+ `@panaversity/ksor` itself, because the version pinned in `package.json` is
72
+ stamped by the CLI that scaffolded this project and could not be resolved before
73
+ that happened. So your FIRST `pnpm install` writes it — run it before you push,
74
+ and commit the result.
75
+
76
+ The deploy config already accounts for this (`vercel.json` installs with
77
+ `--no-frozen-lockfile`), and the shipped `validate.yml` runs no install. If you
78
+ add CI of your own, note that pnpm turns on `--frozen-lockfile` automatically
79
+ whenever `CI` is set.
80
+
60
81
  ## The files, explained
61
82
 
62
83
  Nothing here is decoration, and the dotfiles are not ceremony — each one is a
@@ -100,6 +121,22 @@ and anything that can serve files can serve it.
100
121
  outside it), build with `pnpm build`, serve `system/site/out/`. If the
101
122
  build image's pnpm predates the `packageManager` pin, set the
102
123
  `ENABLE_EXPERIMENTAL_COREPACK=1` build environment variable.
124
+ **Once `instance.md` declares a `database:`, the BUILD needs the DSN too.**
125
+ `pnpm build` first runs `pnpm export-denylist`, which asks the record's
126
+ database what has been withdrawn (`ksor takedown --export`) and writes
127
+ `.ksor-denylist.json` for the site to read. Without it the build stops:
128
+
129
+ ```
130
+ KSOR_DB_URL is unset, and instance.md declares a database
131
+ why: a takedown lives in that database. Without it this build cannot tell
132
+ 'nothing is denied' from 'nobody asked'
133
+ ```
134
+
135
+ That is deliberate — a site built without asking would publish a document you
136
+ withdrew. Give the build environment the same `KSOR_DB_URL` your server uses
137
+ (read access is enough), or keep the record database-free, where the export
138
+ writes "nothing denied" and exits 0.
139
+
103
140
  - **GitHub Pages, nginx, S3, anything static** — run `pnpm build` and
104
141
  upload `system/site/out/`. Hosted under a sub-path (like
105
142
  `user.github.io/repo`)? Build with `KSOR_BASE_PATH=/repo pnpm build`.
@@ -2,8 +2,11 @@
2
2
  # A real environment variable always wins over this file, so CI and production
3
3
  # keep their own values.
4
4
 
5
- # The Postgres store, named by instance.md's database.dsn_env.
6
- # Needs the pgvector extension: CREATE EXTENSION vector;
5
+ # The Postgres store, named by instance.md's database.dsn_env — uncomment the
6
+ # `database:` block there first. Needs the pgvector extension:
7
+ # CREATE EXTENSION vector;
8
+ # On a remote host prefer sslmode=verify-full, which states the TLS posture
9
+ # explicitly instead of relying on a driver default that is due to change.
7
10
  KSOR_DB_URL=postgresql://user:password@host:5432/dbname
8
11
 
9
12
  # The embedding provider key. instance.md defaults to gemini-embedding-001.
@@ -17,7 +20,84 @@ GEMINI_API_KEY=
17
20
  # KSOR_SSO_URL=https://your-sso.example.com
18
21
  # KSOR_MCP_RESOURCE_URL=https://your-host.example.com/mcp
19
22
  # KSOR_JWT_ALLOWED_AUDIENCES=https://your-host.example.com/mcp
23
+ # The JWKS endpoint defaults to <KSOR_SSO_URL>/api/auth/jwks, which is Better
24
+ # Auth's layout. EVERY other provider — Auth0, Okta, Entra, Keycloak, Cognito —
25
+ # publishes its keys elsewhere, and a wrong JWKS URL fails as a TRANSIENT fetch
26
+ # error, so every request 503s with nothing naming the cause. Say where the keys
27
+ # are:
28
+ # KSOR_JWKS_URL=https://your-sso.example.com/.well-known/jwks.json
20
29
  # Serving a public bind with auth off additionally requires
21
30
  # KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1, which serves your whole record to anyone
22
31
  # who can reach the port.
23
32
  KSOR_AUTH_DISABLED=1
33
+
34
+ # ── Production knobs ────────────────────────────────────────────────────────
35
+ # Unset is fine for a local run; each one matters once this serves for real.
36
+
37
+ # Snapshot-token signing keys, "kid=secret[,kid2=secret2]" (first is active).
38
+ # WITHOUT this the key is generated per PROCESS. Two deployments need it, not
39
+ # just one: any MULTI-REPLICA deploy (a token minted by one replica is refused
40
+ # by another), and any SCALE-TO-ZERO host, where a single replica mints a new
41
+ # key on every cold start — so a snapshot issued before a scale-down stops
42
+ # validating after it, even with one instance.
43
+ # KSOR_SNAPSHOT_KEYS=v1=<32+ random bytes>
44
+
45
+ # Which audience tier this server serves, when instance.md declares
46
+ # `audiences:`. Unset = the least-privileged tier. Same variable the site's
47
+ # per-audience build reads, so both surfaces mean the same thing.
48
+ # KSOR_AUDIENCE=public
49
+
50
+ # Bind. PORT is honoured too, for hosts that inject it.
51
+ # KSOR_MCP_HOST=127.0.0.1
52
+ # KSOR_MCP_PORT=8080
53
+
54
+ # Request caps and pool sizing.
55
+ # KSOR_MAX_INFLIGHT=64
56
+ # KSOR_MAX_BODY_BYTES=1000000
57
+ # KSOR_CONTENT_POOL_MAX=20 # ceiling under load
58
+ # By DEFAULT ksor holds NO open database connection when idle: the pool's
59
+ # minimum is 0 and an unused connection is closed after KSOR_CONTENT_POOL_IDLE_MS.
60
+ # Raising the minimum above 0 makes the server open that many connections at
61
+ # boot and KEEP them — a warm first request, paid for with sockets held open
62
+ # against your database. Opt in only if cold-start latency matters more.
63
+ # KSOR_CONTENT_POOL_MIN=0
64
+ # KSOR_CONTENT_POOL_IDLE_MS=10000
65
+ # KSOR_DRAIN_TIMEOUT_MS=8000 # hard deadline on shutdown; stay under the ~10s
66
+ # a scale-to-zero runtime allows before SIGKILL
67
+
68
+ # Public-door hardening. Both default to the safe posture; set them when a
69
+ # proxy or a browser client sits in front and you know the exact names.
70
+ # KSOR_ALLOWED_HOSTS=records.example.com Host header allow-list (DNS-rebind defence)
71
+ # KSOR_ALLOWED_ORIGINS=https://app.example.com browser Origin allow-list
72
+ # Naming the issuer adds one more check to every token, for one variable:
73
+ # KSOR_SSO_ISSUER=https://your-sso.example.com
74
+
75
+ # Connection behaviour. Unset is right for almost everyone; these exist for a
76
+ # deployment that has measured something.
77
+ # KSOR_DB_CONNECT_PER_REQUEST=1 close the connection when each call finishes,
78
+ # instead of returning it to the pool. OFF by
79
+ # default because the default measures better:
80
+ # a quiet server already holds ZERO connections
81
+ # (min 0 + a 10s idle window), and inside a
82
+ # burst the handshake is paid once. Measured on
83
+ # loopback: 2.58ms/call per-request against
84
+ # 0.13ms pooled, and a remote TLS endpoint
85
+ # widens that. Turn it on where per-request
86
+ # connection is genuinely cheaper — a local
87
+ # pooler sidecar, or a runtime that reuses no
88
+ # process between invocations.
89
+ # KSOR_DB_POOLED_ENDPOINT=1 force "this DSN is a transaction pooler" when
90
+ # the host name does not say so
91
+ # KSOR_READ_RETRY_ATTEMPTS=5 retries for a read that fails at the CONNECTION
92
+ # level — a waking serverless compute
93
+ # KSOR_READ_RETRY_BACKOFF_S=1.0 linear backoff between those attempts
94
+
95
+ # Ingest safety. A corpus that shrinks by more than this FRACTION (0.15 = 15%)
96
+ # refuses to flip; KSOR_ALLOW_SHRINK=1 is how you say a big deletion is meant.
97
+ # KSOR_MAX_SHRINK=0.15
98
+ # KSOR_ALLOW_SHRINK=1
99
+
100
+ # Embedding timeouts and cache (tuning only; unset is sensible).
101
+ # KSOR_EMBED_TIMEOUT_S=30
102
+ # KSOR_QUERY_EMBED_TIMEOUT_S=10
103
+ # KSOR_EMBED_CACHE_MAX=1000
@@ -15,3 +15,7 @@ system/site/.staged-knowledge/
15
15
  !.env.example
16
16
 
17
17
  .DS_Store
18
+
19
+ # The takedown manifest the site build reads — exported from the database,
20
+ # never hand-edited, and re-exported on every build.
21
+ .ksor-denylist.json
@@ -4,6 +4,20 @@ name: KSOR-STAMP-NAME
4
4
  ksor:
5
5
  requires: ">=KSOR-STAMP-VERSION"
6
6
  scaffolded: "KSOR-STAMP-VERSION"
7
+ # The served MCP rung needs ONE required block: the NAME of the environment
8
+ # variable holding your Postgres DSN — never the DSN itself. Uncomment it, copy
9
+ # .env.example to .env, then run `pnpm serve`. Nothing else here is required:
10
+ # `embedding:` already defaults to Gemini at 1536 dimensions, and leaving
11
+ # `retrieval:` out starts you with the abstention gate off and honest about it
12
+ # (turn it on afterwards with `ksor calibrate`, once the record is serving).
13
+ # database:
14
+ # dsn_env: KSOR_DB_URL
15
+ # Where agents reach this record's MCP surface, and the semver it publishes as.
16
+ # Both go into /.well-known/mcp/server.json, the document an agent reads to
17
+ # DISCOVER this record instead of being told the URL. Leave mcp_url out until
18
+ # the server is actually published: an invented URL is worse than none.
19
+ # mcp_url: https://records.example.com/mcp
20
+ # version: 0.1.0
7
21
  ---
8
22
 
9
23
  # Knowledge System of Record
@@ -5,12 +5,16 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "pnpm -C system/site dev",
8
- "build": "pnpm -C system/site build",
8
+ "build": "pnpm export-denylist && pnpm -C system/site build",
9
+ "export-denylist": "ksor takedown --instance instance.md --export .ksor-denylist.json",
9
10
  "check": "node .agents/skills/format-checker/check.mjs",
10
- "serve": "pnpm schema && pnpm grant && pnpm ingest && ksor serve",
11
+ "provision": "pnpm schema && pnpm grant",
12
+ "serve": "ksor serve",
13
+ "refresh": "pnpm ingest && pnpm gc",
11
14
  "schema": "ksor schema --instance instance.md --apply",
12
15
  "grant": "ksor grant --instance instance.md",
13
- "ingest": "ksor ingest --instance instance.md --knowledge knowledge --flip"
16
+ "ingest": "ksor ingest --instance instance.md --knowledge knowledge --flip",
17
+ "gc": "ksor gc --instance instance.md"
14
18
  },
15
19
  "dependencies": {
16
20
  "@panaversity/ksor": "KSOR-STAMP-VERSION"
@@ -0,0 +1,45 @@
1
+ import { appName, mcpEndpoint, mcpNamespace, recordVersion } from "@/lib/shared";
2
+
3
+ /**
4
+ * `/.well-known/mcp/server.json` — how an agent DISCOVERS this record's MCP
5
+ * surface without being told the URL.
6
+ *
7
+ * AGENTS.md's critical rule 3 names this as one of the surfaces that must not
8
+ * break, because agents finding a KSoR is how a KSoR gets used. A document a
9
+ * validating client rejects is a broken surface, so this follows the published
10
+ * schema rather than an approximation of it:
11
+ *
12
+ * $schema recommended, and it is what tells a reader which revision
13
+ * this document claims to satisfy.
14
+ * name REQUIRED, and must match ^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$ —
15
+ * a namespace, one slash, an identifier. `instance.md`'s bare
16
+ * `name:` has no slash, so it was rejected outright.
17
+ * version REQUIRED. Absent, the document failed validation on its own.
18
+ * capabilities NOT a field in the schema; it was invented here.
19
+ *
20
+ * (Checked against the 2025-12-11 schema, round-6 review of #43, which built
21
+ * the scaffold and validated the emitted file.)
22
+ *
23
+ * Static-exported alongside the site, so it is served by whatever host serves
24
+ * the record's pages and needs no runtime.
25
+ */
26
+ export const dynamic = "force-static";
27
+
28
+ const SCHEMA = "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json";
29
+
30
+ export function GET(): Response {
31
+ const endpoint = mcpEndpoint();
32
+ return Response.json(
33
+ {
34
+ $schema: SCHEMA,
35
+ name: `${mcpNamespace()}/${appName}`,
36
+ description: `The ${appName} Knowledge System of Record: governed markdown served with citations and honest abstention.`,
37
+ version: recordVersion(),
38
+ // Absent until the owner declares where the server runs — an invented
39
+ // URL is worse than none, because an agent would try it and conclude the
40
+ // record is down rather than unpublished.
41
+ ...(endpoint === null ? {} : { remotes: [{ type: "streamable-http", url: endpoint }] }),
42
+ },
43
+ { headers: { "cache-control": "public, max-age=300" } },
44
+ );
45
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The audience rule, alone, with no imports and no side effects.
3
+ *
4
+ * CANONICAL COPY: `packages/content/src/lib/audience-rule.ts`. The scaffold's
5
+ * site carries a byte-identical copy at
6
+ * `system/site/lib/audience-rule.ts`, and `audience-rule-drift.test.ts`
7
+ * fails if the two ever differ. The site cannot simply import the kernel: its
8
+ * lib is deliberately dependency-light and runs inside Next's build, while the
9
+ * kernel package carries pg and the embedding providers.
10
+ *
11
+ * Why the rule gets its own file at all: the site and the kernel enforce the
12
+ * same visibility rule in two languages — TypeScript here, SQL in
13
+ * `audience.ts` — and it drifted four separate times while each side's own
14
+ * tests stayed green, because each side was internally consistent with itself.
15
+ * `AUDIENCE_CASES` is the shared decision table both are asserted against, and
16
+ * this file is the shared implementation of the TypeScript half.
17
+ */
18
+
19
+ export interface AudienceModel {
20
+ /** Least- to most-restricted, `public` first. */
21
+ readonly audiences: readonly string[];
22
+ /** The tier of a document that declares no `visibility:`. */
23
+ readonly defaultVisibility: string;
24
+ }
25
+
26
+ /**
27
+ * May a build FOR `audience` publish a document of this `visibility`?
28
+ *
29
+ * `model === null` is a record that declares no audience model: nothing to
30
+ * filter, everything publishes — the level-0 shape.
31
+ */
32
+ export function decideVisible(
33
+ model: AudienceModel | null,
34
+ audience: string,
35
+ visibility: string | null,
36
+ ): boolean {
37
+ if (model === null) return true;
38
+ const value = visibility === null || visibility === "" ? model.defaultVisibility : visibility;
39
+ const rank = model.audiences.indexOf(value);
40
+ // An undeclared visibility is refused, never published: a value no build
41
+ // understands is a typo, and a typo reads as a restriction.
42
+ if (rank === -1) return false;
43
+ return rank <= model.audiences.indexOf(audience);
44
+ }
@@ -1,3 +1,4 @@
1
+ import { decideVisible, type AudienceModel } from "./audience-rule";
1
2
  import { instanceFrontmatter } from "./shared";
2
3
 
3
4
  /**
@@ -15,12 +16,8 @@ import { instanceFrontmatter } from "./shared";
15
16
  * that declares no audiences has no model and publishes every document —
16
17
  * the behaviour of every instance written before this key existed.
17
18
  */
18
- export interface AudienceModel {
19
- /** Least- to most-restricted, `public` first. */
20
- readonly audiences: readonly string[];
21
- /** The tier of a document that declares no `visibility:`. */
22
- readonly defaultVisibility: string;
23
- }
19
+ export type { AudienceModel };
20
+ export { decideVisible };
24
21
 
25
22
  function unquote(raw: string): string {
26
23
  const trimmed = raw.trim();
@@ -157,14 +154,7 @@ export const buildAudience: string = resolveBuildAudience(audienceModel);
157
154
 
158
155
  /** Whether a document of this visibility belongs in THIS build. */
159
156
  export function visibleInBuild(visibility: string | null): boolean {
160
- if (audienceModel === null) return true;
161
- const value =
162
- visibility === null || visibility === "" ? audienceModel.defaultVisibility : visibility;
163
- const rank = audienceModel.audiences.indexOf(value);
164
- // An undeclared visibility is refused, never published: a value no build
165
- // understands is a typo, and a typo reads as a restriction.
166
- if (rank === -1) return false;
167
- return rank <= audienceModel.audiences.indexOf(buildAudience);
157
+ return decideVisible(audienceModel, buildAudience, visibility);
168
158
  }
169
159
 
170
160
  /**
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The denial rule, alone, with no imports and no side effects.
3
+ *
4
+ * CANONICAL COPY: `packages/content/src/lib/denial-rule.ts`. The scaffold's
5
+ * site carries a byte-identical copy at `system/site/lib/denial-rule.ts`, and
6
+ * `denial-rule-drift.test.ts` fails if the two ever differ — the same
7
+ * arrangement decision 18 made for the audience rule, and for the same reason:
8
+ * the site cannot import the kernel, whose package carries pg and the embedding
9
+ * providers.
10
+ *
11
+ * Why it is a leaf: these functions decide whether a withdrawn document gets
12
+ * published, and they lived inside a module that reads `instance.md` at import
13
+ * time — so none of them could be tested as rules. A round-8 mutation made
14
+ * `isDenied` return false unconditionally, which publishes every withdrawn
15
+ * document to `/docs` and `llms.txt`, and the entire suite stayed green.
16
+ */
17
+
18
+ /** The shape `ksor takedown --export` writes. */
19
+ export interface DenylistManifest {
20
+ format?: number;
21
+ corpus_id?: string;
22
+ source?: string;
23
+ denied?: { stable_id?: string; scope?: string }[];
24
+ denied_subtrees?: string[];
25
+ }
26
+
27
+ /**
28
+ * Is this document denied? Exact ids, plus the directories a `--subtree`
29
+ * takedown governs.
30
+ *
31
+ * `ksor takedown --export` expands a `--subtree` denial to its actual
32
+ * descendants by walking parent_id, where the tree lives. Interpreting SCOPE
33
+ * here meant prefix-matching stable_ids, and a section's stable_id ends in
34
+ * `/index` (or `#section`), so the prefix never matched its children and every
35
+ * descendant kept publishing — the failure decision 14 records as the reason
36
+ * its own walk uses parent_id rather than a prefix.
37
+ *
38
+ * But an expanded list can only name what the ACTIVE GENERATION contains, and
39
+ * the site reads DISK. A document added under a withdrawn section after the
40
+ * last ingest is on disk and not in the database, so subtree denials also
41
+ * arrive as DIRECTORIES. That is not the rejected prefix match: those paths
42
+ * come from `sources.origin_path`, so they are real locations on disk, and a
43
+ * document's location cannot be decoupled from itself by a frontmatter
44
+ * `sor_id:` the way its id can.
45
+ *
46
+ * `recordPath` is in the record's own frame (it starts with the record
47
+ * directory's name), because that is the frame `origin_path` uses.
48
+ */
49
+ export function isDenied(
50
+ manifest: DenylistManifest,
51
+ stableId: string,
52
+ recordPath: string,
53
+ ): boolean {
54
+ if ((manifest.denied ?? []).some((d) => String(d.stable_id) === stableId)) return true;
55
+ return (manifest.denied_subtrees ?? []).some((dir) => {
56
+ const prefix = String(dir).replace(/\\/g, "/");
57
+ if (prefix === "/") return true;
58
+ return recordPath.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`);
59
+ });
60
+ }
61
+
62
+ /**
63
+ * A plain scalar, read the way the kernel's frontmatter reader reads one.
64
+ *
65
+ * The two diverged on a TRAILING COMMENT: the kernel strips `# …` from an
66
+ * unquoted scalar and the site kept it, so `sor_id: hr/policy # renamed 2026`
67
+ * gave the kernel `hr/policy` and the site `hr/policy # renamed 2026`. A
68
+ * takedown on the id the MCP door reports as `provenance.stable_id` was then
69
+ * denied by the door and silently ignored by the site build, which kept
70
+ * publishing the document.
71
+ *
72
+ * A comment cannot appear inside a QUOTED scalar's value, so quoting is
73
+ * resolved first — exactly the kernel's order.
74
+ *
75
+ * `ok: false` marks a value the kernel's reader REFUSES rather than reads: a
76
+ * flow collection, an anchor, a block scalar, or anything with a `: ` in it.
77
+ * That matters because refusing one line poisons the whole map — see
78
+ * `frontmatterMap`.
79
+ */
80
+ export function scalarLike(raw: string | undefined): string | undefined {
81
+ if (raw === undefined) return undefined;
82
+ const parsed = readScalar(raw.trim());
83
+ return parsed.kind === "string" ? parsed.value : undefined;
84
+ }
85
+
86
+ /**
87
+ * Plain scalars the kernel's reader converts to a bool, null, int or float —
88
+ * never a string, so they can never be an id.
89
+ */
90
+ const YAML_TYPED =
91
+ /^(?:true|True|TRUE|false|False|FALSE|yes|Yes|YES|no|No|NO|on|On|ON|off|Off|OFF|~|null|Null|NULL|[-+]?[0-9][0-9_]*|[-+]?(?:\.[0-9]+|[0-9][0-9_]*\.[0-9_]*)(?:[eE][-+]?[0-9]+)?)$/;
92
+
93
+ /**
94
+ * Three outcomes, because the kernel's reader has three:
95
+ *
96
+ * string a plain or quoted string — usable as an id.
97
+ * typed a bool, null, int or float. The kernel KEEPS the key with a
98
+ * non-string value, and `stableIdOf` requires a string, so the
99
+ * override is dropped. The key exists; it just is not an id.
100
+ * refused a shape the reader will not read at all. The kernel POISONS the
101
+ * whole map on one of these.
102
+ *
103
+ * Collapsing `typed` into `refused` would empty the map for a document whose
104
+ * `order: 3` is perfectly ordinary — which the kernel does not do.
105
+ */
106
+ interface ScalarRead {
107
+ readonly kind: "string" | "typed" | "refused";
108
+ readonly value: string;
109
+ }
110
+
111
+ function readScalar(raw: string): ScalarRead {
112
+ // An EMPTY value is `null` to the kernel — the key exists and is not a
113
+ // string, exactly like a bool or a number.
114
+ if (raw === "") return { kind: "typed", value: "" };
115
+ const dq = /^"(.*)"$/.exec(raw);
116
+ if (dq !== null)
117
+ return { kind: "string", value: (dq[1] ?? "").replace(/\\"/g, '"').replace(/\\\\/g, "\\") };
118
+ const sq = /^'(.*)'$/.exec(raw);
119
+ if (sq !== null) return { kind: "string", value: (sq[1] ?? "").replace(/''/g, "'") };
120
+ const plain = raw.replace(/[ \t]+#.*$/, "").trim();
121
+ // The shapes the kernel's reader does not hand back as a STRING. Two groups,
122
+ // and both matter for the same reason:
123
+ //
124
+ // refused a flow collection, an anchor, a block scalar, a plain value
125
+ // containing ": " — the kernel returns ok:false and poisons the
126
+ // whole map.
127
+ // typed a YAML bool, null, int or float — the kernel returns them as
128
+ // non-strings, and `stableIdOf` requires a string, so it DROPS
129
+ // the override. `sor_id: 4711` therefore resolved to the path on
130
+ // the kernel and to "4711" here: a takedown honoured by the door
131
+ // and ignored by the site build, the same divergence round 9
132
+ // closed for comments and flow lists, in the same function
133
+ // (round-10 review of PR 43).
134
+ //
135
+ // Both are `ok: false` here because both end with the site NOT taking an
136
+ // override — which is what the kernel does. Kept in step with `scalarValue`
137
+ // in ingest/adapters/plain-tree.ts and bound to it by
138
+ // `stable-id-conformance.test.ts`.
139
+ if (/:[ \t]/.test(plain) || plain.endsWith(":")) return { kind: "refused", value: "" };
140
+ if (/^[|>&*!{[]/.test(plain)) return { kind: "refused", value: "" };
141
+ if (YAML_TYPED.test(plain)) return { kind: "typed", value: "" };
142
+ return { kind: "string", value: plain };
143
+ }
144
+
145
+ /**
146
+ * The frontmatter block as a map, read the way the KERNEL reads it — including
147
+ * the part that looks like a bug and is load-bearing: if ANY top-level line is
148
+ * a shape the reader refuses, the WHOLE map comes back empty.
149
+ *
150
+ * That behaviour is inherited from the oracle's PyYAML path, and mirroring it
151
+ * is not optional. The site read `sor_id:` with a bare regex, so a document
152
+ * carrying an ordinary flow list —
153
+ *
154
+ * title: Policy
155
+ * tags: [hr, payroll]
156
+ * sor_id: hr/policy
157
+ *
158
+ * — got `hr/policy` here and `knowledge/policies/policy` from the kernel, which
159
+ * drops the override with the poisoned map. A takedown then matched on exactly
160
+ * one surface: denied by the MCP door, ignored by the site build, published to
161
+ * /docs and llms.txt. That is the failure decisions 14 and 18 exist to stop,
162
+ * re-entered through the denial rule (round-9 review of PR 43).
163
+ */
164
+ export function frontmatterMap(block: string): Record<string, string> {
165
+ const map: Record<string, string> = {};
166
+ for (const line of block.split(/\r?\n/)) {
167
+ if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
168
+ if (/^[ \t]/.test(line)) continue; // nested structure — no top-level scalar
169
+ const kv = /^([^\s:]+):(?:[ \t]+(.*))?$/.exec(line);
170
+ const key = kv?.[1];
171
+ if (key === undefined) return {};
172
+ const parsed = readScalar((kv?.[2] ?? "").trim());
173
+ if (parsed.kind === "refused") return {};
174
+ // A typed value is present in the kernel's map and is not a string; this
175
+ // map holds strings, so the key is simply absent — which is what every
176
+ // consumer here needs to know about it.
177
+ if (parsed.kind === "string") map[key] = parsed.value;
178
+ }
179
+ return map;
180
+ }
181
+
182
+ /**
183
+ * The file's path in the frame the RECORD uses — `sources.origin_path`, which
184
+ * is project-root relative and therefore starts with the record directory's
185
+ * own name. `relPath` is the file's path relative to the record directory,
186
+ * with forward slashes.
187
+ */
188
+ export function recordPathFrom(recordName: string, relPath: string): string {
189
+ return `${recordName}/${relPath}`;
190
+ }
191
+
192
+ /**
193
+ * The record's stable_id for a file, mirroring the kernel's adapter —
194
+ * INCLUDING the `sor_id:` frontmatter override.
195
+ *
196
+ * Deriving it from the path alone meant a takedown of any document carrying an
197
+ * `sor_id:` never matched here and it stayed published, while the MCP door
198
+ * denied it: the same decoupling decision 14 records as the reason the subtree
199
+ * walk uses parent_id rather than a prefix.
200
+ */
201
+ export function stableIdFrom(
202
+ recordName: string,
203
+ relPath: string,
204
+ frontmatterBlock: string,
205
+ ): string {
206
+ // Through the MAP, not a bare regex on the block: the kernel drops the whole
207
+ // map when any line is a shape it refuses, and an id the two surfaces read
208
+ // differently is a takedown that lands on one of them.
209
+ const override = frontmatterMap(frontmatterBlock)["sor_id"];
210
+ if (override !== undefined && override !== "") return override;
211
+ return `${recordName}/${relPath.replace(/\.md$/i, "")}`;
212
+ }
@@ -58,3 +58,51 @@ function readInstanceTitle(): string {
58
58
  }
59
59
 
60
60
  export const appTitle: string = readInstanceTitle();
61
+
62
+ /**
63
+ * Where this record's MCP surface is published, if the owner has said.
64
+ *
65
+ * `null` when they have not: an invented URL is worse than none, because an
66
+ * agent would try it and conclude the record is down rather than unpublished.
67
+ */
68
+ export function mcpEndpoint(): string | null {
69
+ const declared = /^mcp_url:[ \t]*(.*)$/m.exec(instanceFrontmatter())?.[1] ?? "";
70
+ const value = declared.trim().replace(/^["']|["']$/g, "");
71
+ return value === "" ? null : value;
72
+ }
73
+
74
+ /**
75
+ * The namespace half of the MCP `name`, which the schema requires to look like
76
+ * `<namespace>/<identifier>` — a bare record name has no slash and is rejected
77
+ * by a validating client (round-6 review of #43).
78
+ *
79
+ * Derived from the published MCP URL's host in reverse-DNS order, which is the
80
+ * convention and is something the owner has already declared rather than a
81
+ * second thing to configure. With no URL declared there is nothing published to
82
+ * namespace, so the local-only namespace says exactly that.
83
+ */
84
+ export function mcpNamespace(): string {
85
+ const endpoint = mcpEndpoint();
86
+ if (endpoint === null) return "local";
87
+ try {
88
+ const host = new URL(endpoint).hostname;
89
+ const labels = host.split(".").filter((l) => l !== "");
90
+ // A bare host or an IP literal cannot be reversed into a namespace
91
+ // meaningfully; "local" is honest about that.
92
+ if (labels.length < 2 || /^\d+$/.test(labels[labels.length - 1] ?? "")) return "local";
93
+ return labels.reverse().join(".");
94
+ } catch {
95
+ return "local";
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The version this record publishes as. The record's own generation is not a
101
+ * semver, and the schema wants one, so this reads an explicit `version:` from
102
+ * instance.md and falls back to a first-release default.
103
+ */
104
+ export function recordVersion(): string {
105
+ const declared = /^version:[ \t]*(.*)$/m.exec(instanceFrontmatter())?.[1] ?? "";
106
+ const value = declared.trim().replace(/^["']|["']$/g, "");
107
+ return /^\d+\.\d+\.\d+/.test(value) ? value : "0.1.0";
108
+ }