@panaversity/ksor 0.0.10 → 0.0.11
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 +111 -0
- package/dist/cli.mjs +139 -33
- package/docs/index.md +3 -2
- package/package.json +1 -1
- package/templates/scaffold/AGENTS.md +18 -9
- package/templates/scaffold/instance.md +7 -0
- package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +5 -2
- package/templates/scaffold/system/site/lib/shared.ts +49 -0
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +19 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,116 @@
|
|
|
1
1
|
# @panaversity/ksor
|
|
2
2
|
|
|
3
|
+
## 0.0.11
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0a0dd27: A record describes itself on the surface agents discover it through
|
|
8
|
+
|
|
9
|
+
`/.well-known/mcp/server.json` carried one hard-coded sentence — "The <name>
|
|
10
|
+
Knowledge System of Record: governed markdown served with citations and honest
|
|
11
|
+
abstention." — byte-identical in every ksor record ever scaffolded. An agent
|
|
12
|
+
choosing between records in a registry learned nothing from any of them, which
|
|
13
|
+
is the opposite of what that document exists for.
|
|
14
|
+
|
|
15
|
+
The description now comes from the record's own prose: its display title and the
|
|
16
|
+
first real sentence of `instance.md`, which is what the intake interview writes.
|
|
17
|
+
A record whose owner has not described it yet SAYS so rather than borrowing a
|
|
18
|
+
confident sentence it has not earned — the same answer the MCP door already
|
|
19
|
+
gives an agent that connects, so the two surfaces do not disagree about whether
|
|
20
|
+
this record knows what it is.
|
|
21
|
+
|
|
22
|
+
The scaffold's opening paragraphs are authoring guidance, not scope, so the
|
|
23
|
+
template is detected across the whole body rather than paragraph by paragraph:
|
|
24
|
+
publishing instructions-to-the-author as a description would be worse than
|
|
25
|
+
admitting there is none.
|
|
26
|
+
|
|
27
|
+
- 0fe759d: Three defects found by auditing 0.0.10 against a live record
|
|
28
|
+
|
|
29
|
+
**A repeated `sslmode` was read the wrong end.** `pinnedTlsDsn` took the FIRST
|
|
30
|
+
value of a repeated parameter; `pg` takes the LAST. So on
|
|
31
|
+
`?sslmode=require&sslmode=disable` — whose effective mode is `disable` — the pin
|
|
32
|
+
saw a weak mode, collapsed the duplicates into one `verify-full`, turned TLS on,
|
|
33
|
+
and printed "TLS verified" at an operator whose DSN ended in `disable`. The
|
|
34
|
+
direction was safe; silently overruling an explicit opt-out and then misreporting
|
|
35
|
+
it was not. All three TLS functions now read the mode the driver will use.
|
|
36
|
+
|
|
37
|
+
The same sweep asserted the larger worry the pin creates — that re-serializing a
|
|
38
|
+
connection string could alter a credential. Seventeen DSNs with the passwords
|
|
39
|
+
people actually paste (raw `@`, spaces, `%`, `+`, brackets, non-ASCII,
|
|
40
|
+
percent-encoded separators) are now checked field by field against `pg`'s own
|
|
41
|
+
resolved view: everything the driver derives is byte-identical, and so is the
|
|
42
|
+
TLS decision.
|
|
43
|
+
|
|
44
|
+
**The outline's `position` disclosed documents an audience may not see.** It was
|
|
45
|
+
the rank in the whole record, so a public caller received 1, 3, 4 — a gap exactly
|
|
46
|
+
where an internal sibling sat, telling them something exists and roughly where.
|
|
47
|
+
The same row's `child_count` was already computed over visible children only, so
|
|
48
|
+
one response object disagreed with itself. `position` is now the rank among the
|
|
49
|
+
siblings the caller can see, computed as a window over the filtered set so it
|
|
50
|
+
stays correct across pages and at every depth, and both it and `depth` say what
|
|
51
|
+
they are in the tool schema.
|
|
52
|
+
|
|
53
|
+
**`ksor serve` now says when the record has no identity yet.** The MCP door
|
|
54
|
+
already refused to pass an unedited `instance.md` to agents as instructions —
|
|
55
|
+
it substitutes a plain statement that the scope is unstated — but the operator
|
|
56
|
+
starting the server was told nothing, so a record serving with no declared
|
|
57
|
+
identity looked exactly like one that had been described. It is a boot line now,
|
|
58
|
+
beside the abstention posture: both answer "how much should I trust this".
|
|
59
|
+
|
|
60
|
+
- f5cd885: The bearer door's key line joins the boot block instead of interrupting it
|
|
61
|
+
|
|
62
|
+
In bearer mode the line naming where the signing keys were discovered printed
|
|
63
|
+
before the aligned posture block and in a different shape, so it read as a stray
|
|
64
|
+
log line rather than as part of what the server was telling you about itself. It
|
|
65
|
+
is a `keys` row in the block now, under `auth`, resolved at boot exactly as
|
|
66
|
+
before.
|
|
67
|
+
|
|
68
|
+
- 5f30b5f: The site build no longer fails when two evaluations of the record staging overlap
|
|
69
|
+
|
|
70
|
+
The scaffold stages a per-audience copy of the record before the site build
|
|
71
|
+
reads it, removing the previous stage first. `rmSync(..., { force: true })`
|
|
72
|
+
suppresses ENOENT but retries nothing: Node retries EBUSY / EMFILE / ENFILE /
|
|
73
|
+
ENOTEMPTY / EPERM only when `maxRetries` is set, and it defaults to zero. The
|
|
74
|
+
bundler evaluates the source config more than once when it wants it in more than
|
|
75
|
+
one place, so one run could remove the stage while another was still copying
|
|
76
|
+
into it — surfacing as `ENOTEMPTY` and failing the entire site build (seen once
|
|
77
|
+
in CI, 2026-08-21).
|
|
78
|
+
|
|
79
|
+
The removal now asks for those retries. Losing that race is safe: the stage is a
|
|
80
|
+
deterministic function of the record and the denylist, so redoing it produces
|
|
81
|
+
the same bytes.
|
|
82
|
+
|
|
83
|
+
Three claims in the scaffold's `AGENTS.md` that recent releases made false are
|
|
84
|
+
also corrected: `--actor` no longer "defaults to the operating user" (it is
|
|
85
|
+
required, and there is no default by design); the signing keys are discovered
|
|
86
|
+
from the SSO's own metadata rather than fetched from Better Auth's path; and the
|
|
87
|
+
`order:` key now drives the MCP `outline` tool alongside the sidebar and
|
|
88
|
+
`llms.txt`, which is what "one order drives every surface" was always supposed
|
|
89
|
+
to mean.
|
|
90
|
+
|
|
91
|
+
- 4a1c154: The shrink guard guards `ksor ingest --flip` again — it had stopped
|
|
92
|
+
|
|
93
|
+
`.env.example` documents `KSOR_MAX_SHRINK` as "a corpus that shrinks by more
|
|
94
|
+
than this FRACTION refuses to flip". In 0.0.10 it did not. Deleting eight of ten
|
|
95
|
+
documents and running `ksor ingest --flip` published the two that were left,
|
|
96
|
+
silently, exit 0.
|
|
97
|
+
|
|
98
|
+
The cause was the fix that stopped a refused ingest from publishing. That moved
|
|
99
|
+
the flip out of `buildGeneration` and into the command, so the governance gate
|
|
100
|
+
could run against the new generation BEFORE it became the active one — and the
|
|
101
|
+
shrink check, which lived inside the build's flip branch, was stepped straight
|
|
102
|
+
over. The library test that covers the guard stayed green throughout, because it
|
|
103
|
+
drives `buildGeneration` directly with `flip: true`, which is no longer the path
|
|
104
|
+
the CLI takes.
|
|
105
|
+
|
|
106
|
+
There is now one answer to "may this generation be activated" — `flipRefusal` —
|
|
107
|
+
and both flip paths ask it, in the same transaction as the flip itself. The new
|
|
108
|
+
test drives the command rather than the library, so a guard that only one of two
|
|
109
|
+
paths performs fails the tier that proves it.
|
|
110
|
+
|
|
111
|
+
Verified against a live record: a 10 → 2 node build now names all eight removed
|
|
112
|
+
documents, refuses with exit 1, and leaves the previous generation serving.
|
|
113
|
+
|
|
3
114
|
## 0.0.10
|
|
4
115
|
|
|
5
116
|
### Patch Changes
|
package/dist/cli.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { bodyLimit } from "hono/body-limit";
|
|
|
16
16
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
17
17
|
import { parseArgs } from "node:util";
|
|
18
18
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
19
|
-
//#region ../content-gateway/dist/main-
|
|
19
|
+
//#region ../content-gateway/dist/main-3DN4fOiE.mjs
|
|
20
20
|
/**
|
|
21
21
|
* A connection could not be ESTABLISHED in time — retryable.
|
|
22
22
|
*
|
|
@@ -93,6 +93,20 @@ function isLoopbackHost$1(hostname) {
|
|
|
93
93
|
return host === "" || host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
94
94
|
}
|
|
95
95
|
/**
|
|
96
|
+
* The sslmode the DRIVER will use, which is the LAST one written.
|
|
97
|
+
*
|
|
98
|
+
* `URLSearchParams.get` returns the FIRST value; `pg` takes the last. On
|
|
99
|
+
* `?sslmode=require&sslmode=disable` those disagree, and reading the first made
|
|
100
|
+
* the pin treat an explicitly disabled connection as a weak one — collapsing the
|
|
101
|
+
* duplicates into a single `verify-full`, turning TLS on, and printing "verified"
|
|
102
|
+
* at an operator whose DSN ended in `disable`. The direction was safe; silently
|
|
103
|
+
* overruling an explicit opt-out and then misreporting it is not (found by
|
|
104
|
+
* sweeping the driver's own parser, 2026-08-21).
|
|
105
|
+
*/
|
|
106
|
+
function effectiveSslMode$1(url) {
|
|
107
|
+
return (url.searchParams.getAll("sslmode").at(-1) ?? "").toLowerCase();
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
96
110
|
* The DSN ksor actually connects with — the weak sslmode SPELLED OUT.
|
|
97
111
|
*
|
|
98
112
|
* pg 8 treats `sslmode=require|prefer|verify-ca` as aliases for `verify-full`,
|
|
@@ -116,8 +130,7 @@ function pinnedTlsDsn$1(dsn) {
|
|
|
116
130
|
return dsn;
|
|
117
131
|
}
|
|
118
132
|
if (isLoopbackHost$1(url.hostname)) return dsn;
|
|
119
|
-
|
|
120
|
-
if (!WEAK_SSLMODES$1.includes(mode)) return dsn;
|
|
133
|
+
if (!WEAK_SSLMODES$1.includes(effectiveSslMode$1(url))) return dsn;
|
|
121
134
|
url.searchParams.set("sslmode", "verify-full");
|
|
122
135
|
return url.toString();
|
|
123
136
|
}
|
|
@@ -133,7 +146,7 @@ function tlsPosture(dsn) {
|
|
|
133
146
|
return null;
|
|
134
147
|
}
|
|
135
148
|
if (isLoopbackHost$1(url.hostname)) return null;
|
|
136
|
-
const mode = (url
|
|
149
|
+
const mode = effectiveSslMode$1(url);
|
|
137
150
|
if (mode === "disable") return "TLS off (sslmode=disable)";
|
|
138
151
|
if (mode === "no-verify") return "TLS UNVERIFIED (sslmode=no-verify)";
|
|
139
152
|
if (WEAK_SSLMODES$1.includes(mode)) return `TLS verified (sslmode=${mode} pinned to verify-full)`;
|
|
@@ -189,7 +202,7 @@ function tlsOptionsFor$1(dsn) {
|
|
|
189
202
|
return;
|
|
190
203
|
}
|
|
191
204
|
if (isLoopbackHost$1(url.hostname)) return void 0;
|
|
192
|
-
const mode = (url
|
|
205
|
+
const mode = effectiveSslMode$1(url);
|
|
193
206
|
if (mode === "disable" || mode === "no-verify") return void 0;
|
|
194
207
|
return { rejectUnauthorized: true };
|
|
195
208
|
}
|
|
@@ -2058,7 +2071,22 @@ walk AS (
|
|
|
2058
2071
|
-- gates each row on its OWN visibility (round-9 review of PR 43).
|
|
2059
2072
|
WHERE n.tenant_id = $1 AND n.status = 'published' AND w.depth < $5
|
|
2060
2073
|
)
|
|
2061
|
-
|
|
2074
|
+
-- The rank among the siblings THIS CALLER CAN SEE, not the stored one.
|
|
2075
|
+
--
|
|
2076
|
+
-- content_nodes.position is the rank in the whole record, so a tier that
|
|
2077
|
+
-- cannot see a sibling saw a GAP where it sat -- 1, 3, 4 -- which discloses
|
|
2078
|
+
-- that a document exists and roughly where, to a caller the record refuses to
|
|
2079
|
+
-- show it to. The same row's child_count was already computed over visible
|
|
2080
|
+
-- children only, so one response object disagreed with itself about whether
|
|
2081
|
+
-- hidden siblings are disclosed (found live 2026-08-21).
|
|
2082
|
+
--
|
|
2083
|
+
-- Computed as a WINDOW over the filtered set: window functions run after WHERE
|
|
2084
|
+
-- and before LIMIT/OFFSET, so the rank is the true visible sibling rank on
|
|
2085
|
+
-- every page and at every depth. Doing it in JS would have to renumber a page
|
|
2086
|
+
-- at a time -- which is how this query already produced two paging defects.
|
|
2087
|
+
SELECT w.slug, w.kind, w.title, w.heading_path,
|
|
2088
|
+
row_number() OVER (PARTITION BY w.parent_id ORDER BY w.sort_key)::int AS position,
|
|
2089
|
+
w.depth,
|
|
2062
2090
|
(SELECT count(*) FROM content_nodes ch
|
|
2063
2091
|
WHERE ch.tenant_id = $1 AND ch.generation = w.generation
|
|
2064
2092
|
AND ch.parent_id = w.node_id AND ch.status = 'published'
|
|
@@ -2903,9 +2931,23 @@ const FRAMEWORK_INSTRUCTIONS = `You are answering from a Knowledge System of Rec
|
|
|
2903
2931
|
* replaced it with "has not yet been described" (review of PR #43).
|
|
2904
2932
|
*/
|
|
2905
2933
|
const TEMPLATE_MARKER = "_fill this in; it is";
|
|
2934
|
+
/**
|
|
2935
|
+
* Has the owner said what this record is FOR yet?
|
|
2936
|
+
*
|
|
2937
|
+
* The MCP door already answers honestly when they have not — it replaces the
|
|
2938
|
+
* template with a plain statement that the scope is unstated. But the operator
|
|
2939
|
+
* starting the server was told nothing, so a record serving with no declared
|
|
2940
|
+
* identity looked exactly like one that had been described. The boot report is
|
|
2941
|
+
* where that belongs, beside the abstention posture: both are answers to "how
|
|
2942
|
+
* much should I trust what this thing says".
|
|
2943
|
+
*/
|
|
2944
|
+
function recordIsUndescribed(authored) {
|
|
2945
|
+
const body = authored.trim();
|
|
2946
|
+
return body === "" || body.includes(TEMPLATE_MARKER);
|
|
2947
|
+
}
|
|
2906
2948
|
function composeInstructions(authored) {
|
|
2907
2949
|
const body = authored.trim();
|
|
2908
|
-
return
|
|
2950
|
+
return recordIsUndescribed(authored) ? `${FRAMEWORK_INSTRUCTIONS}
|
|
2909
2951
|
|
|
2910
2952
|
(This record has not yet been described by its owner — instance.md still carries the scaffold template. Treat its scope as unstated.)` : `${FRAMEWORK_INSTRUCTIONS}
|
|
2911
2953
|
|
|
@@ -2955,8 +2997,8 @@ const OUTLINE_OUTPUT = z.object({
|
|
|
2955
2997
|
kind: z.string(),
|
|
2956
2998
|
title: z.string(),
|
|
2957
2999
|
heading_path: z.string(),
|
|
2958
|
-
position: z.number().int(),
|
|
2959
|
-
depth: z.number().int(),
|
|
3000
|
+
position: z.number().int().describe("Rank among the siblings YOU can see, from 1. Rows already arrive in reading order, so this is for citing a place, not for sorting."),
|
|
3001
|
+
depth: z.number().int().describe("Levels below the record's root, so rows are self-locating."),
|
|
2960
3002
|
child_count: z.number().int(),
|
|
2961
3003
|
permalink: z.string().nullable().describe("The page a person can open, when the record publishes one; null otherwise."),
|
|
2962
3004
|
has_content: z.boolean()
|
|
@@ -3568,6 +3610,16 @@ function abstainPosture(floor) {
|
|
|
3568
3610
|
return `floor ${floor} — below it, this record abstains`;
|
|
3569
3611
|
}
|
|
3570
3612
|
/**
|
|
3613
|
+
* What the boot report says when instance.md is still the scaffold template.
|
|
3614
|
+
*
|
|
3615
|
+
* Not a scolding: a level-0 record is allowed to be undescribed and this is not
|
|
3616
|
+
* an error. It is stated because the instance.md body IS the agent surface's
|
|
3617
|
+
* system prompt, so leaving it unwritten is a decision with a runtime effect —
|
|
3618
|
+
* every agent is told this record's scope is unstated — and an operator should
|
|
3619
|
+
* learn that from the server rather than from an agent's answer.
|
|
3620
|
+
*/
|
|
3621
|
+
const UNDESCRIBED_RECORD = "instance.md is still the scaffold template — agents are told this record's scope is unstated; run the intake interview to describe it";
|
|
3622
|
+
/**
|
|
3571
3623
|
* Composition (oracle main.py's boot order, adapted): instance → DSN via
|
|
3572
3624
|
* the declared env NAME → provider → pool → space guard → service context.
|
|
3573
3625
|
* Auth is built by the door that needs it (http.ts) — BEFORE the pool
|
|
@@ -3736,10 +3788,11 @@ function resolveSecurity(bind) {
|
|
|
3736
3788
|
}
|
|
3737
3789
|
async function runHttp(composition) {
|
|
3738
3790
|
const auth = buildAuth(process.env);
|
|
3791
|
+
const keyLines = [];
|
|
3739
3792
|
if (auth.mode === "public") {
|
|
3740
3793
|
const keys = await auth.jwks();
|
|
3741
|
-
|
|
3742
|
-
if (keys.advisory !== null)
|
|
3794
|
+
keyLines.push(bootLine("keys", `${keys.source} — ${keys.url}`));
|
|
3795
|
+
if (keys.advisory !== null) keyLines.push(bootLine("", keys.advisory));
|
|
3743
3796
|
}
|
|
3744
3797
|
const resourceMetadataUrl = auth.mode === "public" ? new URL("/.well-known/oauth-protected-resource/mcp", auth.config.resourceUrl).toString() : "";
|
|
3745
3798
|
const bind = resolveBind(process.env);
|
|
@@ -3925,7 +3978,9 @@ async function runHttp(composition) {
|
|
|
3925
3978
|
});
|
|
3926
3979
|
s.once("error", reject);
|
|
3927
3980
|
});
|
|
3981
|
+
if (recordIsUndescribed(instance.instructions)) console.error(bootLine("identity", UNDESCRIBED_RECORD));
|
|
3928
3982
|
console.error(bootLine("auth", authPosture(auth.mode, bind.host)));
|
|
3983
|
+
for (const line of keyLines) console.error(line);
|
|
3929
3984
|
console.error(bootLine("abstain", abstainPosture(instance.abstain.vectorFloor)));
|
|
3930
3985
|
console.error(bootLine("serving", `http://${bind.host}:${bind.port}/mcp`));
|
|
3931
3986
|
let draining = false;
|
|
@@ -4080,6 +4135,20 @@ function isLoopbackHost(hostname) {
|
|
|
4080
4135
|
return host === "" || host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
4081
4136
|
}
|
|
4082
4137
|
/**
|
|
4138
|
+
* The sslmode the DRIVER will use, which is the LAST one written.
|
|
4139
|
+
*
|
|
4140
|
+
* `URLSearchParams.get` returns the FIRST value; `pg` takes the last. On
|
|
4141
|
+
* `?sslmode=require&sslmode=disable` those disagree, and reading the first made
|
|
4142
|
+
* the pin treat an explicitly disabled connection as a weak one — collapsing the
|
|
4143
|
+
* duplicates into a single `verify-full`, turning TLS on, and printing "verified"
|
|
4144
|
+
* at an operator whose DSN ended in `disable`. The direction was safe; silently
|
|
4145
|
+
* overruling an explicit opt-out and then misreporting it is not (found by
|
|
4146
|
+
* sweeping the driver's own parser, 2026-08-21).
|
|
4147
|
+
*/
|
|
4148
|
+
function effectiveSslMode(url) {
|
|
4149
|
+
return (url.searchParams.getAll("sslmode").at(-1) ?? "").toLowerCase();
|
|
4150
|
+
}
|
|
4151
|
+
/**
|
|
4083
4152
|
* The DSN ksor actually connects with — the weak sslmode SPELLED OUT.
|
|
4084
4153
|
*
|
|
4085
4154
|
* pg 8 treats `sslmode=require|prefer|verify-ca` as aliases for `verify-full`,
|
|
@@ -4103,8 +4172,7 @@ function pinnedTlsDsn(dsn) {
|
|
|
4103
4172
|
return dsn;
|
|
4104
4173
|
}
|
|
4105
4174
|
if (isLoopbackHost(url.hostname)) return dsn;
|
|
4106
|
-
|
|
4107
|
-
if (!WEAK_SSLMODES.includes(mode)) return dsn;
|
|
4175
|
+
if (!WEAK_SSLMODES.includes(effectiveSslMode(url))) return dsn;
|
|
4108
4176
|
url.searchParams.set("sslmode", "verify-full");
|
|
4109
4177
|
return url.toString();
|
|
4110
4178
|
}
|
|
@@ -4158,7 +4226,7 @@ function tlsOptionsFor(dsn) {
|
|
|
4158
4226
|
return;
|
|
4159
4227
|
}
|
|
4160
4228
|
if (isLoopbackHost(url.hostname)) return void 0;
|
|
4161
|
-
const mode = (url
|
|
4229
|
+
const mode = effectiveSslMode(url);
|
|
4162
4230
|
if (mode === "disable" || mode === "no-verify") return void 0;
|
|
4163
4231
|
return { rejectUnauthorized: true };
|
|
4164
4232
|
}
|
|
@@ -4321,7 +4389,7 @@ async function withPgRetry(op, options = {}) {
|
|
|
4321
4389
|
throw lastError;
|
|
4322
4390
|
}
|
|
4323
4391
|
//#endregion
|
|
4324
|
-
//#region ../content/dist/commands-
|
|
4392
|
+
//#region ../content/dist/commands-ulU-h9ei.mjs
|
|
4325
4393
|
/**
|
|
4326
4394
|
* EVAL-LOCKED constants, quarried verbatim from the oracle
|
|
4327
4395
|
* (sor-agentfactory @ b554f91, config.py) — changing any of these is a
|
|
@@ -8138,6 +8206,39 @@ async function sameCommit(c, tenantId, generation, sourceCommit) {
|
|
|
8138
8206
|
const stored = r.rows[0]?.source_commit ?? null;
|
|
8139
8207
|
return String(stored ?? "") === String(sourceCommit ?? "");
|
|
8140
8208
|
}
|
|
8209
|
+
/**
|
|
8210
|
+
* May this generation be ACTIVATED? Returns the refusal, or null.
|
|
8211
|
+
*
|
|
8212
|
+
* Extracted so there is exactly ONE answer to that question. It used to live
|
|
8213
|
+
* inside `buildGeneration`'s flip branch, which made it unreachable the moment
|
|
8214
|
+
* a caller flipped separately — and `ksor ingest --flip` does, deliberately: the
|
|
8215
|
+
* governance gate has to run against the new generation BEFORE it becomes the
|
|
8216
|
+
* active one. That change silently retired this guard on the CLI path, so a
|
|
8217
|
+
* record that lost 80% of its documents published without a word, while the
|
|
8218
|
+
* library test that covers the guard stayed green because it drives
|
|
8219
|
+
* `buildGeneration` directly (found live 2026-08-21, auditing 0.0.10).
|
|
8220
|
+
*
|
|
8221
|
+
* A pre-flip check that only one of two flip paths performs is not a guard.
|
|
8222
|
+
*/
|
|
8223
|
+
async function flipRefusal(client, options) {
|
|
8224
|
+
const { log } = options;
|
|
8225
|
+
const delta = await flipDelta(client, {
|
|
8226
|
+
tenantId: options.tenantId,
|
|
8227
|
+
corpusId: options.corpusId,
|
|
8228
|
+
newGeneration: options.newGeneration
|
|
8229
|
+
});
|
|
8230
|
+
const added = addedSlugs(delta);
|
|
8231
|
+
const removed = removedSlugs(delta);
|
|
8232
|
+
log(`pre-flip delta vs gen ${delta.priorGeneration}: ${delta.priorSlugs.size} -> ${delta.newSlugs.size} nodes (+${added.length} / -${removed.length})`);
|
|
8233
|
+
if (removed.length > 0) log(` removed: ${JSON.stringify(removed.slice(0, 20))}`);
|
|
8234
|
+
if (added.length > 0) log(` added: ${JSON.stringify(added.slice(0, 20))}`);
|
|
8235
|
+
const configuredShrink = envFloat("KSOR_MAX_SHRINK", .15, 0);
|
|
8236
|
+
const maxShrink = configuredShrink <= 1 ? configuredShrink : .15;
|
|
8237
|
+
if (configuredShrink > 1) log(`KSOR_MAX_SHRINK=${configuredShrink} is not a fraction in [0,1]; using ${maxShrink} (did you mean ${configuredShrink / 100}?)`);
|
|
8238
|
+
const allowed = options.force || process.env["KSOR_ALLOW_SHRINK"] === "1";
|
|
8239
|
+
if (!shrinkUnsafe(delta.priorSlugs.size, delta.newSlugs.size, maxShrink) || allowed) return null;
|
|
8240
|
+
return `REFUSING FLIP: corpus shrank ${pct(shrinkFraction(delta.priorSlugs.size, delta.newSlugs.size))} vs gen ${delta.priorGeneration} (> KSOR_MAX_SHRINK=${pct(maxShrink)}); ${removed.length} node(s) vanished. Generation ${options.newGeneration} is READY but NOT served — the old generation keeps serving. If the drop is intended, re-run with KSOR_ALLOW_SHRINK=1; otherwise fix the corpus and re-ingest.`;
|
|
8241
|
+
}
|
|
8141
8242
|
/** Thrown inside the build transaction to roll it back when nothing changed. */
|
|
8142
8243
|
var UnchangedCorpus = class extends Error {
|
|
8143
8244
|
activeGeneration;
|
|
@@ -8262,26 +8363,19 @@ async function buildGeneration(pool, instance, options) {
|
|
|
8262
8363
|
flipped: false,
|
|
8263
8364
|
refusal: null
|
|
8264
8365
|
};
|
|
8265
|
-
const
|
|
8366
|
+
const refusal = await flipRefusal(c, {
|
|
8266
8367
|
tenantId: tenant,
|
|
8267
8368
|
corpusId: instance.corpusId,
|
|
8268
|
-
newGeneration: generation
|
|
8369
|
+
newGeneration: generation,
|
|
8370
|
+
force: options.force === true,
|
|
8371
|
+
log
|
|
8269
8372
|
});
|
|
8270
|
-
|
|
8271
|
-
const removed = removedSlugs(delta);
|
|
8272
|
-
log(`pre-flip delta vs gen ${delta.priorGeneration}: ${delta.priorSlugs.size} -> ${delta.newSlugs.size} nodes (+${added.length} / -${removed.length})`);
|
|
8273
|
-
if (removed.length > 0) log(` removed: ${JSON.stringify(removed.slice(0, 20))}`);
|
|
8274
|
-
if (added.length > 0) log(` added: ${JSON.stringify(added.slice(0, 20))}`);
|
|
8275
|
-
const configuredShrink = envFloat("KSOR_MAX_SHRINK", .15, 0);
|
|
8276
|
-
const maxShrink = configuredShrink <= 1 ? configuredShrink : .15;
|
|
8277
|
-
if (configuredShrink > 1) log(`KSOR_MAX_SHRINK=${configuredShrink} is not a fraction in [0,1]; using ${maxShrink} (did you mean ${configuredShrink / 100}?)`);
|
|
8278
|
-
const allowed = options.force === true || process.env["KSOR_ALLOW_SHRINK"] === "1";
|
|
8279
|
-
if (shrinkUnsafe(delta.priorSlugs.size, delta.newSlugs.size, maxShrink) && !allowed) return {
|
|
8373
|
+
if (refusal !== null) return {
|
|
8280
8374
|
ready,
|
|
8281
8375
|
centroids,
|
|
8282
8376
|
health,
|
|
8283
8377
|
flipped: false,
|
|
8284
|
-
refusal
|
|
8378
|
+
refusal
|
|
8285
8379
|
};
|
|
8286
8380
|
await flip(c, {
|
|
8287
8381
|
tenantId: tenant,
|
|
@@ -8652,11 +8746,23 @@ async function ingestCommand(args) {
|
|
|
8652
8746
|
const governance = await withPool(dsn, (pool) => assertGovernanceServable(pool, instance, report.generation).then(() => null, (error) => error instanceof Error ? error.message : String(error)));
|
|
8653
8747
|
if (governance !== null) return fail$1(REFUSED, `generation ${report.generation} was built and NOT activated — no surface could serve it\n ${governance.split("\n").join("\n ")}\n note: generation ${report.generation} is left behind, un-activated; \`ksor gc\` reaps it once the grace window passes. The previously active generation still serves.`);
|
|
8654
8748
|
if (values.flip === true && !report.unchanged) {
|
|
8655
|
-
await withPool(dsn, (pool) => runIngest(pool, instance.tenantId, (client) =>
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
|
|
8749
|
+
const refusal = await withPool(dsn, (pool) => runIngest(pool, instance.tenantId, async (client) => {
|
|
8750
|
+
const stop = await flipRefusal(client, {
|
|
8751
|
+
tenantId: instance.tenantId,
|
|
8752
|
+
corpusId: instance.corpusId,
|
|
8753
|
+
newGeneration: report.generation,
|
|
8754
|
+
force: false,
|
|
8755
|
+
log: (line) => process.stdout.write(line + "\n")
|
|
8756
|
+
});
|
|
8757
|
+
if (stop !== null) return stop;
|
|
8758
|
+
await flip(client, {
|
|
8759
|
+
tenantId: instance.tenantId,
|
|
8760
|
+
corpusId: instance.corpusId,
|
|
8761
|
+
toGeneration: report.generation
|
|
8762
|
+
});
|
|
8763
|
+
return null;
|
|
8764
|
+
}));
|
|
8765
|
+
if (refusal !== null) return fail$1(REFUSED, refusal);
|
|
8660
8766
|
process.stdout.write(`FLIPPED active generation -> ${report.generation}\n`);
|
|
8661
8767
|
}
|
|
8662
8768
|
if (values.flip !== true) process.stdout.write("ready; flip withheld (pass --flip to activate)\n");
|
package/docs/index.md
CHANGED
|
@@ -45,8 +45,9 @@ instead of their training memory. The corpus grows with each implemented verb.
|
|
|
45
45
|
Read the scaffold's own `AGENTS.md` first — it is the working contract.
|
|
46
46
|
Knowledge lives in `knowledge/` and never inside the site; frontmatter uses
|
|
47
47
|
a closed key set (`title` + `status` required); `pnpm check` explains any
|
|
48
|
-
violation and how to fix it.
|
|
49
|
-
frontmatter key — never `meta.json` or `sidebar_position
|
|
48
|
+
violation and how to fix it. Reading order is the governed `order:`
|
|
49
|
+
frontmatter key — never `meta.json` or `sidebar_position` — and it drives
|
|
50
|
+
every surface: the sidebar, `llms.txt`, and the MCP `outline` tool. If the
|
|
50
51
|
instance declares an `audiences:` model, documents may carry a
|
|
51
52
|
`visibility:` key and per-audience builds (`KSOR_AUDIENCE=<tier> pnpm
|
|
52
53
|
build`) stage only what that tier may see — publication, not authorship:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panaversity/ksor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
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",
|
|
@@ -235,11 +235,14 @@ Two things worth being deliberate about:
|
|
|
235
235
|
- **Set `KSOR_SSO_ISSUER` when your SSO stamps a stable `iss`.** Audience is
|
|
236
236
|
always enforced against `KSOR_JWT_ALLOWED_AUDIENCES`; naming the issuer adds
|
|
237
237
|
one more check for the cost of one variable.
|
|
238
|
-
- **
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
238
|
+
- **The signing keys are DISCOVERED; you rarely set `KSOR_JWKS_URL`.** The door
|
|
239
|
+
reads your SSO's own metadata document — RFC 8414
|
|
240
|
+
(`/.well-known/oauth-authorization-server`), then OpenID Discovery
|
|
241
|
+
(`/.well-known/openid-configuration`) — so Auth0, Okta, Entra, Keycloak,
|
|
242
|
+
Cognito, Google and Better Auth all work unmodified. The boot report's `keys`
|
|
243
|
+
line names which document answered and where the keys came from; set
|
|
244
|
+
`KSOR_JWKS_URL` only to override that, or when your SSO publishes no metadata
|
|
245
|
+
at all.
|
|
243
246
|
|
|
244
247
|
## Withdrawing a document — `ksor takedown`
|
|
245
248
|
|
|
@@ -258,8 +261,12 @@ pnpm exec ksor takedown --instance instance.md --revoke <stable-id>
|
|
|
258
261
|
The stable id is what a search result reports as `provenance.stable_id` — for
|
|
259
262
|
most documents that is `knowledge/<path-without-.md>`. `--subtree` withdraws a
|
|
260
263
|
section and everything beneath it, including documents added later.
|
|
261
|
-
`--actor NAME` names who performed the act in the ledger
|
|
262
|
-
|
|
264
|
+
`--actor NAME` names who performed the act in the ledger, and a denial or a
|
|
265
|
+
revocation is REFUSED without it. There is no default: a name taken from the
|
|
266
|
+
environment reads like a person and is whatever the shell happened to be
|
|
267
|
+
(`runner` under CI, `root` in a container), which is worse than no name at all
|
|
268
|
+
in the one row that exists to record who did this. Read-only modes
|
|
269
|
+
(`--list`, `--ledger`, `--export`) need nothing.
|
|
263
270
|
|
|
264
271
|
**The MCP door stops serving it immediately. The SITE stops at its next
|
|
265
272
|
build** — the site reads a file, not the database, and `pnpm build` refreshes
|
|
@@ -329,8 +336,10 @@ Details in README → Deploying.
|
|
|
329
336
|
takes the position that page declares.
|
|
330
337
|
- Sidebar position is the governed `order:` key: documents that declare it come
|
|
331
338
|
first, ascending; the rest follow in name order.
|
|
332
|
-
- One order drives the sidebar, `llms.txt`,
|
|
333
|
-
link
|
|
339
|
+
- One order drives every surface — the sidebar, `llms.txt`, the home page's
|
|
340
|
+
first-document link, and the MCP `outline` tool an agent reads to decide what
|
|
341
|
+
to read first. Set it once and they agree. The door picks up a reorder at the
|
|
342
|
+
next `pnpm refresh`, which costs no embedding: only the ordering changed.
|
|
334
343
|
- Never `meta.json` or `sidebar_position`: the checker refuses framework files
|
|
335
344
|
in the record, which has to read the same without the site.
|
|
336
345
|
|
|
@@ -42,3 +42,10 @@ act on it.
|
|
|
42
42
|
Ask your coding agent to run the **intake interview** (it knows how — see
|
|
43
43
|
`.agents/skills/intake-interview/`), answer its questions, and let it write
|
|
44
44
|
this document with you.
|
|
45
|
+
|
|
46
|
+
Until you do, `ksor serve` says so — at boot, and to every agent that connects:
|
|
47
|
+
the MCP surface replaces this template with a plain statement that the record's
|
|
48
|
+
scope is unstated, rather than passing authoring guidance to a runtime agent as
|
|
49
|
+
if it were instructions. Nothing breaks, and the record still answers with
|
|
50
|
+
citations; it just cannot tell an agent what it is authoritative FOR, which is
|
|
51
|
+
the one thing that makes an answer worth trusting.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appName, mcpEndpoint, mcpNamespace, recordVersion } from "@/lib/shared";
|
|
1
|
+
import { appName, mcpEndpoint, mcpNamespace, recordDescription, recordVersion } from "@/lib/shared";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* `/.well-known/mcp/server.json` — how an agent DISCOVERS this record's MCP
|
|
@@ -33,7 +33,10 @@ export function GET(): Response {
|
|
|
33
33
|
{
|
|
34
34
|
$schema: SCHEMA,
|
|
35
35
|
name: `${mcpNamespace()}/${appName}`,
|
|
36
|
-
|
|
36
|
+
// The record's OWN account of itself — see recordDescription. A
|
|
37
|
+
// description identical in every ksor record cannot help an agent choose
|
|
38
|
+
// one, and a record with no scope yet says so instead of guessing.
|
|
39
|
+
description: recordDescription(),
|
|
37
40
|
version: recordVersion(),
|
|
38
41
|
// Absent until the owner declares where the server runs — an invented
|
|
39
42
|
// URL is worse than none, because an agent would try it and conclude the
|
|
@@ -59,6 +59,55 @@ function readInstanceTitle(): string {
|
|
|
59
59
|
|
|
60
60
|
export const appTitle: string = readInstanceTitle();
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* How this record describes ITSELF, in one line — what an agent reads in a
|
|
64
|
+
* registry listing to decide whether this record can answer its question.
|
|
65
|
+
*
|
|
66
|
+
* It comes from the record's own prose (instance.md's first real paragraph,
|
|
67
|
+
* which the intake interview writes) because the alternative is what shipped
|
|
68
|
+
* before: one hard-coded sentence, byte-identical in every ksor record ever
|
|
69
|
+
* scaffolded, telling a discovering agent nothing that distinguishes this record
|
|
70
|
+
* from any other. "Discoverability determines whether agents find you at all" is
|
|
71
|
+
* a product principle, and a description that cannot discriminate is not
|
|
72
|
+
* discoverability (found live 2026-08-21).
|
|
73
|
+
*
|
|
74
|
+
* An UNDESCRIBED record says so rather than borrowing a confident sentence it
|
|
75
|
+
* has not earned — the same answer the MCP door already gives an agent that
|
|
76
|
+
* connects, so the two surfaces do not disagree about whether this record knows
|
|
77
|
+
* what it is. The marker is the template's own unfilled placeholder, matched on
|
|
78
|
+
* the WHOLE body: a scaffold's first paragraphs are authoring guidance, and
|
|
79
|
+
* reading one of those as the record's scope is worse than admitting there is
|
|
80
|
+
* none.
|
|
81
|
+
*/
|
|
82
|
+
const TEMPLATE_MARKER = "_fill this in; it is";
|
|
83
|
+
|
|
84
|
+
function readInstanceScope(): string | null {
|
|
85
|
+
const text = readFileSync(findInstance(process.cwd()), "utf8");
|
|
86
|
+
const body = text.replace(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/, "");
|
|
87
|
+
if (body.includes(TEMPLATE_MARKER)) return null;
|
|
88
|
+
const afterHeading = body.replace(/^[\s\S]*?^#[ \t]+.+$/m, "");
|
|
89
|
+
for (const para of afterHeading.split(/\n[ \t]*\n/)) {
|
|
90
|
+
const one = para.trim().replace(/\s+/g, " ");
|
|
91
|
+
if (one === "" || one.startsWith("#") || one.startsWith("-") || one.startsWith(">")) continue;
|
|
92
|
+
const sentence = /^(.+?[.!?])(\s|$)/.exec(one)?.[1] ?? one;
|
|
93
|
+
return sentence.length > 300 ? `${sentence.slice(0, 297)}...` : sentence;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** null until the owner has written one — never a guess. */
|
|
99
|
+
export const appScope: string | null = readInstanceScope();
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The one-line description every discovery surface publishes. Built here so the
|
|
103
|
+
* registry document and anything else that needs one cannot drift apart.
|
|
104
|
+
*/
|
|
105
|
+
export function recordDescription(): string {
|
|
106
|
+
return appScope === null
|
|
107
|
+
? `${appTitle} — its owner has not yet described what this record covers.`
|
|
108
|
+
: `${appTitle} — ${appScope}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
62
111
|
/**
|
|
63
112
|
* Where this record's MCP surface is published, if the owner has said.
|
|
64
113
|
*
|
|
@@ -343,13 +343,30 @@ function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
|
|
|
343
343
|
return { files: [...documents, ...assets], documents: documents.length, total };
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Remove the stage, asking for the retries this exact failure needs.
|
|
348
|
+
*
|
|
349
|
+
* `force: true` suppresses ENOENT; it does NOT retry anything. Node retries
|
|
350
|
+
* EBUSY / EMFILE / ENFILE / ENOTEMPTY / EPERM only when `maxRetries` is set,
|
|
351
|
+
* and it defaults to zero. The build evaluates `source.config.ts` more than
|
|
352
|
+
* once when the bundler wants it in more than one place, so two runs can
|
|
353
|
+
* overlap: one removing the stage while the other is still copying into it.
|
|
354
|
+
* That surfaced as `ENOTEMPTY` out of `rmSync` and failed the whole site build
|
|
355
|
+
* (CI, 2026-08-21) — a race that is safe to lose, because the stage is a
|
|
356
|
+
* deterministic function of the record and the denylist, so redoing it produces
|
|
357
|
+
* the same bytes.
|
|
358
|
+
*/
|
|
359
|
+
function removeStage(stageDir: string): void {
|
|
360
|
+
rmSync(stageDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
|
361
|
+
}
|
|
362
|
+
|
|
346
363
|
/** Fill a clean stage with exactly the set this build may publish. */
|
|
347
364
|
function fillStage(recordDir: string, stageDir: string, denied: DenylistManifest): void {
|
|
348
365
|
// The old stage goes first, before any refusal can throw: a refused build
|
|
349
366
|
// that leaves the previous, more permissive stage on disk hands the next
|
|
350
367
|
// careless build a filtered copy nothing governs (review finding,
|
|
351
368
|
// 2026-08-19).
|
|
352
|
-
|
|
369
|
+
removeStage(stageDir);
|
|
353
370
|
const plan = planStage(recordDir, denied);
|
|
354
371
|
// An empty record is its own problem, reported by the page that renders it;
|
|
355
372
|
// an empty AUDIENCE is a misconfiguration that would otherwise surface as
|
|
@@ -468,7 +485,7 @@ export function knowledgeSourceDir(): string {
|
|
|
468
485
|
// A stage left behind by an earlier model would be a filtered copy of the
|
|
469
486
|
// record nothing governs any more — removed before the refusal below can
|
|
470
487
|
// throw, so a refused build never leaves one behind either.
|
|
471
|
-
|
|
488
|
+
removeStage(stageDir);
|
|
472
489
|
refuseVisibilityWithoutAudiences(recordDir);
|
|
473
490
|
return RECORD_DIR;
|
|
474
491
|
}
|