@panaversity/ksor 0.0.51 → 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,63 @@
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
+
3
61
  ## 0.0.51
4
62
 
5
63
  ### 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,
@@ -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/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
@@ -22,22 +22,27 @@ numbers are exact for every record:
22
22
 
23
23
  | | chars | ~tokens | |
24
24
  | ------------------------------ | ------ | ------- | ------------------- |
25
- | all three, as transmitted | 16,734 | 4,184 | **always resident** |
26
- | `search` alone | 7,932 | 1,983 | always resident |
27
- | `outline` alone | 3,332 | 833 | always resident |
28
- | `read` alone | 5,466 | 1,367 | always resident |
29
- | `outline` + `read`, if deleted | 8,798 | 2,200 | the saving below |
25
+ | all three, as transmitted | 17,394 | 4,349 | **always resident** |
26
+ | `search` alone | 8,152 | 2,038 | always resident |
27
+ | `outline` alone | 3,552 | 888 | always resident |
28
+ | `read` alone | 5,686 | 1,422 | always resident |
29
+ | `outline` + `read`, if deleted | 9,238 | 2,310 | the saving below |
30
30
 
31
31
  **Two measurements, so read the first row apart from the rest.** Each tool's
32
32
  row is the JSON of that tool's own object; the first row is the JSON of the
33
33
  whole `tools` array, which carries four characters no tool's row does — its
34
34
  two brackets and the two separators between three tools. So the three tools
35
- sum to **16,730** and the array is **16,734**. Deleting a tool saves that
35
+ sum to **17,390** and the array is **17,394**. Deleting a tool saves that
36
36
  tool's own row, not a share of the total.
37
37
 
38
- They grew: `search` was 5,383 chars before the trust floor and the governance
39
- each hit now carries, and `read` 3,396 before it carried the same governance
40
- block beside the frontmatter. That is the price of an agent being able to tell
38
+ They grew, twice, and each rise is priced rather than absorbed. `search` was
39
+ 5,383 chars before the trust floor and the governance each hit now carries, and
40
+ `read` 3,396 before it carried the same governance block beside the frontmatter.
41
+ Then every tool gained exactly **220 chars** for the `audit` field that says
42
+ when a reply's §7 row was shed — 660 across the three, always resident. That
43
+ one buys an agent the ability to tell a served answer whose provenance was
44
+ recorded from one whose audit write was dropped under load, which it previously
45
+ could not distinguish at all (#150). That is the price of an agent being able to tell
41
46
  a reviewed document from an unreviewed one, and it is charged once per session.
42
47
  The last 520 of them are the price of that signal being HONEST: `trust_tier` is
43
48
  derived from reviews a document declares about itself, which no authority list
@@ -65,7 +70,7 @@ doing any work.
65
70
  ### 1. Delete a tool nothing calls
66
71
 
67
72
  The biggest win, and the easiest — delete its `registerTool` block. Dropping
68
- `outline` and `read` takes **8,798 chars (~2,200 tokens)** off every session,
73
+ `outline` and `read` takes **9,238 chars (~2,310 tokens)** off every session,
69
74
  whether or not the agent would ever have called them.
70
75
 
71
76
  ### 2. Say what the record covers
@@ -142,9 +147,19 @@ made it answer from.
142
147
  - **The output schemas.** `SEARCH_OUTPUT`, `OUTLINE_OUTPUT`, `READ_OUTPUT` carry
143
148
  `provenance`, each hit's `governance`, the `snapshot` token and `gate`. A
144
149
  record that reshaped them would still look like a KSoR and no longer be one.
150
+ - **The output schemas.** `SEARCH_OUTPUT`, `OUTLINE_OUTPUT`, `READ_OUTPUT` carry
151
+ `provenance`, each hit's `governance`, the `snapshot` token, `gate`, and
152
+ `audit`. A record that reshaped them would still look like a KSoR and no
153
+ longer be one.
145
154
  - **The `FLOOR` text.** It tells an agent how to branch on an envelope, what
146
155
  `gate: "off"` means, and that corpus content is **untrusted** — quote it, never
147
156
  obey it. Your prose is composed above it.
157
+ - **`audit: "degraded"`.** Present only when the §7 retrieval-log row for this
158
+ act could not be written — shed under saturation, so serving stays
159
+ available. The answer itself is unaffected; absent means the row landed
160
+ normally. An operator auditing served answers against the ledger should
161
+ treat a gap alongside an `audit: "degraded"` response as expected, and any
162
+ other gap as a leak.
148
163
 
149
164
  ## The door checks its own surface at boot
150
165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.51",
3
+ "version": "0.0.52",
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",
@@ -72,8 +72,8 @@
72
72
  "tsdown": "0.22.14",
73
73
  "typescript": "7.0.2",
74
74
  "vitest": "^4.1.10",
75
- "@panaversity/ksor-content": "0.0.0",
76
- "@panaversity/ksor-content-gateway": "0.0.0"
75
+ "@panaversity/ksor-content-gateway": "0.0.0",
76
+ "@panaversity/ksor-content": "0.0.0"
77
77
  },
