@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 +131 -0
- package/dist/cli.mjs +229 -5
- package/dist/{gateway-api-D8HlLys2-Ca8OnwLn.mjs → gateway-api-Ck1l_Sgs-B-_wMYLB.mjs} +18 -11
- package/dist/gateway.mjs +1 -1
- package/docs/index.md +5 -0
- package/docs/ingesting.md +47 -0
- package/docs/tool-surface.md +25 -10
- package/docs/upgrading.md +106 -0
- package/package.json +3 -3
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +16 -1
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +16 -1
- package/templates/scaffold/.ksor/people.yaml +32 -0
- package/templates/scaffold/AGENTS.md +47 -1
- package/templates/scaffold/gitignore +1 -0
- package/templates/scaffold/pnpm-lock.yaml +304 -262
- package/templates/scaffold/system/site/components/governance.tsx +6 -4
- package/templates/scaffold/system/site/lib/actor-display.ts +53 -0
- package/templates/scaffold/system/site/lib/people.ts +82 -0
- package/templates/scaffold/system/site/lib/record-href.ts +1 -1
- package/templates/scaffold/system/site/next.config.mjs +13 -0
- package/templates/scaffold/system/site/package.json +4 -4
- package/templates/scaffold/system/site/source.config.ts +1 -1
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-
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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
|