@panaversity/ksor 0.0.17 → 0.0.19

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,101 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.19
4
+
5
+ ### Patch Changes
6
+
7
+ - aa4bdce: Record why the vector index is unused, and what fixing it would cost
8
+
9
+ Diagnosis only — no serving behaviour changes. Answers are unaffected, and were
10
+ already correct: the query plans a sequential scan, and a sequential scan is
11
+ EXACT. What grows with the corpus is the work, not the error.
12
+
13
+ The cause recorded until now — a window function, then joins and predicates
14
+ Postgres cannot estimate — was incomplete. Testing each clause on its own shows
15
+ a cost mispricing underneath: a full sequential pass over 20,000 chunks,
16
+ including 20,000 1536-dimension distance computations, is priced at 1904 for
17
+ work that takes ~130 ms, while the HNSW scan's startup cost alone is 2137.
18
+
19
+ A restructured arm reaches 36 ms against 648 ms — but only with `ef_search` at
20
+ pgvector's default, which is the setting where the index missed the true nearest
21
+ neighbour for 1 query in 100 on a bed with real cluster structure, dropping the
22
+ top-1 similarity by 0.99. Against this record's ~0.01 abstention separation,
23
+ that flips an abstention: the corpus holds the answer and the door says it does
24
+ not. The speed and the approximation cannot be separated, so taking them is an
25
+ owner decision rather than a tuning change.
26
+
27
+ Both the current plan and the fix path are now pinned by tests, so neither can
28
+ drift unnoticed.
29
+
30
+ - b9f3d00: A citation pin no longer outlives a restriction
31
+
32
+ A snapshot token pins a generation so a citation keeps resolving to the same
33
+ bytes. It was also deciding the _audience_ question — evaluating `visibility` on
34
+ the pinned row — so a document restricted after the token was issued kept reading
35
+ in full for the token's life, to a caller the record had just closed it to.
36
+
37
+ Three routes refused it and one served it, on the same surface, in the same
38
+ second: `outline` omitted it, `search` filtered it, an unpinned `read` refused it,
39
+ and `read` with a pre-flip token returned the whole document.
40
+
41
+ The generation pointers are why the obvious guard missed it. A flip sets
42
+ `rollback_generation` to the generation just superseded, so a pre-flip pin is
43
+ exactly the rollback pointer — servable by design, and the check that narrows a
44
+ pin to {active, rollback} passed it.
45
+
46
+ **Governance is now read from the record as it stands.** A pin still decides
47
+ which generation's content is served; it no longer decides whether the caller may
48
+ have it. A document the record no longer contains cannot be resurrected by one
49
+ either. Unpinned reads are unaffected — with nothing pinned, the two generations
50
+ are the same one and the check is an identity.
51
+
52
+ The cost is deliberate: a citation can stop resolving within the token's 30
53
+ minutes when the record restricts what it points at. That is what "the record
54
+ changed" should look like. The alternative is a window in which a withdrawal is
55
+ not a withdrawal.
56
+
57
+ ## 0.0.18
58
+
59
+ ### Patch Changes
60
+
61
+ - ea049fd: A takedown can no longer stop applying without saying so
62
+
63
+ Two ways a recorded withdrawal quietly stopped covering what it was recorded to
64
+ cover. Both were found by attacking the door before exposing it publicly, and
65
+ both were reproduced end to end against a real database.
66
+
67
+ **A denial matched nothing after the document moved.** `takedown_denylist`
68
+ records a `stable_id`, and the serving predicate matches those rows against the
69
+ documents in the generation being served — so an id that no longer exists denies
70
+ nothing. The default stable_id is derived from the file's path, which means an
71
+ ordinary rename or move of a withdrawn document was enough: search, read,
72
+ outline and the site all served it again, with no error anywhere. Adding an
73
+ `index.md` beside a withdrawn section did the same, by changing the section's id.
74
+
75
+ Serving now refuses in that state, and so does the ingest that would create it —
76
+ the same check at both ends, so a generation where a withdrawal has stopped
77
+ applying cannot be published _or_ served:
78
+
79
+ ```
80
+ 2 takedown(s) match no document in generation 7: knowledge/legal/notice.md, …
81
+ why: … an id that no longer exists denies NOTHING — so a withdrawn document
82
+ that was renamed, moved, or had an index.md added beside it is served again
83
+ fix: point the denial at where the document lives now, or retire it
84
+ deliberately — never guess which one, because the tool cannot tell a rename
85
+ from a deletion
86
+ ```
87
+
88
+ Refusing rather than re-pointing automatically is the whole point: a tool that
89
+ guessed would eventually guess that a withdrawn document had been deleted when
90
+ it had been renamed.
91
+
92
+ **A withdrawn section did not cover its own directory.** When a section has no
93
+ `index.md` and its documents all live one level further down, it had no file to
94
+ name its own directory, so only the subdirectory was exported to the site. A
95
+ document written directly under the withdrawn section published to `/docs` and
96
+ `llms.txt` in the window before the next ingest. The section's directory is now
97
+ derived from its own identity, which for an index-less section is its path.
98
+
3
99
  ## 0.0.17
