@panaversity/ksor 0.0.17 → 0.0.18

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,47 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.18
4
+
5
+ ### Patch Changes
6
+
7
+ - ea049fd: A takedown can no longer stop applying without saying so
8
+
9
+ Two ways a recorded withdrawal quietly stopped covering what it was recorded to
10
+ cover. Both were found by attacking the door before exposing it publicly, and
11
+ both were reproduced end to end against a real database.
12
+
13
+ **A denial matched nothing after the document moved.** `takedown_denylist`
14
+ records a `stable_id`, and the serving predicate matches those rows against the
15
+ documents in the generation being served — so an id that no longer exists denies
16
+ nothing. The default stable_id is derived from the file's path, which means an
17
+ ordinary rename or move of a withdrawn document was enough: search, read,
18
+ outline and the site all served it again, with no error anywhere. Adding an
19
+ `index.md` beside a withdrawn section did the same, by changing the section's id.
20
+
21
+ Serving now refuses in that state, and so does the ingest that would create it —
22
+ the same check at both ends, so a generation where a withdrawal has stopped
23
+ applying cannot be published _or_ served:
24
+
25
+ ```
26
+ 2 takedown(s) match no document in generation 7: knowledge/legal/notice.md, …
27
+ why: … an id that no longer exists denies NOTHING — so a withdrawn document
28
+ that was renamed, moved, or had an index.md added beside it is served again
29
+ fix: point the denial at where the document lives now, or retire it
30
+ deliberately — never guess which one, because the tool cannot tell a rename
31
+ from a deletion
32
+ ```
33
+
34
+ Refusing rather than re-pointing automatically is the whole point: a tool that
35
+ guessed would eventually guess that a withdrawn document had been deleted when
36
+ it had been renamed.
37
+
38
+ **A withdrawn section did not cover its own directory.** When a section has no
39
+ `index.md` and its documents all live one level further down, it had no file to
40
+ name its own directory, so only the subdirectory was exported to the site. A
41
+ document written directly under the withdrawn section published to `/docs` and
42
+ `llms.txt` in the window before the next ingest. The section's directory is now
43
+ derived from its own identity, which for an index-less section is its path.
44
+
3
45
  ## 0.0.17
4
46
 
5
47
  ### 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-Deg5kd9y.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`);
@@ -4501,7 +4516,7 @@ async function withPgRetry(op, options = {}) {
4501
4516
  throw lastError;
4502
4517
  }
4503
4518
  //#endregion
4504
- //#region ../content/dist/commands-CXqK2c2f.mjs
4519
+ //#region ../content/dist/commands-DcPJJlNb.mjs
4505
4520
  /**
4506
4521
  * EVAL-LOCKED constants, quarried verbatim from the oracle
4507
4522
  * (sor-agentfactory @ b554f91, config.py) — changing any of these is a
@@ -6425,7 +6440,8 @@ async function assertGovernanceServable(pool, instance, targetGeneration) {
6425
6440
  if (generation === 0) return {
6426
6441
  generation,
6427
6442
  builtAt: null,
6428
- restricted: 0
6443
+ restricted: 0,
6444
+ orphaned: []
6429
6445
  };
6430
6446
  return {
6431
6447
  generation,
@@ -6434,13 +6450,27 @@ async function assertGovernanceServable(pool, instance, targetGeneration) {
6434
6450
  instance.corpusId,
6435
6451
  generation
6436
6452
  ])).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)
6453
+ 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),
6454
+ 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", [
6455
+ instance.tenantId,
6456
+ instance.corpusId,
6457
+ generation
6458
+ ])).rows.map((r) => r.stable_id)
6438
6459
  };
6439
6460
  });
6440
6461
  if (state.generation === 0) return;
6441
6462
  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
6463
  fix: rebuild the record so its governance reaches the database:
6443
6464
  ksor ingest --instance instance.md --knowledge knowledge --flip`);
6465
+ if (state.orphaned.length > 0) {
6466
+ const named = state.orphaned.slice(0, 5).join(", ");
6467
+ const more = state.orphaned.length - Math.min(5, state.orphaned.length);
6468
+ 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
6469
+ 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:
6470
+ ksor takedown --instance instance.md --stable-id <the new id> --reason <why> --actor <who>
6471
+ ksor takedown --instance instance.md --revoke <the old id> --actor <who>
6472
+ (ksor takedown --list shows what is recorded)`);
6473
+ }
6444
6474
  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
6475
  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
6476
  fix: declare the model in instance.md (audiences: least-restricted first, plus default_visibility:), or remove the visibility: keys and re-ingest`);
@@ -6858,7 +6888,10 @@ async function deniedStableIds(pool, instance) {
6858
6888
  *
6859
6889
  * The seed's OWN file counts when the seed has children, and only then — see
6860
6890
  * the SQL comment: a container's index.md names its directory, a leaf's file
6861
- * names its parent's.
6891
+ * names its parent's. An index-less container has no such file, so the one it
6892
+ * would have had is synthesized from its (path-derived) "#section" id — without
6893
+ * it, a section whose descendants all live one level down contributed only the
6894
+ * subdirectory and left its own level publishable (issue #86).
6862
6895
  */