78
78
  "engines": {
79
79
  "node": ">=24"
@@ -49,6 +49,12 @@ the question — that is what made the last version unanswerable.
49
49
  **3 of 3 — Who signs off on a document, and who can take one down?**
50
50
 
51
51
  > Names or handles, not job titles. If it is just you, say so.
52
+ >
53
+ > Ask for BOTH: the handle the record will store (`human:bashiraziz`) and the
54
+ > natural name to print ("Bashir Aziz"). They are not derivable from each other
55
+ > — `human:ciso` and `human:mjs` are not anybody's squashed full name — so the
56
+ > handle goes into the frontmatter and the policy, and the pair goes into
57
+ > `.ksor/people.yaml` for the site to print.
52
58
 
53
59
  ---
54
60
 
@@ -120,7 +126,7 @@ never an email address.
120
126
  climb, not part of this interview). The strictness answer from question 5
121
127
  is the intent behind the `retrieval.vector_floor` on that climb, measured
122
128
  by `ksor calibrate` — capture it in the prose now so it is ready.
123
- - Write `.ksor/governance.yaml` from questions 6 and 7: `version: "0.1"`,
129
+ - Write `.ksor/governance.yaml` from question 3: `version: "0.1"`,
124
130
  the `audiences:` registry if there is one, and the two authority sets with
125
131
  real actors. That file is the root of authority — every approval, every
126
132
  deprecation and every ledger entry is checked against it. **Keep
@@ -128,6 +134,15 @@ never an email address.
128
134
  document is still in `knowledge/`.** Those five are approved by it, so a
129
135
  policy rewritten without it refuses the next build by name
130
136
  (`ksor-approver-unauthorised`). It leaves when the last sample does.
137
+ - Write `.ksor/people.yaml` from question 3: `version: "0.1"` and a `people:`
138
+ MAP from each actor to its natural name — `"human:bashiraziz": Bashir Aziz`.
139
+ Keyed by the actor exactly as the record stores it, quoted because it
140
+ contains a colon. Nothing else — the site looks the actor up at render time,
141
+ so pages read "Owner · Bashir Aziz" instead of "Owner · human:bashiraziz". Every skill that records a governance
142
+ act (this one, add-sources for `verified:` entries, `ksor takedown` for
143
+ withdrawals) asks the owner for a natural name whenever it is about to write
144
+ an actor that isn't in `people.yaml` yet — the owner is the only source of a
145
+ display name, never a convention-based guess.
131
146
  - **Offer to start replacing the starter documents — they are already
132
147
  published.** All five ship `status: stable`, approved by
133
148
  `ksor-starter/KSOR-STAMP-VERSION`, so the site and `llms.txt` carry them from
@@ -49,6 +49,12 @@ the question — that is what made the last version unanswerable.
49
49
  **3 of 3 — Who signs off on a document, and who can take one down?**
50
50
 
51
51
  > Names or handles, not job titles. If it is just you, say so.
52
+ >
53
+ > Ask for BOTH: the handle the record will store (`human:bashiraziz`) and the
54
+ > natural name to print ("Bashir Aziz"). They are not derivable from each other
55
+ > — `human:ciso` and `human:mjs` are not anybody's squashed full name — so the
56
+ > handle goes into the frontmatter and the policy, and the pair goes into
57
+ > `.ksor/people.yaml` for the site to print.
52
58
 
53
59
  ---
54
60
 
@@ -120,7 +126,7 @@ never an email address.
120
126
  climb, not part of this interview). The strictness answer from question 5