4
100
 
5
101
  ### Patch Changes
package/dist/cli.mjs CHANGED
@@ -15,7 +15,7 @@ import { bodyLimit } from "hono/body-limit";
15
15
  import { execFileSync, spawnSync } from "node:child_process";
16
16
  import { parseArgs } from "node:util";
17
17
  import { readFile, readdir, stat } from "node:fs/promises";
18
- //#region ../content-gateway/dist/main-BtKmcm72.mjs
18
+ //#region ../content-gateway/dist/main-DDeyGVjK.mjs
19
19
  /**
20
20
  * A connection could not be ESTABLISHED in time — retryable.
21
21
  *
@@ -1830,7 +1830,8 @@ async function assertGovernanceServable$1(pool, instance, targetGeneration) {
1830
1830
  if (generation === 0) return {
1831
1831
  generation,
1832
1832
  builtAt: null,
1833
- restricted: 0
1833
+ restricted: 0,
1834
+ orphaned: []
1834
1835
  };
1835
1836
  return {
1836
1837
  generation,
@@ -1839,13 +1840,27 @@ async function assertGovernanceServable$1(pool, instance, targetGeneration) {
1839
1840
  instance.corpusId,
1840
1841
  generation
1841
1842
  ])).rows[0]?.schema_version ?? null,
1842
- restricted: declaresModel ? 0 : Number((await client.query("SELECT count(*)::int AS n FROM content_nodes WHERE tenant_id = $1 AND generation = $2 AND visibility IS NOT NULL", [instance.tenantId, generation])).rows[0].n)
1843
+ restricted: declaresModel ? 0 : Number((await client.query("SELECT count(*)::int AS n FROM content_nodes WHERE tenant_id = $1 AND generation = $2 AND visibility IS NOT NULL", [instance.tenantId, generation])).rows[0].n),
1844
+ orphaned: (await client.query("SELECT d.stable_id FROM takedown_denylist d WHERE d.tenant_id = $1 AND d.corpus_id = $2 AND NOT EXISTS (SELECT 1 FROM content_nodes n WHERE n.tenant_id = d.tenant_id AND n.corpus_id = d.corpus_id AND n.generation = $3 AND n.stable_id = d.stable_id) ORDER BY d.stable_id", [
1845
+ instance.tenantId,
1846
+ instance.corpusId,
1847
+ generation
1848
+ ])).rows.map((r) => r.stable_id)
1843
1849
  };
1844
1850
  });
1845
1851
  if (state.generation === 0) return;
1846
1852
  if (declaresModel && (state.builtAt === null || compareSchemaVersion$1(state.builtAt, "2.2") < 0)) throw new GovernanceGateError$1(`generation ${state.generation} was built against schema ${state.builtAt ?? "(before 2.4, which is when a generation started recording this)"}, older than 2.2 — the version that put visibility on the node row\n why: instance.md declares an audience model, but the documents in this generation carry no visibility at all. Every one of them would be served at default_visibility — the WIDEST tier — including any document whose frontmatter restricts it
1847
1853
  fix: rebuild the record so its governance reaches the database:
1848
1854
  ksor ingest --instance instance.md --knowledge knowledge --flip`);
1855
+ if (state.orphaned.length > 0) {
1856
+ const named = state.orphaned.slice(0, 5).join(", ");
1857
+ const more = state.orphaned.length - Math.min(5, state.orphaned.length);
1858
+ throw new GovernanceGateError$1(`${state.orphaned.length} takedown(s) match no document in generation ${state.generation}: ${named}${more > 0 ? `, and ${more} more` : ""}\n why: a denial is recorded against a stable_id, and the serving predicate matches it against the documents in this generation. An id that no longer exists denies NOTHING — so a withdrawn document that was renamed, moved, or had an index.md added beside it is served again by search, read, outline and the site, with no error anywhere. The denial is meant to be immune to reorganization; this is the state where it is not
1859
+ fix: point the denial at where the document lives now, or retire it deliberately — never guess which one, because the tool cannot tell a rename from a deletion:
1860
+ ksor takedown --instance instance.md --stable-id <the new id> --reason <why> --actor <who>
1861
+ ksor takedown --instance instance.md --revoke <the old id> --actor <who>
1862
+ (ksor takedown --list shows what is recorded)`);
1863
+ }
1849
1864
  if (!declaresModel && state.restricted > 0) throw new GovernanceGateError$1(`${state.restricted} document(s) in generation ${state.generation} declare visibility:, but instance.md declares no audiences:
1850
1865
  why: an author restricted those documents and nothing would enforce it — this door would serve them in full to every caller, and the frontmatter key saying otherwise would be the only trace. The site refuses to BUILD in this exact state (ksor-visibility-without-audiences); the door must not serve in it
1851
1866
  fix: declare the model in instance.md (audiences: least-restricted first, plus default_visibility:), or remove the visibility: keys and re-ingest`);
@@ -2038,6 +2053,28 @@ g AS (
2038
2053
  (SELECT active_generation FROM corpora
2039
2054
  WHERE tenant_id = $1 AND corpus_id = $2)
2040
2055
  ) AS gen
2056
+ ),
2057
+ -- The generation the record is on RIGHT NOW, which decides governance even when
2058
+ -- content is served from a pinned one.
2059
+ --
2060
+ -- A snapshot pin exists so a citation keeps resolving to the same bytes. It used
2061
+ -- to decide the audience question too, by evaluating visibility on the pinned
2062
+ -- row — so a document restricted after the token was issued kept reading in full
2063
+ -- for the token's life, while outline, search and an unpinned read all
2064
+ -- refused it in the same second. servableGenerations could not catch it: a
2065
+ -- flip sets rollback_generation to the generation just superseded, so a pre-flip
2066
+ -- pin IS the rollback pointer and is servable by design (issue #87).
2067
+ --
2068
+ -- Pins yield. A citation may stop resolving within the token's life, which is
2069
+ -- what "the record changed" should look like — the alternative is a window in
2070
+ -- which a withdrawal is not a withdrawal, and decision 19 says a surface that
2071
+ -- refuses must refuse everywhere, which includes its own fourth route.
2072
+ --
2073
+ -- When nothing is pinned this is the SAME generation as g, so the join is an
2074
+ -- identity and no unpinned read changes behaviour.
2075
+ live AS (
2076
+ SELECT active_generation AS gen FROM corpora
2077
+ WHERE tenant_id = $1 AND corpus_id = $2
2041
2078
  )`;
2042
2079
  /** Candidates by LEAF slug ($4), each with its full root path (for suffix disambiguation). */