6863
6896
  async function deniedSubtreeDirs(pool, instance) {
6864
6897
  const paths = await runRead(pool, instance.tenantId, async (client) => {
@@ -6866,7 +6899,7 @@ async function deniedSubtreeDirs(pool, instance) {
6866
6899
  SELECT active_generation AS g FROM corpora WHERE tenant_id = $1 AND corpus_id = $2
6867
6900
  ),
6868
6901
  seed AS (
6869
- SELECT n.node_id
6902
+ SELECT n.node_id, n.stable_id
6870
6903
  FROM takedown_denylist d
6871
6904
  JOIN content_nodes n ON n.tenant_id = d.tenant_id AND n.stable_id = d.stable_id
6872
6905
  JOIN gen ON n.generation = gen.g
@@ -6904,7 +6937,28 @@ async function deniedSubtreeDirs(pool, instance) {
6904
6937
  WHERE NOT EXISTS (SELECT 1 FROM content_nodes kid
6905
6938
  JOIN gen ON kid.generation = gen.g
6906
6939
  WHERE kid.tenant_id = $1 AND kid.parent_id = s2.node_id)
6907
- )`, [instance.tenantId, instance.corpusId])).rows.map((r) => String(r.origin_path));
6940
+ )
6941
+ UNION
6942
+ -- An INDEX-LESS container has no file at all, so the join above drops
6943
+ -- it however many descendants it has, and a section whose files all
6944
+ -- live one level down contributed only the SUBdirectory — leaving a
6945
+ -- document written directly under the withdrawn section publishable
6946
+ -- (issue #86). The round-10 "seed counts when it has children" rule was
6947
+ -- right and could not fire here, because there was nothing to count.
6948
+ --
6949
+ -- So the index.md it WOULD have had is synthesized, and the container
6950
+ -- then names its directory exactly as an index-bearing one does. This is
6951
+ -- not the stable_id prefix matching decision 14 rejects: that fails
6952
+ -- because a sor_id: override decouples an id from its path, and a
6953
+ -- "#section" id is generated from the path with no frontmatter in
6954
+ -- reach: there is no index file to carry an override
6955
+ -- (adapters/plain-tree.ts:214-219).
6956
+ SELECT substring(sd.stable_id from '^(.*)#section$') || '/index.md'
6957
+ FROM seed sd
6958
+ WHERE sd.stable_id LIKE '%#section'
6959
+ AND EXISTS (SELECT 1 FROM content_nodes kid
6960
+ JOIN gen ON kid.generation = gen.g
6961
+ WHERE kid.tenant_id = $1 AND kid.parent_id = sd.node_id)`, [instance.tenantId, instance.corpusId])).rows.map((r) => String(r.origin_path));
6908
6962
  });
6909
6963
  const dirs = /* @__PURE__ */ new Set();
6910
6964
  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.18",
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",