@panaversity/ksor 0.0.7 → 0.0.9
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 +280 -0
- package/dist/cli.mjs +3379 -1234
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-CpDIVudJ.mjs → src-pl4aOpVs.mjs} +1 -0
- package/docs/index.md +8 -4
- package/package.json +2 -2
- package/schema/migrations/2.1-2.2__governance-on-the-node-row.sql +55 -0
- package/schema/migrations/2.2-2.3__takedown-writes-and-a-readable-ledger.sql +70 -0
- package/schema/migrations/2.3-2.4__a-generation-remembers-its-schema.sql +22 -0
- package/schema/schema.sql +61 -8
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +24 -2
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +24 -2
- package/templates/scaffold/AGENTS.md +95 -7
- package/templates/scaffold/README.md +45 -8
- package/templates/scaffold/env.example +82 -2
- package/templates/scaffold/gitignore +4 -0
- package/templates/scaffold/instance.md +14 -0
- package/templates/scaffold/package.json +7 -3
- package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +45 -0
- package/templates/scaffold/system/site/lib/audience-rule.ts +44 -0
- package/templates/scaffold/system/site/lib/audience.ts +4 -14
- package/templates/scaffold/system/site/lib/denial-rule.ts +212 -0
- package/templates/scaffold/system/site/lib/shared.ts +48 -0
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +186 -8
|
@@ -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
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
48
|
-
|
|
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`
|
|
52
|
-
|
|
53
|
-
|
|
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:
|
|
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
|
|
@@ -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
|
-
"
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
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
|
+
}
|