2043
2080
  const NODE_BY_SLUG_SQL = `
@@ -2067,7 +2104,12 @@ SELECT n.node_id, n.slug, n.title, n.stable_id, n.path, n.generation, n.permalin
2067
2104
  FROM tree n
2068
2105
  JOIN content_nodes self ON self.node_id = n.node_id AND self.tenant_id = $1
2069
2106
  AND self.generation = n.generation
2070
- WHERE n.slug = $4 AND ${DENY$1} AND ${audienceAllowed$1("self")}
2107
+ -- INNER join, so a document the record no longer contains cannot be
2108
+ -- resurrected by a pin either: no live row, no read.
2109
+ JOIN live ON TRUE
2110
+ JOIN content_nodes now ON now.tenant_id = $1 AND now.generation = live.gen
2111
+ AND now.stable_id = self.stable_id
2112
+ WHERE n.slug = $4 AND ${DENY$1} AND ${audienceAllowed$1("now")}
2071
2113
  ORDER BY n.path`;
2072
2114
  const ALIAS_SQL = `
2073
2115
  WITH ${GEN}
@@ -2084,8 +2126,11 @@ const NODE_BY_STABLE_ID_SQL = `
2084
2126
  WITH RECURSIVE ${GEN}, ${DENIED_CTE$1}
2085
2127
  SELECT n.node_id, n.slug, n.title, n.stable_id, n.stable_id::text AS path, n.generation, n.permalink
2086
2128
  FROM content_nodes n JOIN g ON n.generation = g.gen