121
127
  is the intent behind the `retrieval.vector_floor` on that climb, measured
122
128
  by `ksor calibrate` — capture it in the prose now so it is ready.
123
- - Write `.ksor/governance.yaml` from questions 6 and 7: `version: "0.1"`,
129
+ - Write `.ksor/governance.yaml` from question 3: `version: "0.1"`,
124
130
  the `audiences:` registry if there is one, and the two authority sets with
125
131
  real actors. That file is the root of authority — every approval, every
126
132
  deprecation and every ledger entry is checked against it. **Keep
@@ -128,6 +134,15 @@ never an email address.
128
134
  document is still in `knowledge/`.** Those five are approved by it, so a
129
135
  policy rewritten without it refuses the next build by name
130
136
  (`ksor-approver-unauthorised`). It leaves when the last sample does.
137
+ - Write `.ksor/people.yaml` from question 3: `version: "0.1"` and a `people:`
138
+ MAP from each actor to its natural name — `"human:bashiraziz": Bashir Aziz`.
139
+ Keyed by the actor exactly as the record stores it, quoted because it
140
+ contains a colon. Nothing else — the site looks the actor up at render time,
141
+ so pages read "Owner · Bashir Aziz" instead of "Owner · human:bashiraziz". Every skill that records a governance
142
+ act (this one, add-sources for `verified:` entries, `ksor takedown` for
143
+ withdrawals) asks the owner for a natural name whenever it is about to write
144
+ an actor that isn't in `people.yaml` yet — the owner is the only source of a
145
+ display name, never a convention-based guess.
131
146
  - **Offer to start replacing the starter documents — they are already
132
147
  published.** All five ship `status: stable`, approved by
133
148
  `ksor-starter/KSOR-STAMP-VERSION`, so the site and `llms.txt` carry them from
@@ -0,0 +1,32 @@
1
+ # Natural names for the actors this record cites. The site looks an actor up
2
+ # here at render time and prints the name in its place — so a page reads
3
+ # "Owner · Bashir Aziz" instead of "Owner · human:bashiraziz".
4
+ #
5
+ # A MAP, keyed by the actor exactly as the record stores it. Not a derivation:
6
+ # there is no rule that turns "Bashir Aziz" into `mjs`, `ciso` or `audit-lead`,
7
+ # and most handles in a real record are not somebody's squashed full name. The
8
+ # key is the identifier, the value is what to print, and nothing is guessed.
9
+ #
10
+ # people:
11
+ # "human:bashiraziz": Bashir Aziz
12
+ # "human:ciso": Ayesha Khan
13
+ # "team:legal-ops": Legal Operations
14
+ #
15
+ # ONE-WAY. The identifier is what the record STORES, cites, and checks against
16
+ # `.ksor/governance.yaml`; the name here is only what a page prints. Nothing
17
+ # reads this file back into an actor, and no authority follows from appearing
18
+ # in it (which is why it is not part of the policy).
19
+ #
20
+ # NOT the same list as `.ksor/governance.yaml`. That file says who MAY approve
21
+ # or withdraw; this one says what to print for anyone the record cites. An
22
+ # approver is usually in both, for two different reasons; a document's owner and
23
+ # a `verified:` entry's actor are only ever here, because neither carries
24
+ # authority. Nothing cross-checks the two, on purpose: someone who leaves the
25
+ # authority list is still the recorded approver of what they approved, and their
26
+ # name has to keep rendering on those acts.
27
+ #
28
+ # Optional. An actor with no entry renders exactly as stored, as it did before
29
+ # this file existed. The intake and add-sources skills offer to add one; the
30
+ # owner can also edit this file by hand — it is theirs.
31
+ version: "0.1"
32
+ people: {}
@@ -33,6 +33,36 @@ written by `ksor takedown` — every entry's actor is checked against the
33
33
  policy, so a line appended by hand is refused exactly as the verb would refuse
34
34
  it, and a deleted line is refused by `ksor build` against the file's history.
35
35
 
