@panaversity/ksor 0.0.9 → 0.0.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
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",
@@ -147,19 +147,20 @@ Stand it up in this order (each step's errors explain how to fix themselves):
147
147
  `knowledge/`**: an applied schema reports "already applied", an existing
148
148
  grant reports "already granted", and ingest builds a fresh generation.
149
149
 
150
- **`pnpm serve` is the only command this rung needs.** Run it the first
151
- time, run it after editing `knowledge/`, run it to bring the server back —
152
- it is always the right answer, so there is nothing to decide. Every step it
153
- chains reports the state it found rather than failing: an applied schema
154
- says "already applied", an existing grant says "already granted", and
155
- unchanged chunks carry forward by content hash, so a rerun on an untouched
156
- corpus makes **zero provider calls** (`embedded 0, carried N`).
157
-
158
- A rerun on an unchanged record costs **nothing at all**: ingest compares the
159
- corpus it just read against the generation already serving and, when they
160
- are identical at the same commit, consumes no generation and writes no rows
161
- ("unchanged generation N already serves this corpus"). Edit a document and
162
- the next run builds a generation for it, re-embedding only what changed.
150
+ **`pnpm refresh` after editing `knowledge/`; `pnpm serve` to bring the
151
+ server up.** Two commands, and the split is the point: serving must not
152
+ publish, or a restart, a crash-loop or an autoscaling event each republishes
153
+ your record. Everything is re-runnable and reports the state it found rather
154
+ than failing an applied schema says "already applied", an existing grant
155
+ says "already granted".
156
+
157
+ A refresh on an unchanged record costs **nothing at all**: ingest compares
158
+ the corpus it just read against the generation already serving and, when
159
+ they are identical at the same commit, consumes no generation and writes no
160
+ rows ("unchanged generation N already serves this corpus"). Edit a
161
+ document and the next refresh builds a generation for it, re-embedding only
162
+ what changed and carrying the rest forward by content hash so an ordinary
163
+ edit makes a handful of provider calls, not a corpus-worth.
163
164
 
164
165
  Generations do accumulate as you edit. Reap the superseded ones when you
165
166
  think of it, or on a schedule:
@@ -169,10 +170,10 @@ Stand it up in this order (each step's errors explain how to fix themselves):
169
170
  ```
170
171
 
171
172
  The individual verbs (`pnpm schema`, `pnpm grant`, `pnpm ingest`,
172
- `pnpm serve`) exist for pipelines and split duties a deploy step that
173
- ingests while a different process serves, or a DBA who holds the credentials
174
- that authorize ingest. Reach for them when something else runs the steps;
175
- not as a daily choice.
173
+ `pnpm gc`) are what `provision` and `refresh` are made of. Reach for them
174
+ when duties are split a deploy step that ingests while a different process
175
+ serves, or a DBA who holds the credentials that authorize ingest — not as a
176
+ daily choice.
176
177
 
177
178
  4. **Turn the abstention gate on — deliberately, once it serves.** This is the
178
179
  step that makes "not in this corpus" a real answer, and it is measured, never
@@ -97,7 +97,7 @@ different coding agent's way of finding the same working contract.
97
97
  | `.gitattributes` | markdown is checked out byte-stable on every platform, so the same commit hashes the same everywhere. |
98
98
  | `.env.example` | the variables the served rung needs; copy to `.env` (gitignored) and fill in. |
99
99
  | `.gitignore` | keeps build output, `node_modules/`, and `.env` out of the record's history. |
100
- | `package.json` | the two surface commands — `pnpm dev` (the site) and `pnpm serve` (the agent surface: schema grant ingest serve) — plus `pnpm build` / `pnpm check`, the pinned `@panaversity/ksor` tool, and the pnpm version this project pins. |
100
+ | `package.json` | the surface commands — `pnpm dev` (the site) and `pnpm provision` / `pnpm refresh` / `pnpm serve` (the agent surface: set up once, publish, then serve) — plus `pnpm build` / `pnpm check`, the pinned `@panaversity/ksor` tool, and the pnpm version this project pins. |
101
101
  | `pnpm-workspace.yaml` | where the workspace looks for code (`system/site`, plus reserved `system/gateways/*` and `system/packages/*`), and the supply-chain policy for installs. |
102
102
  | `pnpm-lock.yaml` | the exact dependency versions — the reason two machines build the same site. |
103
103
 
@@ -20,11 +20,12 @@ GEMINI_API_KEY=
20
20
  # KSOR_SSO_URL=https://your-sso.example.com
21
21
  # KSOR_MCP_RESOURCE_URL=https://your-host.example.com/mcp
22
22
  # KSOR_JWT_ALLOWED_AUDIENCES=https://your-host.example.com/mcp
23
- # The JWKS endpoint defaults to <KSOR_SSO_URL>/api/auth/jwks, which is Better
24
- # Auth's layout. EVERY other provider — Auth0, Okta, Entra, Keycloak, Cognito —
25
- # publishes its keys elsewhere, and a wrong JWKS URL fails as a TRANSIENT fetch
26
- # error, so every request 503s with nothing naming the cause. Say where the keys
27
- # are:
23
+ # The signing keys are DISCOVERED from your SSO's own metadata document —
24
+ # RFC 8414 (/.well-known/oauth-authorization-server), then OpenID Discovery
25
+ # (/.well-known/openid-configuration) so Auth0, Okta, Entra, Keycloak,
26
+ # Cognito and Google all work unmodified. The boot line says which document
27
+ # answered and where the keys came from.
28
+ # Set this only to override discovery, or when your SSO publishes no metadata:
28
29
  # KSOR_JWKS_URL=https://your-sso.example.com/.well-known/jwks.json
29
30
  # Serving a public bind with auth off additionally requires
30
31
  # KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1, which serves your whole record to anyone
@@ -6,7 +6,10 @@ ksor:
6
6
  scaffolded: "KSOR-STAMP-VERSION"
7
7
  # The served MCP rung needs ONE required block: the NAME of the environment
8
8
  # variable holding your Postgres DSN — never the DSN itself. Uncomment it, copy
9
- # .env.example to .env, then run `pnpm serve`. Nothing else here is required:
9
+ # .env.example to .env, then: `pnpm provision` once (schema + grant), then
10
+ # `pnpm refresh` to PUBLISH the record, then `pnpm serve`. Serving does not
11
+ # publish — that is deliberate, and skipping refresh serves nothing.
12
+ # Nothing else here is required:
10
13
  # `embedding:` already defaults to Gemini at 1536 dimensions, and leaving
11
14
  # `retrieval:` out starts you with the abstention gate off and honest about it
12
15
  # (turn it on afterwards with `ksor calibrate`, once the record is serving).
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Reading order — ONE rule, for the website and the MCP door alike.
3
+ *
4
+ * `order:` is the only ordering key an author may write: it is in the governed
5
+ * frontmatter set the format checker closes, and the checker's own remedy for a
6
+ * stray `meta.json` says so ("sidebar order is the `order` frontmatter key").
7
+ *
8
+ * The MCP door did not read it. The kernel's tree adapter was converted from
9
+ * the predecessor, where the ordering keys were Docusaurus's `position` /
10
+ * `sidebar_position` — neither of which a compliant record may declare, because
11
+ * the checker refuses them as unknown keys. So the two surfaces disagreed about
12
+ * the record's reading order for every corpus that ordered itself at all: the
13
+ * site honoured `order:` and the door fell back to filename order and called it
14
+ * the record's structure. On a curriculum, where reading order IS the content,
15
+ * an agent asking `outline` for "what do I read first" got the wrong answer
16
+ * (found live 2026-08-21, by an agent probing a real ingested record).
17
+ *
18
+ * That is decision 18's shape — one guarantee, two surfaces, two heads — so it
19
+ * gets decision 18's treatment: this file is the rule, `ORDER_CASES` is the
20
+ * decision table, and both surfaces are asserted against the same rows. The
21
+ * site cannot import the kernel, so this file is COPIED into the scaffold and
22
+ * the copy is asserted byte-identical rather than trusted.
23
+ *
24
+ * Four things the two surfaces disagreed about beyond the key name, each of
25
+ * which is a row in the table:
26
+ *
27
+ * - the unordered sentinel. The kernel used 10_000, a real number, so
28
+ * `order: 20000` sorted AFTER an unordered document in the door and BEFORE
29
+ * it on the site. Unordered is not a large order; it is the absence of one.
30
+ * - truncation. The kernel applied `Math.trunc`, collapsing 3.2 and 3.7 into
31
+ * one position and re-sorting them by name; the site kept both.
32
+ * - the tie key's extension. The kernel compared `example.md` against
33
+ * `example-two.md` — where `-` (45) sorts before `.` (46) — while the site
34
+ * compared the extensionless urls, where the shorter is a prefix and wins.
35
+ * Two ordinary filenames, two different orders.
36
+ * - case. The kernel lowercased the tie key and the site did not, so
37
+ * `apple.md` and `Banana.md` came out in opposite orders.
38
+ *
39
+ * No imports: a leaf, so it is testable in isolation and safe to copy.
40
+ */
41
+
42
+ /**
43
+ * A document that declares no usable `order:` sorts after every document that
44
+ * does. Infinity, not a large number — see above.
45
+ */
46
+ export const UNORDERED: number = Number.POSITIVE_INFINITY;
47
+
48
+ /**
49
+ * The `order:` frontmatter value as a sort key.
50
+ *
51
+ * A numeric string is accepted because YAML frontmatter is read by scanners
52
+ * here, not by a YAML library: `order: 3` and `order: "3"` both reach this as
53
+ * text on one surface and as a number on the other, and an author cannot be
54
+ * expected to know which. Anything that is not a finite number — a word, a
55
+ * boolean, an empty value — is NOT an order, and the document sorts unordered.
56
+ */
57
+ export function orderValue(raw: unknown): number {
58
+ if (typeof raw === "number") return Number.isFinite(raw) ? raw : UNORDERED;
59
+ if (typeof raw === "string") {
60
+ const trimmed = raw.trim();
61
+ if (trimmed === "") return UNORDERED;
62
+ const parsed = Number(trimmed);
63
+ return Number.isFinite(parsed) ? parsed : UNORDERED;
64
+ }
65
+ return UNORDERED;
66
+ }
67
+
68
+ /**
69
+ * The tie key for one sibling: its name with a MARKDOWN extension removed,
70
+ * case PRESERVED. The extension comes off because the site compares routes,
71
+ * which never carry one, and `.` sorting after `-` silently reversed ordinary
72
+ * pairs. Only `.md`/`.mdx` come off — a directory named `v1.2` keeps its dot,
73
+ * because the site's route keeps it too. Case is preserved because the site
74
+ * compares urls, and the url is what a reader sees.
75
+ */
76
+ export function tieKey(name: string): string {
77
+ return name.replace(/\.mdx?$/, "");
78
+ }
79
+
80
+ /** One sibling's place in its parent: what it declared, and how ties break. */
81
+ export interface Sibling {
82
+ readonly order: number;
83
+ readonly tie: string;
84
+ }
85
+
86
+ /**
87
+ * Compare by code point, not by locale or UTF-16 unit: reading order must be
88
+ * one bytewise truth on every machine, and `<` on strings compares UTF-16 units
89
+ * — which differ from code points on astral names.
90
+ */
91
+ export function codePointCompare(a: string, b: string): number {
92
+ const as = [...a];
93
+ const bs = [...b];
94
+ const n = Math.min(as.length, bs.length);
95
+ for (let i = 0; i < n; i += 1) {
96
+ const x = as[i]?.codePointAt(0) ?? 0;
97
+ const y = bs[i]?.codePointAt(0) ?? 0;
98
+ if (x !== y) return x < y ? -1 : 1;
99
+ }
100
+ return as.length === bs.length ? 0 : as.length < bs.length ? -1 : 1;
101
+ }
102
+
103
+ /** Declared order first; ties break on the tie key. Total, and stable-safe. */
104
+ export function compareSiblings(a: Sibling, b: Sibling): number {
105
+ if (a.order !== b.order) return a.order < b.order ? -1 : 1;
106
+ return codePointCompare(a.tie, b.tie);
107
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Turning the reading-order rule into a sorted page tree — the SITE's half of
3
+ * decision 18, and canonical here so it can be tested.
4
+ *
5
+ * It names no framework type — the page-tree node shape is declared
6
+ * structurally below — for two reasons: the file is byte-copied into the
7
+ * scaffold's site, where it runs against Fumadocs's `Node`; and a rule that can
8
+ * only be exercised inside a Next build is a rule nobody exercises. The kernel
9
+ * has no website — it carries this so the SAME ORDER_CASES rows can be run
10
+ * against the tree sort a reader actually sees.
11
+ *
12
+ * Structural, not nominal: any node shaped like this sorts, which is what makes
13
+ * one file serve both the real page tree and the table's fixtures.
14
+ */
15
+
16
+ import { compareSiblings, UNORDERED } from "./order-rule";
17
+
18
+ /** The members of a page-tree node that reading order depends on. */
19
+ export interface OrderNode {
20
+ readonly type: string;
21
+ /** A page's route. Absent on folders. */
22
+ readonly url?: string;
23
+ /** A folder's index document, when it has one. */
24
+ readonly index?: { readonly url: string } | undefined;
25
+ readonly children?: readonly OrderNode[] | undefined;
26
+ }
27
+
28
+ function nodeOrder(node: OrderNode, orders: ReadonlyMap<string, number>): number {
29
+ if (node.type === "page") return orders.get(node.url ?? "") ?? UNORDERED;
30
+ if (node.type === "folder" && node.index) return orders.get(node.index.url) ?? UNORDERED;
31
+ return UNORDERED;
32
+ }
33
+
34
+ // A url that identifies this node: its own, its index's, or its first
35
+ // descendant's — a folder with no index document has no url of its own.
36
+ function nodeUrl(node: OrderNode): string {
37
+ if (node.type === "page") return node.url ?? "";
38
+ if (node.type === "folder") {
39
+ if (node.index) return node.index.url;
40
+ for (const child of node.children ?? []) {
41
+ const url = nodeUrl(child);
42
+ if (url !== "") return url;
43
+ }
44
+ }
45
+ return "";
46
+ }
47
+
48
+ // The index of a top-level document's own segment: "/docs/x" splits to
49
+ // ["", "docs", "x"], so its segment is at 2 — the baseUrl's segment count.
50
+ const BASE_SEGMENTS = "/docs".split("/").length;
51
+
52
+ /**
53
+ * The tie key: the ONE path segment that distinguishes this node from its
54
+ * siblings — never the whole url.
55
+ *
56
+ * The tree adapter ties on the entry's own name, so a folder with no index
57
+ * document must tie on its folder name too. Its url comes from a descendant
58
+ * (`/docs/guides/first`), and comparing that whole url against a sibling
59
+ * `/docs/guides-x` puts them in the opposite order, because the separator `/`
60
+ * (47) sorts after `-` (45). Taking the segment at this depth is exactly the
61
+ * adapter's key.
62
+ */
63
+ function tieAt(node: OrderNode, depth: number): string {
64
+ return nodeUrl(node).split("/")[BASE_SEGMENTS + depth] ?? "";
65
+ }
66
+
67
+ /**
68
+ * Children of one parent, in reading order, recursively.
69
+ *
70
+ * Generic in the node so the caller keeps its own richer type: the site passes
71
+ * Fumadocs `Node`s and gets `Node`s back. The one cast is the rebuilt folder —
72
+ * spreading a node and replacing `children` produces a value TypeScript can no
73
+ * longer prove is the same subtype, though it is: every other member is copied
74
+ * verbatim. `depth` starts at 0 for the tree's own children.
75
+ */
76
+ export function sortNodes<T extends OrderNode>(
77
+ nodes: readonly T[],
78
+ orders: ReadonlyMap<string, number>,
79
+ depth: number,
80
+ ): T[] {
81
+ return nodes
82
+ .map((node) =>
83
+ node.type === "folder"
84
+ ? ({ ...node, children: sortNodes(node.children ?? [], orders, depth + 1) } as T)
85
+ : node,
86
+ )
87
+ .sort((a, b) =>
88
+ compareSiblings(
89
+ { order: nodeOrder(a, orders), tie: tieAt(a, depth) },
90
+ { order: nodeOrder(b, orders), tie: tieAt(b, depth) },
91
+ ),
92
+ );
93
+ }
@@ -3,6 +3,9 @@ import { loader } from "fumadocs-core/source";
3
3
  import { lucideIconsPlugin } from "fumadocs-core/source/lucide-icons";
4
4
  import type { Node, Root } from "fumadocs-core/page-tree";
5
5
 
6
+ import { orderValue } from "./order-rule";
7
+ import { sortNodes } from "./page-order";
8
+
6
9
  // See https://fumadocs.dev/docs/headless/source-api for more info
7
10
  export const source = loader({
8
11
  baseUrl: "/docs",
@@ -18,54 +21,12 @@ export type KnowledgePage = (typeof source)["$inferPage"];
18
21
  // every rendered link.
19
22
  export const basePath: string = process.env.KSOR_BASE_PATH ?? "";
20
23
 
21
- // `order:` is a governed frontmatter key; a document without one, or with a
22
- // value that is not a number, sorts after every document that declares one.
24
+ // Reading order is ONE rule, shared with the MCP door byte-for-byte see
25
+ // ./order-rule.ts. The site cannot import the kernel, so the rule is copied and
26
+ // the copy is asserted; every case both surfaces must agree on is a row in the
27
+ // kernel's ORDER_CASES table, and this half is asserted against the same rows.
23
28
  function orderOf(page: KnowledgePage): number {
24
- const raw: unknown = page.data.order;
25
- const value = typeof raw === "string" ? Number(raw) : raw;
26
- return typeof value === "number" && Number.isFinite(value) ? value : Number.POSITIVE_INFINITY;
27
- }
28
-
29
- function nodeOrder(node: Node, orders: ReadonlyMap<string, number>): number {
30
- if (node.type === "page") return orders.get(node.url) ?? Number.POSITIVE_INFINITY;
31
- if (node.type === "folder" && node.index) {
32
- return orders.get(node.index.url) ?? Number.POSITIVE_INFINITY;
33
- }
34
- return Number.POSITIVE_INFINITY;
35
- }
36
-
37
- // The tie-break key: a page's url, a folder's index url or first page's url.
38
- // Ties break on it so unordered documents read in plain name order, folders
39
- // interleaved — the canonical reading order both shells implement (found
40
- // live 2026-08-18: the loader's own tie order grouped folders after loose
41
- // files, silently diverging from the Docusaurus shell on the same record).
42
- function nodeName(node: Node): string {
43
- if (node.type === "page") return node.url;
44
- if (node.type === "folder") {
45
- if (node.index) return node.index.url;
46
- for (const child of node.children) {
47
- const name = nodeName(child);
48
- if (name !== "") return name;
49
- }
50
- }
51
- return "";
52
- }
53
-
54
- function sortNodes(nodes: readonly Node[], orders: ReadonlyMap<string, number>): Node[] {
55
- return nodes
56
- .map((node) =>
57
- node.type === "folder" ? { ...node, children: sortNodes(node.children, orders) } : node,
58
- )
59
- .sort((a, b) => {
60
- const left = nodeOrder(a, orders);
61
- const right = nodeOrder(b, orders);
62
- if (left !== right) return left < right ? -1 : 1;
63
- const leftName = nodeName(a);
64
- const rightName = nodeName(b);
65
- // Codepoint comparison, not locale: reading order must be one bytewise
66
- // truth on every machine.
67
- return leftName < rightName ? -1 : leftName > rightName ? 1 : 0;
68
- });
29
+ return orderValue(page.data.order);
69
30
  }
70
31
 
71
32
  /**
@@ -76,7 +37,7 @@ function sortNodes(nodes: readonly Node[], orders: ReadonlyMap<string, number>):
76
37
  export function getSortedPageTree(): Root {
77
38
  const orders = new Map(source.getPages().map((page) => [page.url, orderOf(page)] as const));
78
39
  const tree = source.getPageTree();
79
- return { ...tree, children: sortNodes(tree.children, orders) };
40
+ return { ...tree, children: sortNodes(tree.children, orders, 0) };
80
41
  }
81
42
 
82
43
  function collectUrls(nodes: readonly Node[], urls: string[]): void {