@panaversity/ksor 0.0.16 → 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 +72 -0
- package/dist/cli.mjs +184 -17
- package/package.json +1 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
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
|
+
|
|
45
|
+
## 0.0.17
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- 44feada: Installing ksor no longer pulls 32 MB of vendor SDK
|
|
50
|
+
|
|
51
|
+
`npx @panaversity/ksor init` installed 54 MB across 52 packages. 32 MB of that
|
|
52
|
+
was `@google/genai` and its dependencies — carried by every adopter, including
|
|
53
|
+
the ones who only ever run `init` and `dev` and never reach a served rung.
|
|
54
|
+
|
|
55
|
+
It existed to make two HTTP calls, both already wrapped behind one
|
|
56
|
+
structurally-typed client boundary. Those calls are now spoken directly:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
before 54 MB 52 packages
|
|
60
|
+
after 22 MB 22 packages
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Nothing about the embedding changed, and that was checked first rather than
|
|
64
|
+
assumed: the SDK and the REST endpoint return **byte-identical vectors** for the
|
|
65
|
+
same text, model, dimensionality and task type — a maximum per-component
|
|
66
|
+
difference of 0.000e+0 at 1536 dimensions. So stored embeddings stay valid and a
|
|
67
|
+
calibrated `vector_floor` keeps its meaning. Had they differed by a rounding
|
|
68
|
+
step, this would have quietly invalidated abstention on every existing record.
|
|
69
|
+
|
|
70
|
+
The provider seam is unchanged: a deployment that prefers an SDK can still
|
|
71
|
+
supply one through `clientFactory`. The single live call to the real vendor
|
|
72
|
+
stays where it was, as the tripwire for API drift, and now meets Gemini with
|
|
73
|
+
nothing in between.
|
|
74
|
+
|
|
3
75
|
## 0.0.16
|
|
4
76
|
|
|
5
77
|
### Patch Changes
|
package/dist/cli.mjs
CHANGED
|
@@ -7,7 +7,6 @@ import { z } from "zod";
|
|
|
7
7
|
import pg from "pg";
|
|
8
8
|
import path, { basename, dirname, join, resolve, sep } from "node:path";
|
|
9
9
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
10
|
-
import { GoogleGenAI } from "@google/genai";
|
|
11
10
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
11
|
import { createRemoteJWKSet, errors, jwtVerify } from "jose";
|
|
13
12
|
import { serve } from "@hono/node-server";
|
|
@@ -16,7 +15,7 @@ import { bodyLimit } from "hono/body-limit";
|
|
|
16
15
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
17
16
|
import { parseArgs } from "node:util";
|
|
18
17
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
19
|
-
//#region ../content-gateway/dist/main-
|
|
18
|
+
//#region ../content-gateway/dist/main-Deg5kd9y.mjs
|
|
20
19
|
/**
|
|
21
20
|
* A connection could not be ESTABLISHED in time — retryable.
|
|
22
21
|
*
|
|
@@ -1526,8 +1525,58 @@ var FakeEmbeddingProvider$1 = class {
|
|
|
1526
1525
|
}
|
|
1527
1526
|
reset() {}
|
|
1528
1527
|
};
|
|
1528
|
+
const DEFAULT_BASE$1 = "https://generativelanguage.googleapis.com/v1beta";
|
|
1529
1529
|
/**
|
|
1530
|
-
*
|
|
1530
|
+
* An HTTP-shaped failure carrying the status the retry classifier reads.
|
|
1531
|
+
*
|
|
1532
|
+
* `isRetryable` in `gemini.ts` asks for a numeric `status` and nothing else, by
|
|
1533
|
+
* design — it was written to survive SDK refactors. This keeps that contract
|
|
1534
|
+
* when the SDK is gone.
|
|
1535
|
+
*/
|
|
1536
|
+
var GeminiHttpError$1 = class extends Error {
|
|
1537
|
+
status;
|
|
1538
|
+
constructor(status, detail) {
|
|
1539
|
+
super(`Gemini API error ${status}: ${detail}`);
|
|
1540
|
+
this.name = "GeminiHttpError";
|
|
1541
|
+
this.status = status;
|
|
1542
|
+
}
|
|
1543
|
+
};
|
|
1544
|
+
/** One POST, with the key in a HEADER — never the query string, which is logged. */
|
|
1545
|
+
async function post$1(opts, apiKey, path, body, timeoutMs) {
|
|
1546
|
+
const res = await (opts.fetchImpl ?? fetch)(`${opts.baseUrl ?? DEFAULT_BASE$1}${path}`, {
|
|
1547
|
+
method: "POST",
|
|
1548
|
+
headers: {
|
|
1549
|
+
"x-goog-api-key": apiKey,
|
|
1550
|
+
"content-type": "application/json"
|
|
1551
|
+
},
|
|
1552
|
+
body: JSON.stringify(body),
|
|
1553
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
1554
|
+
});
|
|
1555
|
+
const text = await res.text();
|
|
1556
|
+
if (!res.ok) {
|
|
1557
|
+
let detail = text.slice(0, 300);
|
|
1558
|
+
try {
|
|
1559
|
+
const message = JSON.parse(text).error?.message;
|
|
1560
|
+
if (typeof message === "string") detail = message;
|
|
1561
|
+
} catch {}
|
|
1562
|
+
throw new GeminiHttpError$1(res.status, detail);
|
|
1563
|
+
}
|
|
1564
|
+
return JSON.parse(text);
|
|
1565
|
+
}
|
|
1566
|
+
/** The embedding half of the slice, spoken over `batchEmbedContents`. */
|
|
1567
|
+
function geminiRestEmbedClient$1(apiKey, opts = {}) {
|
|
1568
|
+
return { models: { async embedContent(params) {
|
|
1569
|
+
const payload = { requests: params.contents.map((text) => ({
|
|
1570
|
+
model: `models/${params.model}`,
|
|
1571
|
+
content: { parts: [{ text }] },
|
|
1572
|
+
taskType: params.config.taskType,
|
|
1573
|
+
outputDimensionality: params.config.outputDimensionality
|
|
1574
|
+
})) };
|
|
1575
|
+
return { embeddings: (await post$1(opts, apiKey, `/models/${params.model}:batchEmbedContents`, payload, params.config.httpOptions.timeout)).embeddings ?? [] };
|
|
1576
|
+
} } };
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* The Gemini transport — the ONE place the vendor is spoken to (converted
|
|
1531
1580
|
* from the oracle's sor_content/lib/providers/gemini.py; decision 6).
|
|
1532
1581
|
* Identity (model, dim, task labels) is CONSTRUCTOR-INJECTED — this module
|
|
1533
1582
|
* never imports config, so the same adapter serves any Gemini embedding
|
|
@@ -1547,7 +1596,7 @@ var FakeEmbeddingProvider$1 = class {
|
|
|
1547
1596
|
* clock. (The oracle's one divergence — a sync query-intent embed keeping
|
|
1548
1597
|
* the batch clock, an eval-harness case — has no TS call site.)
|
|
1549
1598
|
* - The oracle's "has been closed" stale-client RuntimeError predicate is a
|
|
1550
|
-
* Python-SDK failure mode with no
|
|
1599
|
+
* Python-SDK failure mode with no JS equivalent; `reset()`
|
|
1551
1600
|
* keeps its drop-never-close contract regardless.
|
|
1552
1601
|
*/
|
|
1553
1602
|
function httpStatusOf$1(exc) {
|
|
@@ -1601,7 +1650,7 @@ var GeminiEmbeddingProvider$1 = class {
|
|
|
1601
1650
|
this.queryTaskLabel = opts.queryTaskLabel;
|
|
1602
1651
|
this.documentTimeoutMs = Math.trunc(opts.documentTimeoutS * 1e3);
|
|
1603
1652
|
this.queryTimeoutMs = Math.trunc(opts.queryTimeoutS * 1e3);
|
|
1604
|
-
this.clientFactory = opts.clientFactory ?? (() =>
|
|
1653
|
+
this.clientFactory = opts.clientFactory ?? (() => geminiRestEmbedClient$1(opts.apiKey));
|
|
1605
1654
|
}
|
|
1606
1655
|
get recipe() {
|
|
1607
1656
|
return `${this.modelId}/d${this.dim}/${this.documentTaskLabel}`;
|
|
@@ -1781,7 +1830,8 @@ async function assertGovernanceServable$1(pool, instance, targetGeneration) {
|
|
|
1781
1830
|
if (generation === 0) return {
|
|
1782
1831
|
generation,
|
|
1783
1832
|
builtAt: null,
|
|
1784
|
-
restricted: 0
|
|
1833
|
+
restricted: 0,
|
|
1834
|
+
orphaned: []
|
|
1785
1835
|
};
|
|
1786
1836
|
return {
|
|
1787
1837
|
generation,
|
|
@@ -1790,13 +1840,27 @@ async function assertGovernanceServable$1(pool, instance, targetGeneration) {
|
|
|
1790
1840
|
instance.corpusId,
|
|
1791
1841
|
generation
|
|
1792
1842
|
])).rows[0]?.schema_version ?? null,
|
|
1793
|
-
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)
|
|
1794
1849
|
};
|
|
1795
1850
|
});
|
|
1796
1851
|
if (state.generation === 0) return;
|
|
1797
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
|
|
1798
1853
|
fix: rebuild the record so its governance reaches the database:
|
|
1799
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
|
+
}
|
|
1800
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:
|
|
1801
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
|
|
1802
1866
|
fix: declare the model in instance.md (audiences: least-restricted first, plus default_visibility:), or remove the visibility: keys and re-ingest`);
|
|
@@ -4452,7 +4516,7 @@ async function withPgRetry(op, options = {}) {
|
|
|
4452
4516
|
throw lastError;
|
|
4453
4517
|
}
|
|
4454
4518
|
//#endregion
|
|
4455
|
-
//#region ../content/dist/commands-
|
|
4519
|
+
//#region ../content/dist/commands-DcPJJlNb.mjs
|
|
4456
4520
|
/**
|
|
4457
4521
|
* EVAL-LOCKED constants, quarried verbatim from the oracle
|
|
4458
4522
|
* (sor-agentfactory @ b554f91, config.py) — changing any of these is a
|
|
@@ -5423,8 +5487,72 @@ var FakeEmbeddingProvider = class {
|
|
|
5423
5487
|
}
|
|
5424
5488
|
reset() {}
|
|
5425
5489
|
};
|
|
5490
|
+
const DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
5426
5491
|
/**
|
|
5427
|
-
*
|
|
5492
|
+
* An HTTP-shaped failure carrying the status the retry classifier reads.
|
|
5493
|
+
*
|
|
5494
|
+
* `isRetryable` in `gemini.ts` asks for a numeric `status` and nothing else, by
|
|
5495
|
+
* design — it was written to survive SDK refactors. This keeps that contract
|
|
5496
|
+
* when the SDK is gone.
|
|
5497
|
+
*/
|
|
5498
|
+
var GeminiHttpError = class extends Error {
|
|
5499
|
+
status;
|
|
5500
|
+
constructor(status, detail) {
|
|
5501
|
+
super(`Gemini API error ${status}: ${detail}`);
|
|
5502
|
+
this.name = "GeminiHttpError";
|
|
5503
|
+
this.status = status;
|
|
5504
|
+
}
|
|
5505
|
+
};
|
|
5506
|
+
/** One POST, with the key in a HEADER — never the query string, which is logged. */
|
|
5507
|
+
async function post(opts, apiKey, path, body, timeoutMs) {
|
|
5508
|
+
const res = await (opts.fetchImpl ?? fetch)(`${opts.baseUrl ?? DEFAULT_BASE}${path}`, {
|
|
5509
|
+
method: "POST",
|
|
5510
|
+
headers: {
|
|
5511
|
+
"x-goog-api-key": apiKey,
|
|
5512
|
+
"content-type": "application/json"
|
|
5513
|
+
},
|
|
5514
|
+
body: JSON.stringify(body),
|
|
5515
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
5516
|
+
});
|
|
5517
|
+
const text = await res.text();
|
|
5518
|
+
if (!res.ok) {
|
|
5519
|
+
let detail = text.slice(0, 300);
|
|
5520
|
+
try {
|
|
5521
|
+
const message = JSON.parse(text).error?.message;
|
|
5522
|
+
if (typeof message === "string") detail = message;
|
|
5523
|
+
} catch {}
|
|
5524
|
+
throw new GeminiHttpError(res.status, detail);
|
|
5525
|
+
}
|
|
5526
|
+
return JSON.parse(text);
|
|
5527
|
+
}
|
|
5528
|
+
/** The embedding half of the slice, spoken over `batchEmbedContents`. */
|
|
5529
|
+
function geminiRestEmbedClient(apiKey, opts = {}) {
|
|
5530
|
+
return { models: { async embedContent(params) {
|
|
5531
|
+
const payload = { requests: params.contents.map((text) => ({
|
|
5532
|
+
model: `models/${params.model}`,
|
|
5533
|
+
content: { parts: [{ text }] },
|
|
5534
|
+
taskType: params.config.taskType,
|
|
5535
|
+
outputDimensionality: params.config.outputDimensionality
|
|
5536
|
+
})) };
|
|
5537
|
+
return { embeddings: (await post(opts, apiKey, `/models/${params.model}:batchEmbedContents`, payload, params.config.httpOptions.timeout)).embeddings ?? [] };
|
|
5538
|
+
} } };
|
|
5539
|
+
}
|
|
5540
|
+
/** The text half of the slice, spoken over `generateContent`. */
|
|
5541
|
+
function geminiRestTextClient(apiKey, opts = {}) {
|
|
5542
|
+
return { models: { async generateContent(params) {
|
|
5543
|
+
const payload = {
|
|
5544
|
+
contents: [{ parts: [{ text: params.contents }] }],
|
|
5545
|
+
generationConfig: {
|
|
5546
|
+
temperature: params.config.temperature,
|
|
5547
|
+
maxOutputTokens: params.config.maxOutputTokens,
|
|
5548
|
+
thinkingConfig: params.config.thinkingConfig
|
|
5549
|
+
}
|
|
5550
|
+
};
|
|
5551
|
+
return { text: ((await post(opts, apiKey, `/models/${params.model}:generateContent`, payload, 12e4)).candidates?.[0]?.content?.parts ?? []).map((p) => p.text ?? "").join("") };
|
|
5552
|
+
} } };
|
|
5553
|
+
}
|
|
5554
|
+
/**
|
|
5555
|
+
* The Gemini transport — the ONE place the vendor is spoken to (converted
|
|
5428
5556
|
* from the oracle's sor_content/lib/providers/gemini.py; decision 6).
|
|
5429
5557
|
* Identity (model, dim, task labels) is CONSTRUCTOR-INJECTED — this module
|
|
5430
5558
|
* never imports config, so the same adapter serves any Gemini embedding
|
|
@@ -5444,7 +5572,7 @@ var FakeEmbeddingProvider = class {
|
|
|
5444
5572
|
* clock. (The oracle's one divergence — a sync query-intent embed keeping
|
|
5445
5573
|
* the batch clock, an eval-harness case — has no TS call site.)
|
|
5446
5574
|
* - The oracle's "has been closed" stale-client RuntimeError predicate is a
|
|
5447
|
-
* Python-SDK failure mode with no
|
|
5575
|
+
* Python-SDK failure mode with no JS equivalent; `reset()`
|
|
5448
5576
|
* keeps its drop-never-close contract regardless.
|
|
5449
5577
|
*/
|
|
5450
5578
|
function httpStatusOf(exc) {
|
|
@@ -5498,7 +5626,7 @@ var GeminiEmbeddingProvider = class {
|
|
|
5498
5626
|
this.queryTaskLabel = opts.queryTaskLabel;
|
|
5499
5627
|
this.documentTimeoutMs = Math.trunc(opts.documentTimeoutS * 1e3);
|
|
5500
5628
|
this.queryTimeoutMs = Math.trunc(opts.queryTimeoutS * 1e3);
|
|
5501
|
-
this.clientFactory = opts.clientFactory ?? (() =>
|
|
5629
|
+
this.clientFactory = opts.clientFactory ?? (() => geminiRestEmbedClient(opts.apiKey));
|
|
5502
5630
|
}
|
|
5503
5631
|
get recipe() {
|
|
5504
5632
|
return `${this.modelId}/d${this.dim}/${this.documentTaskLabel}`;
|
|
@@ -5541,7 +5669,7 @@ var GeminiTextGenerator = class {
|
|
|
5541
5669
|
client = null;
|
|
5542
5670
|
constructor(opts) {
|
|
5543
5671
|
this.model = opts.model ?? "gemini-2.5-flash";
|
|
5544
|
-
this.clientFactory = opts.clientFactory ?? (() =>
|
|
5672
|
+
this.clientFactory = opts.clientFactory ?? (() => geminiRestTextClient(opts.apiKey));
|
|
5545
5673
|
}
|
|
5546
5674
|
getClient() {
|
|
5547
5675
|
this.client ??= this.clientFactory();
|
|
@@ -6312,7 +6440,8 @@ async function assertGovernanceServable(pool, instance, targetGeneration) {
|
|
|
6312
6440
|
if (generation === 0) return {
|
|
6313
6441
|
generation,
|
|
6314
6442
|
builtAt: null,
|
|
6315
|
-
restricted: 0
|
|
6443
|
+
restricted: 0,
|
|
6444
|
+
orphaned: []
|
|
6316
6445
|
};
|
|
6317
6446
|
return {
|
|
6318
6447
|
generation,
|
|
@@ -6321,13 +6450,27 @@ async function assertGovernanceServable(pool, instance, targetGeneration) {
|
|
|
6321
6450
|
instance.corpusId,
|
|
6322
6451
|
generation
|
|
6323
6452
|
])).rows[0]?.schema_version ?? null,
|
|
6324
|
-
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)
|
|
6325
6459
|
};
|
|
6326
6460
|
});
|
|
6327
6461
|
if (state.generation === 0) return;
|
|
6328
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
|
|
6329
6463
|
fix: rebuild the record so its governance reaches the database:
|
|
6330
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
|
+
}
|
|
6331
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:
|
|
6332
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
|
|
6333
6476
|
fix: declare the model in instance.md (audiences: least-restricted first, plus default_visibility:), or remove the visibility: keys and re-ingest`);
|
|
@@ -6745,7 +6888,10 @@ async function deniedStableIds(pool, instance) {
|
|
|
6745
6888
|
*
|
|
6746
6889
|
* The seed's OWN file counts when the seed has children, and only then — see
|
|
6747
6890
|
* the SQL comment: a container's index.md names its directory, a leaf's file
|
|
6748
|
-
* 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).
|
|
6749
6895
|
*/
|
|
6750
6896
|
async function deniedSubtreeDirs(pool, instance) {
|
|
6751
6897
|
const paths = await runRead(pool, instance.tenantId, async (client) => {
|
|
@@ -6753,7 +6899,7 @@ async function deniedSubtreeDirs(pool, instance) {
|
|
|
6753
6899
|
SELECT active_generation AS g FROM corpora WHERE tenant_id = $1 AND corpus_id = $2
|
|
6754
6900
|
),
|
|
6755
6901
|
seed AS (
|
|
6756
|
-
SELECT n.node_id
|
|
6902
|
+
SELECT n.node_id, n.stable_id
|
|
6757
6903
|
FROM takedown_denylist d
|
|
6758
6904
|
JOIN content_nodes n ON n.tenant_id = d.tenant_id AND n.stable_id = d.stable_id
|
|
6759
6905
|
JOIN gen ON n.generation = gen.g
|
|
@@ -6791,7 +6937,28 @@ async function deniedSubtreeDirs(pool, instance) {
|
|
|
6791
6937
|
WHERE NOT EXISTS (SELECT 1 FROM content_nodes kid
|
|
6792
6938
|
JOIN gen ON kid.generation = gen.g
|
|
6793
6939
|
WHERE kid.tenant_id = $1 AND kid.parent_id = s2.node_id)
|
|
6794
|
-
)
|
|
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));
|
|
6795
6962
|
});
|
|
6796
6963
|
const dirs = /* @__PURE__ */ new Set();
|
|
6797
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.
|
|
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",
|
|
@@ -51,7 +51,6 @@
|
|
|
51
51
|
"provenance": true
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@google/genai": "^2.17.1",
|
|
55
54
|
"@hono/node-server": "2.1.1",
|
|
56
55
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
57
56
|
"@types/pg": "^8.21.0",
|