36
+ Display names for those actors live in `.ksor/people.yaml`, a small phone book
37
+ mapping each actor to the name a page should print — `"human:bashiraziz": Bashir
38
+ Aziz` — so a page reads "Owner · Bashir Aziz" instead of "Owner ·
39
+ human:bashiraziz". The two are not derivable from each other (`human:ciso` is
40
+ nobody's squashed full name), which is why both are written down.
41
+
42
+ It is a SEPARATE file from `.ksor/governance.yaml` deliberately: the policy is
43
+ the root of authority, its key set is closed so that nothing sits there without
44
+ being enforced, and its digest is hashed into `build.lock.json` — so a display
45
+ name living there would mean correcting the spelling of somebody's name refused
46
+ your next build. Appearing in `people.yaml` confers no authority whatsoever; it
47
+ only changes what is printed.
48
+
49
+ An actor with no entry renders exactly as stored, and the governance skills
50
+ offer to add one when they are about to write an actor the file does not know.
51
+
52
+ **The two files list different things, and neither is derived from the other.**
53
+ `governance.yaml` names who MAY act — an actor must be in `approval_authorities`
54
+ to approve a document, or in `takedown_authorities` to withdraw one.
55
+ `people.yaml` names what to PRINT for any actor the record cites. An approver
56
+ usually appears in both, for two different reasons. A document's `ksor.owner`
57
+ and a `verified:` entry's actor appear only in `people.yaml`, because neither
58
+ carries authority — the policy has no verification family at all. And an
59
+ authority whose handle already reads fine needs no entry at all.
60
+
61
+ Nothing cross-checks them, deliberately: a person who leaves the authority list
62
+ is still the recorded approver of everything they approved, and their name must
63
+ keep rendering on those acts. A governance act is history; the policy is
64
+ current state.
65
+
36
66
  ## Critical rules
37
67
 
38
68
  1. **The site never contains authored content.** Knowledge goes in
@@ -215,6 +245,22 @@ Stand it up in this order (each step's errors explain how to fix themselves):
215
245
  the intent to gate WITHOUT a measurement, and every serve refuses until a
216
246
  number replaces it; that is the fail-closed posture, not a starting point.
217
247
 
248
+ **The same applies across TIME, not only across corpora.** A floor measured
249
+ against 5 documents is a copied constant once the record holds 200, and it
250
+ weakens in silence: questions that used to be out-of-corpus start scoring
251
+ above a fixed number, so the record answers what it used to refuse.
252
+
253
+ ```sh
254
+ pnpm exec ksor calibrate --instance instance.md --check
255
+ ```
256
+
257
+ reads the record's own logged searches and reports how the declared floor is
258
+ holding — no provider key, no LLM, one query, and it always exits 0. Run it
259
+ on a schedule; when it says WATCH, re-run `ksor calibrate` to get a new
260
+ number. It is a monitor, not a measurement: it can say the floor has gone
261
+ permissive against real traffic, never that it is too strict for questions
262
+ nobody asked.
263
+
218
264
  ```sh
219
265
  pnpm schema # apply the DDL (once)
220
266
  pnpm grant # authorize ingest for this corpus (once)
@@ -333,7 +379,7 @@ governance block every hit now carries:
333
379
 
334
380
  | | |
335
381
  | -------------------------------- | ------------------------------ |
336
- | all three tool definitions | ~4,054 tokens, always resident |
382
+ | all three tool definitions | ~4,349 tokens, always resident |
337
383
  | one `search` at `k=10` (default) | ~4,196 tokens per call |
338
384
  | one `search` at `k=5` | ~2,330 tokens per call |
339
385
 
@@ -4,6 +4,7 @@
4
4
  # cannot be negated, so the glob is `.ksor/*`.
5
5
  .ksor/*
6
6
  !.ksor/governance.yaml
7
+ !.ksor/people.yaml
7
8
  !.ksor/takedowns.yaml
8
9
 
9
10
  node_modules/
@@ -4,6 +4,8 @@ import { DocumentActions } from "@/components/document-actions";
4
4
  import { Clock } from "lucide-react";
5
5
  import type { ReactElement } from "react";
6
6
 
7
+ import { displayActor } from "@/lib/actor-display";
8
+ import { peopleBook } from "@/lib/people";
7
9
  import {
8
10
  badgeAddsToStatus,
9
11
  badgeText,
@@ -257,7 +259,7 @@ export function GovernanceMeta({
257
259
  <Chip text={trust.tier} />
258
260
  {trust.by === null ? null : (
259
261
  <span className="font-normal text-fd-muted-foreground">
260
- {trust.by}
262
+ {displayActor(trust.by, peopleBook())}
261
263
  {trust.at === null ? null : <> · {day(trust.at)}</>}
262
264
  </span>
263
265
  )}
@@ -286,7 +288,7 @@ export function GovernanceMeta({
286
288
  Links keep full strength: `Replaces` points at the document this one
287
289
  superseded, and that is an action rather than a fact. */}