2129
+ JOIN live ON TRUE
2130
+ JOIN content_nodes now ON now.tenant_id = $1 AND now.generation = live.gen
2131
+ AND now.stable_id = n.stable_id
2087
2132
  WHERE n.tenant_id = $1 AND n.stable_id = $4 AND n.status = 'published' AND ${DENY$1}
2088
- AND ${AUDIENCE_ALLOWED$1}`;
2133
+ AND ${audienceAllowed$1("now")}`;
2089
2134
  const DOCUMENT_CHUNKS_SQL = `
2090
2135
  WITH ${GEN}
2091
2136
  SELECT c.ordinal, COALESCE(c.heading_path_text, ''), c.content
@@ -4501,7 +4546,7 @@ async function withPgRetry(op, options = {}) {
4501
4546
  throw lastError;
4502
4547
  }
4503
4548
  //#endregion
4504
- //#region ../content/dist/commands-CXqK2c2f.mjs
4549
+ //#region ../content/dist/commands-DcPJJlNb.mjs
4505
4550
  /**
4506
4551
  * EVAL-LOCKED constants, quarried verbatim from the oracle
4507
4552
  * (sor-agentfactory @ b554f91, config.py) — changing any of these is a
@@ -6425,7 +6470,8 @@ async function assertGovernanceServable(pool, instance, targetGeneration) {
6425
6470
  if (generation === 0) return {
6426
6471
  generation,
6427
6472
  builtAt: null,
6428
- restricted: 0
6473
+ restricted: 0,
6474
+ orphaned: []
6429
6475
  };
6430
6476
  return {
6431
6477
  generation,
@@ -6434,13 +6480,27 @@ async function assertGovernanceServable(pool, instance, targetGeneration) {
6434
6480
  instance.corpusId,
6435
6481
  generation
6436
6482
  ])).rows[0]?.schema_version ?? null,
6437
- restricted: declaresModel ? 0 : Number((await client.query("SELECT count(*)::int AS n FROM content_nodes WHERE tenant_id = $1 AND generation = $2 AND visibility IS NOT NULL", [instance.tenantId, generation])).rows[0].n)
6483
+ restricted: declaresModel ? 0 : Number((await client.query("SELECT count(*)::int AS n FROM content_nodes WHERE tenant_id = $1 AND generation = $2 AND visibility IS NOT NULL", [instance.tenantId, generation])).rows[0].n),
6484
+ orphaned: (await client.query("SELECT d.stable_id FROM takedown_denylist d WHERE d.tenant_id = $1 AND d.corpus_id = $2 AND NOT EXISTS (SELECT 1 FROM content_nodes n WHERE n.tenant_id = d.tenant_id AND n.corpus_id = d.corpus_id AND n.generation = $3 AND n.stable_id = d.stable_id) ORDER BY d.stable_id", [
6485
+ instance.tenantId,
6486
+ instance.corpusId,
6487
+ generation
6488
+ ])).rows.map((r) => r.stable_id)
6438
6489
  };
6439
6490
  });
6440
6491
  if (state.generation === 0) return;
6441
6492
  if (declaresModel && (state.builtAt === null || compareSchemaVersion(state.builtAt, "2.2") < 0)) throw new GovernanceGateError(`generation ${state.generation} was built against schema ${state.builtAt ?? "(before 2.4, which is when a generation started recording this)"}, older than 2.2 — the version that put visibility on the node row\n why: instance.md declares an audience model, but the documents in this generation carry no visibility at all. Every one of them would be served at default_visibility — the WIDEST tier — including any document whose frontmatter restricts it
6442
6493
  fix: rebuild the record so its governance reaches the database:
6443
6494
  ksor ingest --instance instance.md --knowledge knowledge --flip`);
6495
+ if (state.orphaned.length > 0) {
6496
+ const named = state.orphaned.slice(0, 5).join(", ");
6497
+ const more = state.orphaned.length - Math.min(5, state.orphaned.length);
6498
+ throw new GovernanceGateError(`${state.orphaned.length} takedown(s) match no document in generation ${state.generation}: ${named}${more > 0 ? `, and ${more} more` : ""}\n why: a denial is recorded against a stable_id, and the serving predicate matches it against the documents in this generation. An id that no longer exists denies NOTHING — so a withdrawn document that was renamed, moved, or had an index.md added beside it is served again by search, read, outline and the site, with no error anywhere. The denial is meant to be immune to reorganization; this is the state where it is not
6499
+ fix: point the denial at where the document lives now, or retire it deliberately — never guess which one, because the tool cannot tell a rename from a deletion:
6500
+ ksor takedown --instance instance.md --stable-id <the new id> --reason <why> --actor <who>
6501
+ ksor takedown --instance instance.md --revoke <the old id> --actor <who>
6502
+ (ksor takedown --list shows what is recorded)`);
6503
+ }
6444
6504
  if (!declaresModel && state.restricted > 0) throw new GovernanceGateError(`${state.restricted} document(s) in generation ${state.generation} declare visibility:, but instance.md declares no audiences:
6445
6505
  why: an author restricted those documents and nothing would enforce it — this door would serve them in full to every caller, and the frontmatter key saying otherwise would be the only trace. The site refuses to BUILD in this exact state (ksor-visibility-without-audiences); the door must not serve in it
6446
6506
  fix: declare the model in instance.md (audiences: least-restricted first, plus default_visibility:), or remove the visibility: keys and re-ingest`);
@@ -6858,7 +6918,10 @@ async function deniedStableIds(pool, instance) {
6858
6918
  *
6859
6919
  * The seed's OWN file counts when the seed has children, and only then — see
6860
6920
  * the SQL comment: a container's index.md names its directory, a leaf's file
6861
- * names its parent's.
6921
+ * names its parent's. An index-less container has no such file, so the one it
6922
+ * would have had is synthesized from its (path-derived) "#section" id — without
6923
+ * it, a section whose descendants all live one level down contributed only the
6924
+ * subdirectory and left its own level publishable (issue #86).
6862
6925
  */
6863
6926
  async function deniedSubtreeDirs(pool, instance) {
6864
6927
  const paths = await runRead(pool, instance.tenantId, async (client) => {
@@ -6866,7 +6929,7 @@ async function deniedSubtreeDirs(pool, instance) {
6866
6929
  SELECT active_generation AS g FROM corpora WHERE tenant_id = $1 AND corpus_id = $2
6867
6930
  ),
6868
6931
  seed AS (
6869
- SELECT n.node_id
6932
+ SELECT n.node_id, n.stable_id
6870
6933
  FROM takedown_denylist d
6871
6934
  JOIN content_nodes n ON n.tenant_id = d.tenant_id AND n.stable_id = d.stable_id
6872
6935
  JOIN gen ON n.generation = gen.g
@@ -6904,7 +6967,28 @@ async function deniedSubtreeDirs(pool, instance) {
6904
6967
  WHERE NOT EXISTS (SELECT 1 FROM content_nodes kid
6905
6968
  JOIN gen ON kid.generation = gen.g
6906
6969
  WHERE kid.tenant_id = $1 AND kid.parent_id = s2.node_id)
6907
- )`, [instance.tenantId, instance.corpusId])).rows.map((r) => String(r.origin_path));
6970
+ )
6971
+ UNION
6972
+ -- An INDEX-LESS container has no file at all, so the join above drops
6973
+ -- it however many descendants it has, and a section whose files all
6974
+ -- live one level down contributed only the SUBdirectory — leaving a
6975
+ -- document written directly under the withdrawn section publishable
6976
+ -- (issue #86). The round-10 "seed counts when it has children" rule was
6977
+ -- right and could not fire here, because there was nothing to count.
6978
+ --
6979
+ -- So the index.md it WOULD have had is synthesized, and the container
6980
+ -- then names its directory exactly as an index-bearing one does. This is
6981
+ -- not the stable_id prefix matching decision 14 rejects: that fails
6982
+ -- because a sor_id: override decouples an id from its path, and a
6983
+ -- "#section" id is generated from the path with no frontmatter in
6984
+ -- reach: there is no index file to carry an override
6985
+ -- (adapters/plain-tree.ts:214-219).
6986
+ SELECT substring(sd.stable_id from '^(.*)#section$') || '/index.md'
6987
+ FROM seed sd
6988
+ WHERE sd.stable_id LIKE '%#section'
6989
+ AND EXISTS (SELECT 1 FROM content_nodes kid
6990
+ JOIN gen ON kid.generation = gen.g
6991
+ WHERE kid.tenant_id = $1 AND kid.parent_id = sd.node_id)`, [instance.tenantId, instance.corpusId])).rows.map((r) => String(r.origin_path));
6908
6992
  });
6909
6993
  const dirs = /* @__PURE__ */ new Set();
6910
6994
  for (const raw of paths) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
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",