@panaversity/ksor 0.0.50 → 0.0.52

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 CHANGED
@@ -1,5 +1,136 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.52
4
+
5
+ ### Patch Changes
6
+
7
+ - 1e60b9d: The site prints natural names for the actors a record cites, where the record
8
+ declares them.
9
+
10
+ A governed page led with a slug — "Owner · human:bashiraziz" — on every owner,
11
+ trust, approval and withdrawal line. `.ksor/people.yaml` maps an actor to the
12
+ name a page should print, and the site substitutes it at render time. An actor
13
+ with no entry renders exactly as stored: no splitting, no camelCase guessing,
14
+ no derivation. A display name is the one thing in a governance line that cannot
15
+ be computed — `bashiraziz` is equally "Bashir Aziz" and "Bashira Ziz" — so the
16
+ owner is its only source.
17
+
18
+ A MAP keyed by the whole identifier, not a list of names a handle is derived
19
+ from. The derivation could only ever match a handle that IS somebody's squashed
20
+ full name, so `human:ciso`, `human:audit-lead` and `human:mjs` — most of the
21
+ actors in a real record — had no expressible name at all; and it collided, since
22
+ two different people can squash to one handle.
23
+
24
+ Deliberately NOT part of `.ksor/governance.yaml`. That file is the root of
25
+ authority: its key set is closed so nothing can sit there without being
26
+ enforced, and its digest is hashed into `build.lock.json` — so a display name
27
+ living there would mean correcting the spelling of somebody's name refused the
28
+ next site build as `ksor-lock-stale`. Appearing in `people.yaml` confers no
29
+ authority; it only changes what is printed, and nothing cross-checks the two
30
+ lists, because a person who leaves the authority list is still the recorded
31
+ approver of everything they approved.
32
+
33
+ - f23cddc: `ksor calibrate --check` reports whether a declared abstention floor is still
34
+ holding, from the record's own traffic.
35
+
36
+ A floor is measured once and the record then grows. As it does, questions that
37
+ used to be out-of-corpus start scoring above a fixed number, so the record
38
+ answers what it used to refuse — no error, nothing logged, and the same
39
+ `gate: { floor: … }` in every envelope. AGENTS.md forbids copying a calibrated
40
+ constant between corpora; the same reasoning applies across time within one
41
+ corpus, and nothing enforced it (#182).
42
+
43
+ It needs no telemetry and no new dependency: every search already leaves an
44
+ audit row carrying the gate's own signal, on both sides of the gate, so this is
45
+ one indexed query — no provider key, no embedding call, no LLM. It reports the
46
+ abstain rate, the percentiles of answered top scores, and how many answers
47
+ landed within 0.01 of the floor (the size of the decision in this project's own
48
+ gold, not a threshold somebody picked).
49
+
50
+ **It never fails a run**, and that is the design rather than a limitation. A
51
+ stale floor wants re-measuring; failing a build for one would make the shortest
52
+ way out deleting `vector_floor` — turning the abstention gate off entirely to
53
+ clear the error, which is the escape `build/lifecycle-notice.ts` refuses to
54
+ create for a passed review date. It is also a monitor and not a measurement: it
55
+ can say a floor has gone permissive against real traffic, never that it is too
56
+ strict for questions nobody asked, and it says so rather than reporting a
57
+ healthy-looking nothing on a record no one queries.
58
+
59
+ - a403e19: the served envelope now discloses when a §7 audit row could not be written (issue #150)
60
+
61
+ ## 0.0.51
62
+
63
+ ### Patch Changes
64
+
65
+ - 6478ca4: Test infrastructure only — nothing an adopter installs behaves differently.
66
+
67
+ Every database-tier suite now bootstraps its scratch database under a name
68
+ unique to the run (`ksor_<slug>_<base36 ms>_<6 hex>`) instead of a fixed one.
69
+ Fixed names meant two runs against one Postgres — a second `pnpm test:db`, a CI
70
+ matrix job, an agent running the tier alongside a person — dropped each other's
71
+ database `WITH (FORCE)` mid-test, which surfaced as a missing table or a short
72
+ row count and read as flakiness. A new reaper (`scripts/db-reaper.ts`, the
73
+ tier's globalSetup) drops what an interrupted run leaks, and guard rule 12 keeps
74
+ the naming from drifting back.
75
+
76
+ - c466d4b: The scaffold moves to Fumadocs `16.15.4` (`fumadocs-core`, `fumadocs-ui`) and
77
+ `fumadocs-mdx` `15.4.0`.
78
+
79
+ Maintenance, not a fix — no advisory pushed it, and `npm audit` was already
80
+ clean. It is taken now because the four behaviours the scaffold cites BY VERSION
81
+ were re-verified against the new bytes rather than assumed, and each holds:
82
+ `CalloutType` is still the same six values (`fumadocs-ui/dist/components/callout.d.ts`);
83
+ `resolveHref` still resolves only the `./` and `../` forms and returns everything
84
+ else untouched, which is why the record keeps its own resolver; `remark-code-tab`
85
+ still honours `tab-group` on the `CodeBlockTabs` branch only, which is why the
86
+ scaffold picks that branch; and the search engine is still ZBSearch, so the
87
+ `language` option stays absent. Those citations now name `16.15.4`.
88
+
89
+ `fumadocs-ui` pins `fumadocs-core` exactly, so the two always move together;
90
+ `fumadocs-mdx@15.4.0` requires `fumadocs-core ^16.15.3`, which is what makes this
91
+ one change rather than three. Nothing else moves with it — Fumadocs peers Next as
92
+ a range (`16.x.x`). The committed pnpm lockfile is regenerated to match.
93
+
94
+ - 69d57f2: `ksor migrate --write-site` no longer deletes dependencies the adopter added to
95
+ their site.
96
+
97
+ Every file under `system/site` is offered as a whole-file replacement, which is
98
+ right for the copied rule modules and wrong for `system/site/package.json` — a
99
+ register ksor and the adopter both write in. Copying it whole removed anything
100
+ they had added, inside the same hunk that carried a pin bump, so a project could
101
+ stop building on the release meant to fix it. It is now merged per section: the
102
+ entries ksor ships move to this release's versions, the adopter's own survive,
103
+ and an entry ksor no longer ships is left alone rather than deleted (the tool
104
+ cannot tell one it retired from one they added).
105
+
106
+ Adds `docs/upgrading.md`, which ships in the tarball: the four-step path, the
107
+ table of what migrate carries, the list of files it does not — so an adopter
108
+ knows what to diff by hand — and the refusals to expect.
109
+
110
+ - 4b077aa: The scaffold pins Next `16.3.3`, clearing three high-severity advisories a
111
+ fresh `npm install` reported (#207).
112
+
113
+ `next@16.2.9` pulled `sharp@0.34.5` and `postcss@8.4.31`; the advisories are
114
+ against those, not against anything the scaffold declares, so the bump that
115
+ fixes them is Next's own. Measured on a fresh scaffold from the published CLI:
116
+ `npm audit` goes from **3 high to 0**, the static build takes 41.8s and emits
117
+ its 22 pages, and `llms.txt` carries its 5 entries unchanged. `16.3.3` is not
118
+ semver-major and Fumadocs peers Next as a range (`16.x.x`), so nothing else
119
+ moves with it. The committed pnpm lockfile is regenerated to match — the half
120
+ that would otherwise break an adopter whose CI installs frozen.
121
+
122
+ An existing project takes both across with
123
+ `ksor migrate --write-site`, which offers every file of `system/site` this
124
+ release emits — the pin and the config among them. It prints the diff and
125
+ changes nothing without `--write`.
126
+
127
+ The scaffold's `next.config.mjs` also sets `agentRules: false`. From Next 16.3,
128
+ a `next dev` that detects a coding agent writes `AGENTS.md` and `CLAUDE.md` into
129
+ the Next project root — which here is `system/site`, where the record's own
130
+ hygiene rule refuses markdown (`ksor-site-holds-content`: the site renders the
131
+ record, it never holds it). Left on, an adopter's `pnpm dev` turned their own
132
+ `pnpm check` red without their touching anything.
133
+
3
134
  ## 0.0.50
4
135
 
5
136
  ### Patch Changes
package/dist/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { n as resolveCommand, r as verbs, t as exitCodes } from "./src-dqpI-p1a.mjs";
3
- import { A as parseViewer, B as tlsPosture, C as contentPoolMin, D as outlineHandler, E as keyRingFromEnv, F as runProbe$1, H as withPgRetry$1, I as searchHandler, L as servingPolicy$1, M as prewarmPool, N as readHandler, O as parseInstanceText$1, P as recordIsUndescribed, R as storedTextSearchConfig, S as contentPool$1, T as instancePathOf$1, U as withProbeDeadline$1, V as validateViewer, W as z$1, _ as assertGovernanceServable$1, a as GovernanceGateError$1, b as checkEmbeddingSpace$1, c as McpServer$1, d as READ_ONLY, f as READ_OUTPUT, g as TextSearchConfigMismatch, h as TRUST_TIERS$1, i as FLOOR, j as pooledEndpointFor, k as parseTrustFloor, l as MissingProviderKeyError$1, m as SchemaVersionError, n as ContentStoreError$1, o as MAX_OUTLINE_LIMIT, p as SEARCH_OUTPUT, r as EmbeddingSpaceMismatch$1, t as AudienceError$1, u as OUTLINE_OUTPUT, v as assertSchemaCompatible, w as embedQueryVlit, x as composeInstructions, y as buildShippedProvider$1, z as tallyHandlers } from "./gateway-api-D8HlLys2-Ca8OnwLn.mjs";
3
+ import { A as parseViewer, B as tlsPosture, C as contentPoolMin, D as outlineHandler, E as keyRingFromEnv, F as runProbe$1, H as withPgRetry$1, I as searchHandler, L as servingPolicy$1, M as prewarmPool, N as readHandler, O as parseInstanceText$1, P as recordIsUndescribed, R as storedTextSearchConfig, S as contentPool$1, T as instancePathOf$1, U as withProbeDeadline$1, V as validateViewer, W as z$1, _ as assertGovernanceServable$1, a as GovernanceGateError$1, b as checkEmbeddingSpace$1, c as McpServer$1, d as READ_ONLY, f as READ_OUTPUT, g as TextSearchConfigMismatch, h as TRUST_TIERS$1, i as FLOOR, j as pooledEndpointFor, k as parseTrustFloor, l as MissingProviderKeyError$1, m as SchemaVersionError, n as ContentStoreError$1, o as MAX_OUTLINE_LIMIT, p as SEARCH_OUTPUT, r as EmbeddingSpaceMismatch$1, t as AudienceError$1, u as OUTLINE_OUTPUT, v as assertSchemaCompatible, w as embedQueryVlit, x as composeInstructions, y as buildShippedProvider$1, z as tallyHandlers } from "./gateway-api-Ck1l_Sgs-B-_wMYLB.mjs";
4
4
  import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { InMemoryTransport, LATEST_PROTOCOL_VERSION, createMcpHandler } from "@modelcontextprotocol/server";
@@ -17,7 +17,7 @@ import { Document, YAMLParseError, isCollection, isMap, isPair, isSeq, parseAllD
17
17
  import { parseArgs } from "node:util";
18
18
  import { readFile } from "node:fs/promises";
19
19
  import { execFileSync, spawnSync } from "node:child_process";
20
- //#region ../content-gateway/dist/main-DMCMO8ye.mjs
20
+ //#region ../content-gateway/dist/main-mQtKKwyj.mjs
21
21
  /**
22
22
  * The default registration — and the ORIGINAL of the file `ksor init` emits.
23
23
  *
@@ -4709,7 +4709,7 @@ async function withPgRetry(op, options = {}) {
4709
4709
  throw lastError;
4710
4710
  }
4711
4711
  //#endregion
4712
- //#region ../content/dist/commands-BNxmBRjs.mjs
4712
+ //#region ../content/dist/commands-Cb63_n_A.mjs
4713
4713
  /**
4714
4714
  * EVAL-LOCKED constants, quarried verbatim from the oracle
4715
4715
  * (sor-agentfactory @ b554f91, config.py) — changing any of these is a
@@ -9743,6 +9743,122 @@ function pct(fraction) {
9743
9743
  return `${(fraction * 100).toFixed(0)}%`;
9744
9744
  }
9745
9745
  /**
9746
+ * How close to the floor an answer has to be to count as marginal.
9747
+ *
9748
+ * 0.01 is not a tuned threshold — it is the size of the decision in this
9749
+ * record's own gold, quoted rather than invented: in-corpus at 0.730 / 0.671
9750
+ * against a scope-adjacent near-miss at 0.683 (`evals/behavioural.db.test.ts`),
9751
+ * so a hundredth of a cosine is the smallest difference that has ever changed
9752
+ * an answer here. An answer inside that band is one the floor barely admitted.
9753
+ */
9754
+ const MARGIN_BAND = .01;
9755
+ /** Nearest-rank percentile over a sorted ascending list. */
9756
+ function percentile(sorted, q) {
9757
+ if (sorted.length === 0) return null;
9758
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))] ?? null;
9759
+ }
9760
+ /**
9761
+ * Characterise the traffic against the declared floor.
9762
+ *
9763
+ * Two numbers carry the verdict, and both are reported whatever it says,
9764
+ * because the verdict is a reading aid and the numbers are the evidence:
9765
+ *
9766
+ * the ABSTAIN RATE — a gate that has stopped refusing anything is
9767
+ * either serving a record that now covers its
9768
+ * traffic, or a floor that has fallen behind it.
9769
+ * the MARGINAL SHARE — answers the floor barely admitted. These are the
9770
+ * ones that would flip if the number moved at all,
9771
+ * so a large share means the answer set turns on a
9772
+ * constant measured against a smaller corpus.
9773
+ *
9774
+ * `watch` is deliberately not called "stale". This cannot tell the two causes
9775
+ * apart — only a re-measurement can — so it names what it saw and says what to
9776
+ * run.
9777
+ */
9778
+ function driftReport(floor, samples) {
9779
+ const answeredScores = samples.filter((s) => !s.abstained).map((s) => s.topCosine).sort((a, b) => a - b);
9780
+ const abstained = samples.length - answeredScores.length;
9781
+ const marginal = answeredScores.filter((score) => score - floor <= .010000001).length;
9782
+ const marginalShare = answeredScores.length === 0 ? 0 : marginal / answeredScores.length;
9783
+ const abstainRate = samples.length === 0 ? 0 : abstained / samples.length;
9784
+ const base = {
9785
+ floor,
9786
+ samples: samples.length,
9787
+ abstained,
9788
+ answered: answeredScores.length,
9789
+ marginal,
9790
+ marginalShare,
9791
+ abstainRate,
9792
+ p05: percentile(answeredScores, .05),
9793
+ p50: percentile(answeredScores, .5),
9794
+ p95: percentile(answeredScores, .95)
9795
+ };
9796
+ if (samples.length < 30) return {
9797
+ ...base,
9798
+ verdict: "no-data",
9799
+ why: `only ${samples.length} logged search(es) — too few to characterise; this reports traffic, so a record nobody queries says nothing rather than looking healthy`
9800
+ };
9801
+ if (marginalShare >= .2) return {
9802
+ ...base,
9803
+ verdict: "watch",
9804
+ why: `${marginal} of ${answeredScores.length} answers scored within ${MARGIN_BAND} of the floor — the answer set turns on this exact number`
9805
+ };
9806
+ if (abstained === 0) return {
9807
+ ...base,
9808
+ verdict: "watch",
9809
+ why: `the gate refused none of ${samples.length} searches — either the record now covers its traffic, or the floor has fallen behind it, and only a re-measurement tells them apart`
9810
+ };
9811
+ return {
9812
+ ...base,
9813
+ verdict: "steady",
9814
+ why: `${abstained} of ${samples.length} searches were refused, and ${marginal} answer(s) sat within ${MARGIN_BAND} of the floor`
9815
+ };
9816
+ }
9817
+ /** The report as the CLI prints it. Numbers first; the verdict is a reading aid. */
9818
+ function renderDrift(report, window) {
9819
+ const pct = (n) => `${(n * 100).toFixed(1)}%`;
9820
+ const score = (n) => n === null ? "—" : n.toFixed(3);
9821
+ const lines = [
9822
+ `floor drift — ${window}`,
9823
+ ` declared vector_floor ${report.floor.toFixed(3)}`,
9824
+ ` searches logged ${report.samples} (${report.answered} answered, ${report.abstained} abstained)`,
9825
+ ` abstain rate ${pct(report.abstainRate)}`,
9826
+ ` answered top score p05 ${score(report.p05)} p50 ${score(report.p50)} p95 ${score(report.p95)}`,
9827
+ ` within ${MARGIN_BAND} of floor ${report.marginal} (${pct(report.marginalShare)} of answers)`,
9828
+ ` verdict ${report.verdict.toUpperCase()} — ${report.why}`
9829
+ ];
9830
+ if (report.verdict === "watch") lines.push("", " This is traffic, not a calibration: it cannot tell a record that grew", " from a floor that fell behind. Re-measure to find out —", " ksor calibrate --instance instance.md --queries-file <your questions>", " and paste the floor it prints if it differs from the declared one.");
9831
+ return `${lines.join("\n")}\n`;
9832
+ }
9833
+ /**
9834
+ * The logged searches for this corpus, newest `days` days.
9835
+ *
9836
+ * Only rows carrying a NUMERIC `top_cosine`: an audit row shed under
9837
+ * saturation, or one written before that detail existed, is ABSENT rather than
9838
+ * counted as a zero — a shed row is a gap in the evidence, and scoring it as
9839
+ * zero would drag every statistic here toward a number nobody measured, in the
9840
+ * direction that makes a floor look safer than it is.
9841
+ *
9842
+ * Scoped by CORPUS as well as tenant, for the reason `readLedger` records: a
9843
+ * tenant serving two corpora would otherwise measure one record's floor
9844
+ * against the other's traffic. Read through `runAuditRead` — the serving role
9845
+ * has no SELECT on this table at all, deliberately, and widening that to read
9846
+ * a monitor would trade an audit guarantee for a convenience.
9847
+ */
9848
+ const DRIFT_SQL = `
9849
+ SELECT (detail->>'top_cosine')::float8 AS top_cosine,
9850
+ action = 'search_abstained' AS abstained
9851
+ FROM retrieval_log
9852
+ WHERE tenant_id = $1
9853
+ AND corpus_id = $2
9854
+ AND action IN ('similarity_searched','search_abstained')
9855
+ AND created_at > now() - ($3 || ' days')::interval
9856
+ AND jsonb_typeof(detail->'top_cosine') = 'number'
9857
+ ORDER BY created_at DESC
9858
+ LIMIT $4`;
9859
+ /** How many rows one check reads at most — a bound, so a busy record cannot make this expensive. */
9860
+ const DRIFT_LIMIT = 5e3;
9861
+ /**
9746
9862
  * Out-of-corpus probes scoring at or above the weakest in-corpus question,
9747
9863
  * worst first — the ones that decided the verdict. Empty when the measurement
9748
9864
  * separated, because then nothing held it open.
@@ -9939,9 +10055,14 @@ Usage:
9939
10055
  when the tree is in a repository; --source-commit overrides it.
9940
10056
  ksor calibrate --instance PATH [--queries-file PATH] [--ooc-file PATH]
9941
10057
  [--generation N] [--per-node N] [--min-chars N]
10058
+ ksor calibrate --instance PATH --check [--days N]
9942
10059
  Measure the abstention floor for this corpus and report it. A
9943
10060
  measurement that does not separate in-corpus from out-of-corpus prints
9944
10061
  the diagnosis and NO floor: there is no safe number to paste.
10062
+ --check reads the record's OWN logged searches instead and reports how
10063
+ the declared floor is holding against them — no provider key, no
10064
+ embedding call, no LLM. A monitor, never a gate: it says what to
10065
+ re-measure and always exits 0.
9945
10066
  ksor grant --instance PATH [--revoke]
9946
10067
  Authorize ingest for the instance's tenant (the row row-level security
9947
10068
  requires), or withdraw it. Idempotent; reports the state it established.
@@ -10292,13 +10413,19 @@ async function calibrateCommand(args) {
10292
10413
  "ooc-file": { type: "string" },
10293
10414
  generation: { type: "string" },
10294
10415
  "per-node": { type: "string" },
10295
- "min-chars": { type: "string" }
10416
+ "min-chars": { type: "string" },
10417
+ check: {
10418
+ type: "boolean",
10419
+ default: false
10420
+ },
10421
+ days: { type: "string" }
10296
10422
  }
10297
10423
  });
10298
10424
  const instance = loadInstance(values.instance);
10299
10425
  if (typeof instance === "number") return instance;
10300
10426
  const dsn = resolveDsn(instance);
10301
10427
  if (typeof dsn === "number") return dsn;
10428
+ if (values.check === true) return await checkFloorDrift(instance, dsn, values.days);
10302
10429
  const provider = composeProvider(instance);
10303
10430
  if (typeof provider === "number") return provider;
10304
10431
  let queries = null;
@@ -10327,6 +10454,47 @@ async function calibrateCommand(args) {
10327
10454
  if (advice !== null) process.stdout.write(advice);
10328
10455
  return 0;
10329
10456
  }
10457
+ /** How many days of traffic one --check reads. Bounded so a busy record cannot make it expensive. */
10458
+ const DRIFT_DEFAULT_DAYS = 30;
10459
+ /**
10460
+ * `ksor calibrate --check` — is the declared floor still holding?
10461
+ *
10462
+ * Reads the record's OWN logged searches (`retrieval_log.detail.top_cosine`,
10463
+ * which is written on both sides of the gate) instead of measuring the corpus
10464
+ * again, so it needs no provider key, no embedding call and no LLM. That is
10465
+ * why it runs BEFORE the provider is composed: a check that demanded a vendor
10466
+ * key would be one an adopter never puts in CI.
10467
+ *
10468
+ * ALWAYS EXITS 0. A stale floor wants re-measuring; failing a run for one would
10469
+ * make the shortest way out deleting `vector_floor`, which turns the abstention
10470
+ * gate off entirely to clear the error — the same escape `lifecycle-notice.ts`
10471
+ * refuses to create for a passed review date.
10472
+ */
10473
+ async function checkFloorDrift(instance, dsn, daysArg) {
10474
+ const floor = instance.abstain.vectorFloor;
10475
+ if (floor === null) {
10476
+ process.stdout.write("floor drift: no floor declared — this record's gate is OFF, so out-of-corpus questions are answered rather than refused.\n fix: run `ksor calibrate` and paste the retrieval block it prints\n");
10477
+ return 0;
10478
+ }
10479
+ if (floor === "uncalibrated") {
10480
+ process.stdout.write("floor drift: vector_floor is `uncalibrated` — the door refuses every search until a measured number replaces it.\n fix: run `ksor calibrate` and paste the retrieval block it prints\n");
10481
+ return 0;
10482
+ }
10483
+ const days = daysArg === void 0 ? DRIFT_DEFAULT_DAYS : intFlag("--days", daysArg);
10484
+ const samples = (await withPool(dsn, (pool) => runAuditRead(pool, instance.tenantId, async (client) => {
10485
+ return (await client.query(DRIFT_SQL, [
10486
+ instance.tenantId,
10487
+ instance.corpusId,
10488
+ String(days),
10489
+ DRIFT_LIMIT
10490
+ ])).rows;
10491
+ }))).map((row) => ({
10492
+ topCosine: row.top_cosine,
10493
+ abstained: row.abstained
10494
+ }));
10495
+ process.stdout.write(renderDrift(driftReport(floor, samples), `last ${days} day(s)`));
10496
+ return 0;
10497
+ }
10330
10498
  async function grantCommand(args) {
10331
10499
  const { values } = parseArgs({
10332
10500
  args,
@@ -13059,9 +13227,10 @@ function siteChanges(root, templatesDir, stamps) {
13059
13227
  continue;
13060
13228
  }
13061
13229
  if (!SITE_TEXT_EXTENSIONS.has(path.extname(entry.name))) continue;
13062
- const after = applyProse(readFileSync(abs, "utf8").replaceAll("KSOR-STAMP-NAME", stamps.name).replaceAll("KSOR-STAMP-VERSION", stamps.version), manager);
13230
+ const rendered = applyProse(readFileSync(abs, "utf8").replaceAll("KSOR-STAMP-NAME", stamps.name).replaceAll("KSOR-STAMP-VERSION", stamps.version), manager);
13063
13231
  const target = path.join(root, child);
13064
13232
  const before = existsSync(target) ? readFileSync(target, "utf8") : null;
13233
+ const after = child === "system/site/package.json" && before !== null ? mergeSiteManifest(before, rendered) : rendered;
13065
13234
  if (before !== after) out.push({
13066
13235
  path: child,
13067
13236
  before,
@@ -13073,6 +13242,61 @@ function siteChanges(root, templatesDir, stamps) {
13073
13242
  return out;
13074
13243
  }
13075
13244
  /**
13245
+ * The site manifest, merged: what ksor pins wins, what the adopter added stays.
13246
+ *
13247
+ * Applied per SECTION rather than per file. `dependencies`, `devDependencies`
13248
+ * and `scripts` are registers with two authors — ksor owns the entries it ships
13249
+ * (they are what the emitted site is built and tested against, and a security
13250
+ * bump reaches an existing project through exactly this path), and the adopter
13251
+ * owns everything else in them. A key ksor no longer ships is left alone rather
13252
+ * than deleted: this cannot tell one it retired from one the adopter added.
13253
+ *
13254
+ * Unparseable on either side falls back to the template, which is the same
13255
+ * whole-file offer every other site file gets — a diff, gated on `--write`.
13256
+ */
13257
+ function mergeSiteManifest(before, template) {
13258
+ const parse = (text) => {
13259
+ try {
13260
+ const value = JSON.parse(text);
13261
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
13262
+ } catch {
13263
+ return null;
13264
+ }
13265
+ };
13266
+ const mine = parse(before);
13267
+ const theirs = parse(template);
13268
+ if (mine === null || theirs === null) return template;
13269
+ const section = (key) => {
13270
+ const a = mine[key];
13271
+ const b = theirs[key];
13272
+ const table = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : null;
13273
+ const ours = table(b);
13274
+ const adopters = table(a);
13275
+ if (ours === null) return adopters ?? void 0;
13276
+ if (adopters === null) return ours;
13277
+ const merged = {};
13278
+ for (const [name, version] of Object.entries(adopters)) merged[name] = name in ours ? ours[name] : version;
13279
+ for (const [name, version] of Object.entries(ours)) if (!(name in merged)) merged[name] = version;
13280
+ return merged;
13281
+ };
13282
+ const MERGED = [
13283
+ "dependencies",
13284
+ "devDependencies",
13285
+ "scripts"
13286
+ ];
13287
+ const out = {
13288
+ ...mine,
13289
+ ...theirs
13290
+ };
13291
+ for (const key of MERGED) {
13292
+ const value = section(key);
13293
+ if (value === void 0) delete out[key];
13294
+ else out[key] = value;
13295
+ }
13296
+ const eol = before.includes("\r\n") ? "\r\n" : "\n";
13297
+ return `${JSON.stringify(out, null, indentOf(before)).replaceAll("\n", eol)}${/\r?\n$/.test(before) ? eol : ""}`;
13298
+ }
13299
+ /**
13076
13300
  * Which manager this repository was scaffolded for, from what it committed.
13077
13301
  * `ksor init` reads `npm_config_user_agent` — the run that scaffolds is the
13078
13302
  * run that knows — but a migration is a different run, so it reads the tree.
@@ -6,7 +6,7 @@ import { z, z as z$1 } from "zod";
6
6
  import path, { join } from "node:path";
7
7
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
8
8
  import pg from "pg";
9
- //#region ../content-gateway/dist/gateway-api-D8HlLys2.mjs
9
+ //#region ../content-gateway/dist/gateway-api-Ck1l_Sgs.mjs
10
10
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
11
11
  var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
12
12
  /**
@@ -10383,7 +10383,7 @@ async function search(ctx, query, k = 10) {
10383
10383
  }
10384
10384
  if (abstained) {
10385
10385
  const generation = hits[0]?.generation;
10386
- await logRead(ctx.pool, {
10386
+ const audited = await logRead(ctx.pool, {
10387
10387
  tenantId: inst.tenantId,
10388
10388
  corpusId: inst.corpusId,
10389
10389
  actor,
@@ -10415,7 +10415,8 @@ async function search(ctx, query, k = 10) {
10415
10415
  hits: [],
10416
10416
  snapshot: generation === void 0 ? null : snapshotEnvelope(ctx, generation),
10417
10417
  ...kNote === void 0 ? {} : { k_note: kNote },
10418
- ...degradedReason === void 0 ? {} : { degraded_reason: degradedReason }
10418
+ ...degradedReason === void 0 ? {} : { degraded_reason: degradedReason },
10419
+ ...audited ? {} : { audit: "degraded" }
10419
10420
  };
10420
10421
  }
10421
10422
  const budget = Math.min(SEARCH_BUDGET_CHARS, inst.maximumResponseCharacters);
@@ -10447,7 +10448,7 @@ async function search(ctx, query, k = 10) {
10447
10448
  });
10448
10449
  }
10449
10450
  const generation = hits[0]?.generation ?? 0;
10450
- await logRead(ctx.pool, {
10451
+ const audited = await logRead(ctx.pool, {
10451
10452
  tenantId: inst.tenantId,
10452
10453
  corpusId: inst.corpusId,
10453
10454
  actor,
@@ -10480,7 +10481,8 @@ async function search(ctx, query, k = 10) {
10480
10481
  ...truncated === 0 ? {} : { note: `${truncated} lower-ranked hit(s) dropped by the response budget — narrow the query or use the read tool` },
10481
10482
  ...advisory ? { content_advisory: CONTENT_ADVISORY } : {},
10482
10483
  ...kNote === void 0 ? {} : { k_note: kNote },
10483
- ...degradedReason === void 0 ? {} : { degraded_reason: degradedReason }
10484
+ ...degradedReason === void 0 ? {} : { degraded_reason: degradedReason },
10485
+ ...audited ? {} : { audit: "degraded" }
10484
10486
  };
10485
10487
  }
10486
10488
  const DOCUMENT_BUDGET_CHARS = 28e4;
@@ -10580,7 +10582,7 @@ async function readDocument(ctx, slug, options = {}) {
10580
10582
  const textChars = codePointLength(text);
10581
10583
  const totalChars = scoped.reduce((n, c) => n + codePointLength(c.content), 0);
10582
10584
  const sections = [...new Set(scoped.map((c) => c.headingPath.split("/")[0] ?? "").filter((s) => s !== ""))];
10583
- await logRead(ctx.pool, {
10585
+ const audited = await logRead(ctx.pool, {
10584
10586
  tenantId: inst.tenantId,
10585
10587
  corpusId: inst.corpusId,
10586
10588
  actor,
@@ -10618,7 +10620,8 @@ async function readDocument(ctx, slug, options = {}) {
10618
10620
  note: window.nextHeading === null ? "windowed — this is the last window (next is null)" : "windowed — continue with from_heading set to this response's next (it carries its own scope; do not also resend heading)"
10619
10621
  } : {},
10620
10622
  ...instructionLike(text) || instructionLike(frontmatter ?? "") ? { content_advisory: CONTENT_ADVISORY } : {},
10621
- snapshot_status: refreshed ?? (pinned === null ? "unpinned" : "pinned")
10623
+ snapshot_status: refreshed ?? (pinned === null ? "unpinned" : "pinned"),
10624
+ ...audited ? {} : { audit: "degraded" }
10622
10625
  };
10623
10626
  }
10624
10627
  async function outlineDocuments(ctx, options = {}) {
@@ -10642,7 +10645,7 @@ async function outlineDocuments(ctx, options = {}) {
10642
10645
  }), servingScope(ctx));
10643
10646
  const has_more = rows.length > limit;
10644
10647
  if (has_more) rows.length = limit;
10645
- await logRead(ctx.pool, {
10648
+ const audited = await logRead(ctx.pool, {
10646
10649
  tenantId: inst.tenantId,
10647
10650
  corpusId: inst.corpusId,
10648
10651
  actor,
@@ -10659,6 +10662,7 @@ async function outlineDocuments(ctx, options = {}) {
10659
10662
  });
10660
10663
  return {
10661
10664
  ...rows.some((r) => instructionLike(r.title) || instructionLike(r.headingPath ?? "")) ? { content_advisory: CONTENT_ADVISORY } : {},
10665
+ ...audited ? {} : { audit: "degraded" },
10662
10666
  has_more,
10663
10667
  limit,
10664
10668
  offset,
@@ -10820,7 +10824,8 @@ const SEARCH_OUTPUT = z.object({
10820
10824
  note: z.string().optional(),
10821
10825
  k_note: z.string().optional(),
10822
10826
  degraded_reason: z.string().optional().describe("Why retrieval was degraded. \"embed_unavailable\" = the provider is down and this record gates on a cosine floor, so nothing could be served. \"embed_unavailable_keyword_only\" = the provider is down and this record declares no floor, so these hits come from keyword search alone and rank differently."),
10823
- content_advisory: z.string().optional()
10827
+ content_advisory: z.string().optional(),
10828
+ audit: z.enum(["degraded"]).optional().describe("Present only when the §7 audit row for this act could not be written (shed under saturation). The answer is unaffected; absent means the row landed normally.")
10824
10829
  });
10825
10830
  const OUTLINE_OUTPUT = z.object({
10826
10831
  nodes: z.array(z.object({
@@ -10838,7 +10843,8 @@ const OUTLINE_OUTPUT = z.object({
10838
10843
  limit: z.number().int().describe("Rows this page could hold."),
10839
10844
  offset: z.number().int().describe("Rows skipped to produce this page."),
10840
10845
  next_offset: z.number().int().nullable().describe("Pass as offset to get the next page; null when this is the last one."),
10841
- has_more: z.boolean().describe("True when rows were cut at limit — the record has more, this list is partial.")
10846
+ has_more: z.boolean().describe("True when rows were cut at limit — the record has more, this list is partial."),
10847
+ audit: z.enum(["degraded"]).optional().describe("Present only when the §7 audit row for this act could not be written (shed under saturation). The answer is unaffected; absent means the row landed normally.")
10842
10848
  });
10843
10849
  const READ_OUTPUT = z.object({
10844
10850
  slug: z.string(),
@@ -10856,7 +10862,8 @@ const READ_OUTPUT = z.object({
10856
10862
  est_tokens: z.number().optional(),
10857
10863
  total_est_tokens: z.number().optional(),
10858
10864
  note: z.string().optional(),
10859
- content_advisory: z.string().optional()
10865
+ content_advisory: z.string().optional(),
10866
+ audit: z.enum(["degraded"]).optional().describe("Present only when the §7 audit row for this act could not be written (shed under saturation). The answer is unaffected; absent means the row landed normally.")
10860
10867
  });
10861
10868
  function toolError(error) {
10862
10869
  return {
package/dist/gateway.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { D as outlineHandler, I as searchHandler, N as readHandler, P as recordIsUndescribed, W as z$1, c as McpServer$1, d as READ_ONLY, f as READ_OUTPUT, h as TRUST_TIERS, i as FLOOR, o as MAX_OUTLINE_LIMIT, p as SEARCH_OUTPUT, s as MAX_SEARCH_K, u as OUTLINE_OUTPUT, x as composeInstructions } from "./gateway-api-D8HlLys2-Ca8OnwLn.mjs";
1
+ import { D as outlineHandler, I as searchHandler, N as readHandler, P as recordIsUndescribed, W as z$1, c as McpServer$1, d as READ_ONLY, f as READ_OUTPUT, h as TRUST_TIERS, i as FLOOR, o as MAX_OUTLINE_LIMIT, p as SEARCH_OUTPUT, s as MAX_SEARCH_K, u as OUTLINE_OUTPUT, x as composeInstructions } from "./gateway-api-Ck1l_Sgs-B-_wMYLB.mjs";
2
2
  export { FLOOR, MAX_OUTLINE_LIMIT, MAX_SEARCH_K, McpServer$1 as McpServer, OUTLINE_OUTPUT, READ_ONLY, READ_OUTPUT, SEARCH_OUTPUT, TRUST_TIERS, composeInstructions, outlineHandler, readHandler, recordIsUndescribed, searchHandler, z$1 as z };
package/docs/index.md CHANGED
@@ -51,6 +51,11 @@ instead of their training memory. The corpus grows with each implemented verb.
51
51
  current. Serving does not publish, so a first deploy with no ingest serves
52
52
  an empty record; this is the page that explains why, where ingest belongs
53
53
  (never inside the container), and how the abstention gate gets turned on.
54
+ - **[upgrading.md](./upgrading.md)** — moving an existing record onto a newer
55
+ ksor. `ksor migrate` offers a diff and changes nothing without `--write`;
56
+ `--write-site` is the one flag to remember, because it is the only path by
57
+ which a dependency bump reaches a project already scaffolded. Includes the
58
+ list of files migrate does NOT carry, so you know what to diff by hand.
54
59
  - **[authorization.md](./authorization.md)** — putting the record behind an
55
60
  authorization server, with worked recipes for two of them, executed rather
56
61
  than written. `ksor serve` refuses to boot unauthenticated on a public bind,
package/docs/ingesting.md CHANGED
@@ -230,6 +230,53 @@ belongs on the in-corpus side — moving it separates the measurement. Sometimes
230
230
  it is a genuine near-miss the corpus cannot separate, and then the floor
231
231
  correctly stays uncalibrated.
232
232
 
233
+ ### The floor goes stale as the record grows
234
+
235
+ A floor is measured once, against the corpus as it stood that day, and then the
236
+ record grows. As it does, questions that used to be out-of-corpus start scoring
237
+ above a fixed number — so the record answers what it used to refuse, with no
238
+ error, nothing logged, and the same `gate: { floor: … }` in every envelope. The
239
+ guarantee weakens in silence, in the dangerous direction.
240
+
241
+ You do not need telemetry to see it. Every search already leaves a row carrying
242
+ the gate's own signal, on both sides of it, so the check reads the record's own
243
+ traffic:
244
+
245
+ ```sh
246
+ pnpm exec ksor calibrate --instance instance.md --check
247
+ ```
248
+
249
+ No provider key, no embedding call, no LLM — one indexed query:
250
+
251
+ ```
252
+ floor drift — last 30 day(s)
253
+ declared vector_floor 0.550
254
+ searches logged 112 (100 answered, 12 abstained)
255
+ abstain rate 10.7%
256
+ answered top score p05 0.552 p50 0.810 p95 0.890
257
+ within 0.01 of floor 40 (40.0% of answers)
258
+ verdict WATCH — 40 of 100 answers scored within 0.01 of the floor
259
+ ```
260
+
261
+ Two numbers carry it. The **abstain rate**: a gate that has stopped refusing
262
+ anything is either serving a record that now covers its traffic, or a floor that
263
+ has fallen behind it. And the **share of answers within 0.01 of the floor** —
264
+ the ones that would flip if the number moved at all, 0.01 being the size of the
265
+ decision in this project's own gold rather than a threshold somebody picked.
266
+
267
+ Run it on a schedule, or in your own CI beside `ksor build`. Three things to
268
+ know about what it is:
269
+
270
+ - **It never fails a run.** It always exits 0. A stale floor wants
271
+ re-measuring, and failing a build for one would make the shortest way out
272
+ deleting `vector_floor` — turning the gate off entirely to clear the error.
273
+ - **It reads traffic, so it needs traffic**, and it says so rather than
274
+ reporting a healthy-looking nothing. It also cannot see questions nobody
275
+ asked: it can tell you the floor has gone permissive, never that it is too
276
+ strict.
277
+ - **It is not a calibration.** When it says WATCH, re-run `ksor calibrate` —
278
+ that is the measurement, and it is the thing that produces a new number.
279
+
233
280
  ## Withdrawing a document
234
281
 
235
282
  A takedown is a committed ledger entry FIRST and a database row second, written