288
290
  <dl className="mt-2.5 flex flex-wrap items-baseline gap-x-8 gap-y-2.5 empty:mt-0 [&_a]:text-fd-foreground [&_dd]:font-normal [&_dd]:text-fd-muted-foreground">
289
- {owner === null ? null : <Fact label="Owner">{owner}</Fact>}
291
+ {owner === null ? null : <Fact label="Owner">{displayActor(owner, peopleBook())}</Fact>}
290
292
  {/* Who let this into the record. `ksor.approval` is what makes a `stable`
291
293
  document stable at all (record spec §2.2), so a page that showed the
292
294
  word and not the signature would be publishing the claim without its
@@ -294,7 +296,7 @@ export function GovernanceMeta({
294
296
  {approval === null ? null : (
295
297
  <Fact label="Approved">
296
298
  <>
297
- {approval.by} · {day(approval.at)}
299
+ {displayActor(approval.by, peopleBook())} · {day(approval.at)}
298
300
  </>
299
301
  </Fact>
300
302
  )}
@@ -307,7 +309,7 @@ export function GovernanceMeta({
307
309
  {deprecated === null ? null : (
308
310
  <Fact label="Withdrawn">
309
311
  <>
310
- {deprecated.by} · {day(deprecated.at)}
312
+ {displayActor(deprecated.by, peopleBook())} · {day(deprecated.at)}
311
313
  </>
312
314
  </Fact>
313
315
  )}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Turn a stored actor identifier into the string the page prints.
3
+ *
4
+ * KSoR stores actors as `human:<handle>`, `process:<id>`, `team:<id>` or
5
+ * `<producer>/<version>`. Those forms are right for machines — the checker
6
+ * parses them, `.ksor/governance.yaml` lists actors by them, and the whole
7
+ * authority model depends on them — but a public-facing page shouldn't lead
8
+ * with a slug. This module bridges the two.
9
+ *
10
+ * ONE-WAY RULE. If `.ksor/people.yaml` maps this exact identifier to a name, we
11
+ * print `Human: Bashir Aziz`. If it does not, we print the identifier unchanged
12
+ * (`human:bashiraziz`, `ksor-starter/0.0.47`). No convention-based splitting,
13
+ * no camelCase / kebab-case guessing, and no derivation of a handle from a
14
+ * name: an owner is the only source of a display name.
15
+ *
16
+ * A producer actor (`<producer>/<version>`) has no `<kind>:` prefix and no
17
+ * natural name to look up, so it passes through unchanged — which is correct:
18
+ * a tool that approved a document should be named plainly, not humanised.
19
+ *
20
+ * NO IMPORTS: a leaf, like `lifecycle-rule.ts`. The phone book is handed in by
21
+ * the caller rather than reached for, so the rule can be exercised without a
22
+ * record on disk — and so nothing pulls a filesystem read in behind it.
23
+ */
24
+
25
+ /** The prefixes KSoR's actor grammar defines, in the case the site prints. */
26
+ const KIND_LABELS: Record<string, string> = {
27
+ human: "Human",
28
+ process: "Process",
29
+ team: "Team",
30
+ };
31
+
32
+ /**
33
+ * The display form of an actor. An actor that does not appear in
34
+ * `.ksor/people.yaml` renders exactly as stored, so nothing regresses on a
35
+ * record that has declared no names — which is every record until an owner
36
+ * says otherwise.
37
+ */
38
+ export function displayActor(actor: string, people: ReadonlyMap<string, string>): string {
39
+ const colonAt = actor.indexOf(":");
40
+ if (colonAt === -1) {
41
+ // No `<kind>:` prefix — this is a producer like `ksor-starter/0.0.47`.
42
+ // Print it unchanged: humanising a tool's identifier would misread.
43
+ return actor;
44
+ }
45
+ // Looked up by the WHOLE identifier, not the bare handle: `human:ops` and
46
+ // `team:ops` are different actors, and a phone book keyed on `ops` would
47
+ // print one of them under the other's name.
48
+ const name = people.get(actor) ?? null;
49
+ if (name === null) return actor;
50
+ const kind = actor.slice(0, colonAt);
51
+ const kindLabel = KIND_LABELS[kind] ?? kind.charAt(0).toUpperCase() + kind.slice(1);
52
+ return `${kindLabel}: ${name}`;
53
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Natural names for the actors this record cites — the site's phone book.
3
+ *
4
+ * A MAP keyed by the actor as stored (`human:bashiraziz`, `team:legal-ops`),
5
+ * not a list of names a handle is derived from. The derivation was the defect:
6
+ * `name.replace(/\s+/g, "").toLowerCase()` can only ever match a handle that
7
+ * IS somebody's squashed full name, so `human:ciso`, `human:audit-lead` and
8
+ * `human:mjs` — most of the actors in a real record — had no expressible name
9
+ * at all. It also collided: "Bashir Aziz" and "Bashira Ziz" both derive
10
+ * `bashiraziz`, which would print one person's name on the other's governance
11
+ * act. A map has neither problem, and duplicate keys are refused by the parser
12
+ * rather than resolved by whichever came last.
13
+ *
14
+ * ONE-WAY. The identifier is what the record stores, cites and checks against
15
+ * the policy; this is only what a page prints. Nothing reads a name back into
16
+ * an actor, and no authority follows from appearing here — which is why this is
17
+ * a file of its own and not a block in `.ksor/governance.yaml`: that file is
18
+ * the root of authority, its key set is closed on purpose, and its digest is
19
+ * hashed into `build.lock.json`, so correcting the spelling of someone's name
20
+ * there would refuse the next site build as `ksor-lock-stale`.
21
+ *
22
+ * Read from the project root rather than the process's cwd: `next build` runs
23
+ * in `system/site`, so a cwd-relative path found nothing and the feature was
24
+ * inert in exactly the builds that publish.
25
+ *
26
+ * Read AT USE and memoised, not at module load. A module-load `readFileSync`
27
+ * makes importing this module a filesystem act — it runs wherever the module is
28
+ * pulled in, including from a test that wants nothing but the display rule, and
29
+ * it fixes the answer before anything has had a chance to say where the record
30
+ * is. That is the same defect the env-tuning knobs had (#149/#194), one file
31
+ * over.
32
+ */
33
+
34
+ import { readFileSync } from "node:fs";
35
+ import path from "node:path";
36
+
37
+ import { parseAllDocuments } from "yaml";
38
+
39
+ import { projectRoot } from "./shared";
40
+
41
+ const PEOPLE_YAML = path.join(projectRoot, ".ksor", "people.yaml");
42
+
43
+ function loadPeople(): ReadonlyMap<string, string> {
44
+ let text: string;
45
+ try {
46
+ text = readFileSync(PEOPLE_YAML, "utf8");
47
+ } catch {
48
+ // Optional: its absence means "no natural names declared".
49
+ return new Map();
50
+ }
51
+ try {
52
+ const docs = parseAllDocuments(text.replace(/^/, ""), {
53
+ schema: "core",
54
+ uniqueKeys: true,
55
+ logLevel: "silent",
56
+ });
57
+ const value: unknown = docs[0]?.toJS();
58
+ if (typeof value !== "object" || value === null) return new Map();
59
+ const table = (value as { people?: unknown }).people;
60
+ if (typeof table !== "object" || table === null || Array.isArray(table)) return new Map();
61
+ const out = new Map<string, string>();
62
+ for (const [actor, name] of Object.entries(table as Record<string, unknown>)) {
63
+ // A blank value is an entry someone started and left; printing "" would
64
+ // erase the identifier rather than replace it.
65
+ if (typeof name === "string" && name.trim() !== "") out.set(actor.trim(), name.trim());
66
+ }
67
+ return out;
68
+ } catch {
69
+ return new Map();
70
+ }
71
+ }
72
+
73
+ let cached: ReadonlyMap<string, string> | null = null;
74
+
75
+ /**
76
+ * What this record has declared. Memoised per process: the file is authored,
77
+ * not runtime state, and a static build renders many pages from one read.
78
+ */
79
+ export function peopleBook(): ReadonlyMap<string, string> {
80
+ cached ??= loadPeople();
81
+ return cached;
82
+ }