@panaversity/ksor 0.0.39 → 0.0.41

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.
Files changed (108) hide show
  1. package/CHANGELOG.md +965 -0
  2. package/README.md +29 -9
  3. package/dist/checker/check-main.mjs +14049 -0
  4. package/dist/cli.mjs +11528 -5213
  5. package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
  6. package/dist/gateway.d.mts +52 -13
  7. package/dist/gateway.mjs +2 -2
  8. package/dist/index.d.mts +1 -1
  9. package/dist/index.mjs +1 -1
  10. package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
  11. package/docs/authorization.md +8 -6
  12. package/docs/deploying.md +36 -25
  13. package/docs/index.md +26 -13
  14. package/docs/ingesting.md +70 -22
  15. package/docs/tool-surface.md +69 -16
  16. package/package.json +4 -3
  17. package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
  18. package/schema/schema.sql +77 -14
  19. package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
  20. package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
  21. package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
  22. package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
  23. package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
  24. package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
  25. package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
  26. package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
  27. package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
  28. package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
  29. package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
  30. package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
  31. package/templates/scaffold/.github/workflows/validate.yml +9 -1
  32. package/templates/scaffold/.ksor/governance.yaml +17 -0
  33. package/templates/scaffold/AGENTS.md +293 -119
  34. package/templates/scaffold/Dockerfile +5 -1
  35. package/templates/scaffold/README.md +160 -42
  36. package/templates/scaffold/env.example +37 -6
  37. package/templates/scaffold/gitignore +14 -6
  38. package/templates/scaffold/instance.md +21 -17
  39. package/templates/scaffold/knowledge/governance-ladder.md +11 -2
  40. package/templates/scaffold/knowledge/index.md +9 -0
  41. package/templates/scaffold/knowledge/surfaces/for-agents.md +13 -6
  42. package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
  43. package/templates/scaffold/knowledge/surfaces/index.md +4 -20
  44. package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
  45. package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
  46. package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
  47. package/templates/scaffold/package.json +3 -4
  48. package/templates/scaffold/pnpm-lock.yaml +3 -0
  49. package/templates/scaffold/system/gateways/content.ts +13 -0
  50. package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
  51. package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
  52. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +134 -91
  53. package/templates/scaffold/system/site/app/global.css +198 -6
  54. package/templates/scaffold/system/site/app/layout.tsx +8 -3
  55. package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
  56. package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
  57. package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
  58. package/templates/scaffold/system/site/components/code-block.tsx +87 -0
  59. package/templates/scaffold/system/site/components/embed.tsx +276 -0
  60. package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
  61. package/templates/scaffold/system/site/components/governance.tsx +219 -87
  62. package/templates/scaffold/system/site/components/mdx.tsx +10 -2
  63. package/templates/scaffold/system/site/components/record-index.tsx +5 -5
  64. package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
  65. package/templates/scaffold/system/site/components/record-views.tsx +6 -17
  66. package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
  67. package/templates/scaffold/system/site/lib/alert-rule.ts +214 -0
  68. package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
  69. package/templates/scaffold/system/site/lib/attachments.ts +0 -28
  70. package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
  71. package/templates/scaffold/system/site/lib/audience.ts +42 -146
  72. package/templates/scaffold/system/site/lib/embed-rule.ts +255 -0
  73. package/templates/scaffold/system/site/lib/governance.ts +339 -225
  74. package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
  75. package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
  76. package/templates/scaffold/system/site/lib/lock.ts +282 -0
  77. package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
  78. package/templates/scaffold/system/site/lib/record-href.ts +68 -0
  79. package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
  80. package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
  81. package/templates/scaffold/system/site/lib/shared.ts +67 -104
  82. package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
  83. package/templates/scaffold/system/site/lib/source.ts +256 -186
  84. package/templates/scaffold/system/site/lib/stage-knowledge.ts +638 -467
  85. package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
  86. package/templates/scaffold/system/site/package.json +1 -0
  87. package/templates/scaffold/system/site/record/actor.ts +23 -0
  88. package/templates/scaffold/system/site/record/check.ts +571 -0
  89. package/templates/scaffold/system/site/record/citations.ts +312 -0
  90. package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
  91. package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
  92. package/templates/scaffold/system/site/record/hygiene.ts +320 -0
  93. package/templates/scaffold/system/site/record/index-file.ts +150 -0
  94. package/templates/scaffold/system/site/record/index.ts +103 -0
  95. package/templates/scaffold/system/site/record/instance.ts +257 -0
  96. package/templates/scaffold/system/site/record/instant.ts +43 -0
  97. package/templates/scaffold/system/site/record/ledger.ts +694 -0
  98. package/templates/scaffold/system/site/record/load.ts +129 -0
  99. package/templates/scaffold/system/site/record/lock.ts +306 -0
  100. package/templates/scaffold/system/site/record/near-miss.ts +37 -0
  101. package/templates/scaffold/system/site/record/policy.ts +414 -0
  102. package/templates/scaffold/system/site/record/profile.ts +535 -0
  103. package/templates/scaffold/system/site/record/refusal.ts +106 -0
  104. package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
  105. package/templates/scaffold/system/site/source.config.ts +109 -23
  106. package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
  107. package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
  108. package/templates/scaffold/system/site/lib/page-order.ts +0 -93
@@ -0,0 +1,214 @@
1
+ /**
2
+ * What makes a blockquote a CALLOUT: GitHub's alert syntax, unchanged.
3
+ *
4
+ * > [!WARNING]
5
+ * > A withdrawn document is still cited by answers that were given while
6
+ * > it was published.
7
+ *
8
+ * The reason this syntax and not `:::warning`: a blockquote is CommonMark. The
9
+ * record is CommonMark by rule (critical rule 3 keeps `knowledge/` free of any
10
+ * grammar a plain markdown reader has to learn), and this one is already read
11
+ * by the two places a record is looked at OUTSIDE this site — GitHub renders it
12
+ * as a styled alert, and every other viewer renders an ordinary blockquote
13
+ * carrying a visible `[!WARNING]` label. Nobody is misled and nothing is lost.
14
+ * A `:::` directive is a grammar: it renders as the literal characters, and it
15
+ * would reach `/md/`, `llms.txt` and `llms-full.txt`, where an agent would have
16
+ * to know our dialect to read the record.
17
+ *
18
+ * The set is GitHub's five, exactly. Adding a sixth would mean a record that
19
+ * renders here and not there, which is the whole thing this choice buys.
20
+ *
21
+ * A LEAF: no imports, so the remark plugin and the tests share one rule.
22
+ */
23
+
24
+ /**
25
+ * GitHub's marker -> the fumadocs Callout it becomes.
26
+ *
27
+ * `type` is one of fumadocs' `CalloutType` (`info` | `warn` | `error` |
28
+ * `success` | `warning` | `idea`); anything else renders as plain `info` with
29
+ * nothing going red, so these are checked against the shipped page.
30
+ *
31
+ * NOTE and IMPORTANT share `info` because fumadocs has no fifth colour, and
32
+ * inventing one would drift from GitHub. The `title` is what tells them apart,
33
+ * which is also how GitHub distinguishes them.
34
+ */
35
+ export const ALERT_KINDS = [
36
+ { marker: "NOTE", type: "info", title: "Note" },
37
+ { marker: "TIP", type: "idea", title: "Tip" },
38
+ { marker: "IMPORTANT", type: "info", title: "Important" },
39
+ { marker: "WARNING", type: "warn", title: "Warning" },
40
+ { marker: "CAUTION", type: "error", title: "Caution" },
41
+ ] as const;
42
+
43
+ export type AlertKind = (typeof ALERT_KINDS)[number];
44
+
45
+ export interface AlertMatch {
46
+ readonly kind: AlertKind;
47
+ /** What is left of the leading text once the marker's own line is removed. */
48
+ readonly rest: string;
49
+ }
50
+
51
+ /**
52
+ * The alert this blockquote opens with, or null when it is an ordinary quote.
53
+ *
54
+ * `leadingText` is the value of the first text node of the blockquote's first
55
+ * paragraph — the marker has to be the very start of the quote, and has to be
56
+ * the WHOLE of its first line. Both are GitHub's rules, and following them is
57
+ * the point: a quote that renders as a callout here and as a quote there would
58
+ * make the site and the record disagree about the same bytes.
59
+ *
60
+ * Case-insensitive, because GitHub accepts `[!note]` and rendering it plain
61
+ * here would be exactly that disagreement.
62
+ */
63
+ export function matchAlert(leadingText: string): AlertMatch | null {
64
+ if (!leadingText.startsWith("[!")) return null;
65
+
66
+ const close = leadingText.indexOf("]");
67
+ if (close === -1) return null;
68
+
69
+ const marker = leadingText.slice(2, close).toUpperCase();
70
+ const kind = ALERT_KINDS.find((entry) => entry.marker === marker);
71
+ if (!kind) return null;
72
+
73
+ const after = leadingText.slice(close + 1);
74
+ const newline = after.indexOf("\n");
75
+ const restOfLine = newline === -1 ? after : after.slice(0, newline);
76
+ // Anything else on the marker's line means the author wrote a quote that
77
+ // happens to start with a bracket, not an alert.
78
+ if (restOfLine.trim() !== "") return null;
79
+
80
+ return { kind, rest: newline === -1 ? "" : after.slice(newline + 1) };
81
+ }
82
+
83
+ /**
84
+ * The cases the rule is held to.
85
+ *
86
+ * A table rather than prose assertions, because the interesting half is what
87
+ * this must REFUSE: every refusal here is a blockquote an author wrote meaning
88
+ * a blockquote, and turning one into a coloured panel is a change to the
89
+ * record's meaning that nothing else would catch.
90
+ */
91
+ export const ALERT_CASES = [
92
+ // The five, as GitHub documents them.
93
+ { text: "[!NOTE]\nThe record is the source of truth.", type: "info", title: "Note" },
94
+ { text: "[!TIP]\nStart at level 0.", type: "idea", title: "Tip" },
95
+ { text: "[!IMPORTANT]\nCitations pin a generation.", type: "info", title: "Important" },
96
+ { text: "[!WARNING]\nThis document is superseded.", type: "warn", title: "Warning" },
97
+ { text: "[!CAUTION]\nA takedown does not unsay an answer.", type: "error", title: "Caution" },
98
+ // Lowercase renders as an alert on GitHub, so it renders as one here.
99
+ { text: "[!note]\nStill an alert.", type: "info", title: "Note" },
100
+ { text: "[!Warning]\nStill an alert.", type: "warn", title: "Warning" },
101
+ // The marker alone, with the body in later nodes or later blocks.
102
+ { text: "[!NOTE]", type: "info", title: "Note" },
103
+ { text: "[!NOTE]\n", type: "info", title: "Note" },
104
+ // Trailing spaces on the marker's line are invisible; they may not decide.
105
+ { text: "[!NOTE] \nBody.", type: "info", title: "Note" },
106
+ // Ordinary blockquotes, which must stay blockquotes.
107
+ { text: "A quote about something.", type: null, title: null },
108
+ { text: "[!NOTES]\nNot a marker.", type: null, title: null },
109
+ { text: "[!]\nEmpty marker.", type: null, title: null },
110
+ { text: "[NOTE]\nNo bang.", type: null, title: null },
111
+ { text: " [!NOTE]\nLeading space.", type: null, title: null },
112
+ { text: "[!NOTE\nUnclosed.", type: null, title: null },
113
+ // The marker has to own its line. Text beside it means the author quoted it.
114
+ { text: "[!NOTE] see below", type: null, title: null },
115
+ { text: "[!NOTE] see below\nBody.", type: null, title: null },
116
+ ] as const;
117
+
118
+ /**
119
+ * The slice of hast this touches, written structurally rather than imported.
120
+ *
121
+ * `@types/hast` would be a dependency for five field names, and this only ever
122
+ * reads `type`/`tagName`, walks `children`, and edits the `value` of a text
123
+ * node. Typing what is used keeps this file a leaf.
124
+ */
125
+ interface AlertNode {
126
+ type: string;
127
+ tagName?: string;
128
+ children?: AlertNode[];
129
+ value?: string;
130
+ name?: string;
131
+ attributes?: { type: "mdxJsxAttribute"; name: string; value: string }[];
132
+ }
133
+
134
+ /**
135
+ * The `<Callout>` this blockquote becomes, or null when it stays a blockquote.
136
+ *
137
+ * Mutates the quote's own first paragraph to drop the marker line — the marker
138
+ * is syntax, and leaving it in the rendered panel would show the reader the
139
+ * plumbing.
140
+ */
141
+ function calloutFor(node: AlertNode): AlertNode | null {
142
+ if (node.type !== "element" || node.tagName !== "blockquote") return null;
143
+
144
+ // hast keeps the source's whitespace between block children, so the first
145
+ // paragraph is the first ELEMENT rather than the first child.
146
+ const paragraph = node.children?.find((child) => child.type === "element");
147
+ if (!paragraph || paragraph.tagName !== "p") return null;
148
+
149
+ const lead = paragraph.children?.[0];
150
+ if (!lead || lead.type !== "text" || typeof lead.value !== "string") return null;
151
+
152
+ const match = matchAlert(lead.value);
153
+ if (!match) return null;
154
+
155
+ if (match.rest === "") {
156
+ // The marker was the whole text node. Drop it, and drop the paragraph too
157
+ // when the marker was all it held — `> [!NOTE]` on a line of its own.
158
+ paragraph.children?.shift();
159
+ if (paragraph.children?.length === 0) {
160
+ node.children = node.children?.filter((child) => child !== paragraph);
161
+ }
162
+ } else {
163
+ lead.value = match.rest;
164
+ }
165
+
166
+ return {
167
+ type: "mdxJsxFlowElement",
168
+ name: "Callout",
169
+ attributes: [
170
+ { type: "mdxJsxAttribute", name: "type", value: match.kind.type },
171
+ { type: "mdxJsxAttribute", name: "title", value: match.kind.title },
172
+ ],
173
+ children: node.children ?? [],
174
+ };
175
+ }
176
+
177
+ /** Depth-first, so an alert nested inside a list or another quote converts. */
178
+ function convertAlerts(node: AlertNode): void {
179
+ const children = node.children;
180
+ if (!children) return;
181
+
182
+ for (let i = 0; i < children.length; i++) {
183
+ const child = children[i];
184
+ if (!child) continue;
185
+ convertAlerts(child);
186
+ const callout = calloutFor(child);
187
+ if (callout) children[i] = callout;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * REHYPE, deliberately — and this is the load-bearing half of the design.
193
+ *
194
+ * As a remark plugin this works and is wrong: fumadocs serializes the record's
195
+ * markdown from the mdast (`includeProcessedMarkdown` -> `remarkLLMs`), so a
196
+ * blockquote rewritten there reaches `/md/` and `llms-full.txt` as
197
+ * `<Callout type="warn" title="Warning">` — the agent surface served this
198
+ * site's React component in place of the author's blockquote. Measured, not
199
+ * assumed: that is exactly what the first build of this emitted.
200
+ *
201
+ * By the rehype phase the markdown is already captured, so the page gets the
202
+ * callout and every agent-facing surface keeps the record's own shape. This is
203
+ * product principle 2 — one source, two surfaces — and the reason the syntax
204
+ * is GitHub's rather than a directive in the first place.
205
+ *
206
+ * The cost, recorded because it is real: `remarkStructure` also runs in the
207
+ * remark phase, so the search index contains the literal `[!NOTE]` alongside
208
+ * the passage. Noise in one index is the cheaper half of this trade.
209
+ */
210
+ export function rehypeGithubAlerts(): (tree: AlertNode) => void {
211
+ return (tree: AlertNode): void => {
212
+ convertAlerts(tree);
213
+ };
214
+ }
@@ -110,7 +110,12 @@ export const ATTACHMENT_CASES = [
110
110
  { name: "returns.flashcards.yaml", kind: "deck", parent: "returns.md" },
111
111
  { name: "returns.quiz.yaml", kind: "quiz", parent: "returns.md" },
112
112
  { name: "returns.slides.yaml", kind: "slides", parent: "returns.md" },
113
- { name: "index.summary.md", kind: "summary", parent: "index.md" },
113
+ // No `index.summary.md` row: `index.md` is GENERATED (record spec §1) — no
114
+ // route, no node, no governance of its own — so nothing attaches to it, and
115
+ // decision 27 retires this row with the authored index. The NAME is still an
116
+ // attachment by this rule (it has a stem and a known suffix); what refuses it
117
+ // is `ksor-attachment-of-index` in the checker, which is where "who may be a
118
+ // parent" belongs — this file only answers "is this name an attachment".
114
119
  // A stem containing dots keeps every one of them: the parent is the same
115
120
  // name with the attachment suffix removed, never "up to the first dot".
116
121
  { name: "v1.2.policy.summary.md", kind: "summary", parent: "v1.2.policy.md" },
@@ -1,8 +1,6 @@
1
1
  import { decks, quizzes, slides, summaries } from "collections/server";
2
2
 
3
- import { ATTACHMENT_SUFFIXES } from "./attachment-rule";
4
3
  import { cardHash, type Card, type Deck } from "./deck";
5
- import { newCard, type CardSchedule } from "./srs";
6
4
  import { type Question, type Quiz } from "./quiz";
7
5
  import { type Slide, type Slides } from "./slides";
8
6
  import { embedUrlFor, providerOf } from "./slides-embed";
@@ -162,29 +160,3 @@ export function slidesFor(documentPath: string): SlidesEntry | null {
162
160
  path: parsed.info.path,
163
161
  };
164
162
  }
165
-
166
- /** True when a document has ANY attachment — the presence gate for the UI. */
167
- export function hasAttachments(documentPath: string): boolean {
168
- return (
169
- summaryFor(documentPath) !== null ||
170
- deckFor(documentPath) !== null ||
171
- quizFor(documentPath) !== null ||
172
- slidesFor(documentPath) !== null
173
- );
174
- }
175
-
176
- /**
177
- * A fresh schedule for every card in a deck, all due now.
178
- *
179
- * Exported so the deck's first render and its reset path agree by construction
180
- * rather than by two similar-looking object literals.
181
- */
182
- export function freshSchedules(
183
- cards: readonly DeckCard[],
184
- now: number,
185
- ): Record<string, CardSchedule> {
186
- return Object.fromEntries(cards.map((card) => [card.hash, newCard(card.hash, now)]));
187
- }
188
-
189
- /** Every attachment suffix, for the surfaces that need the list rather than the rule. */
190
- export const ATTACHMENT_SUFFIX_LIST: readonly string[] = ATTACHMENT_SUFFIXES.map((e) => e.suffix);
@@ -16,29 +16,23 @@
16
16
  * this file is the shared implementation of the TypeScript half.
17
17
  */
18
18
 
19
- export interface AudienceModel {
20
- /** Least- to most-restricted, `public` first. */
21
- readonly audiences: readonly string[];
22
- /** The tier of a document that declares no `visibility:`. */
23
- readonly defaultVisibility: string;
19
+ /**
20
+ * The overlap rule (record spec §2.4): a concept holds a LIST of audience
21
+ * identifiers, a viewer holds a list that always includes `public`, and the
22
+ * concept is visible when the two overlap. Rank moves to the viewer,
23
+ * membership stays on the document. Omission is a refusal upstream, never a
24
+ * default here — an empty list on either side is visible to nobody.
25
+ */
26
+ export function overlaps(viewer: readonly string[], audience: readonly string[]): boolean {
27
+ return audience.some((a) => viewer.includes(a));
24
28
  }
25
29
 
26
30
  /**
27
- * May a build FOR `audience` publish a document of this `visibility`?
28
- *
29
- * `model === null` is a record that declares no audience model: nothing to
30
- * filter, everything publishes the level-0 shape.
31
+ * The widening rule: a link, a `ksor.superseded_by` pointer or a companion
32
+ * body may reach a target whose audience contains `public` or contains every
33
+ * identifier in the source's then every reader of the source can read the
34
+ * target. `[internal]` `[public]` passes; `[public]` → `[internal]` refuses.
31
35
  */
32
- export function decideVisible(
33
- model: AudienceModel | null,
34
- audience: string,
35
- visibility: string | null,
36
- ): boolean {
37
- if (model === null) return true;
38
- const value = visibility === null || visibility === "" ? model.defaultVisibility : visibility;
39
- const rank = model.audiences.indexOf(value);
40
- // An undeclared visibility is refused, never published: a value no build
41
- // understands is a typo, and a typo reads as a restriction.
42
- if (rank === -1) return false;
43
- return rank <= model.audiences.indexOf(audience);
36
+ export function mayReach(source: readonly string[], target: readonly string[]): boolean {
37
+ return target.includes("public") || source.every((a) => target.includes(a));
44
38
  }
@@ -1,168 +1,64 @@
1
- import { decideVisible, type AudienceModel } from "./audience-rule";
2
- import { instanceFrontmatter } from "./shared";
3
-
4
1
  /**
5
- * The audience model, declared in instance.md the record says who its
6
- * readers are, and the build enforces it:
2
+ * The viewer this build publishes for (record spec §2.4, build spec §3).
3
+ *
4
+ * A concept holds a LIST of audience identifiers; a viewer holds a list that
5
+ * always includes `public`; the concept is visible when the two overlap. The
6
+ * rule itself is `overlaps` in `./audience-rule` — the kernel's copy, asserted
7
+ * byte-identical — and this module only reads the viewer from the environment:
7
8
  *
8
- * audiences:
9
- * - public
10
- * - internal
11
- * - restricted
12
- * default_visibility: public
9
+ * KSOR_AUDIENCE=public,internal pnpm build
13
10
  *
14
- * Ordered least- to most-restricted, so "build the internal site" means
15
- * "public and internal included" with no further configuration. A record
16
- * that declares no audiences has no model and publishes every document
17
- * the behaviour of every instance written before this key existed.
11
+ * Unset means `[public]`, the only default that cannot leak. Whether each
12
+ * identifier is REGISTERED is the staging's question, because the registry
13
+ * lives in the policy (or the lock) and this module reads neither.
18
14
  */
19
- export type { AudienceModel };
20
- export { decideVisible };
21
-
22
- function unquote(raw: string): string {
23
- const trimmed = raw.trim();
24
- return /^(['"])(.*)\1$/.exec(trimmed)?.[2] ?? trimmed;
25
- }
26
15
 
27
- /** Every refusal this feature makes: a slug a pipeline can match, then the remedy. */
16
+ /** Every refusal the site makes: a slug a pipeline can match, then the remedy. */
28
17
  export function refuse(slug: string, what: string, why: string, fix: string): never {
29
18
  // The slug leads, so a pipeline can match on it, and the three lines below
30
19
  // it are the whole remedy — an operator never has to read this file.
31
20
  throw new Error(`${slug}: ${what}\n why: ${why}\n fix: ${fix}`);
32
21
  }
33
22
 
34
- function readAudienceModel(): AudienceModel | null {
35
- const block = instanceFrontmatter();
36
- // Top-level key only: `^` under /m cannot match an indented child.
37
- if (!/^audiences:/m.test(block)) return null;
38
-
39
- // The grammar mirrors the checker's exactly — CRLF-tolerant, list items at
40
- // ANY indent (YAML allows unindented block sequences), and a ` #` comment
41
- // ends an unquoted entry (all three found live 2026-08-18: records the
42
- // checker blessed either failed this build or silently lost a tier).
43
- const stripComment = (value: string): string =>
44
- /^["']/.test(value.trim()) ? value : value.replace(/\s+#.*$/, "");
45
- // A line scanner, not a block regex: a blank line among the items or a
46
- // comment on the key line broke the block capture and refused every build
47
- // of a checker-green record (review finding, 2026-08-19).
48
- const flow = /^audiences:[ \t]*\[(.*)\][ \t]*(?:#.*)?$/m.exec(block)?.[1];
49
- let items: string[] = [];
50
- if (flow !== undefined) {
51
- items = flow.split(",");
52
- } else {
53
- const lines = block.split("\n");
54
- const start = lines.findIndex((line) => /^audiences:[ \t]*(?:#.*)?$/.test(line));
55
- if (start !== -1) {
56
- for (const line of lines.slice(start + 1)) {
57
- if (line.trim() === "") continue;
58
- const item = /^[ \t]*-[ \t]+(.*)$/.exec(line);
59
- if (item === null) break;
60
- items.push(item[1] ?? "");
61
- }
62
- }
63
- }
64
- const audiences = items
65
- .map(stripComment)
66
- .map(unquote)
67
- .filter((value) => value !== "");
68
-
69
- // A declared-but-unreadable model must never read as "no model": that is
70
- // the one parse failure that publishes the whole record.
71
- if (audiences.length === 0) {
72
- refuse(
73
- "ksor-audiences-unreadable",
74
- "instance.md declares `audiences:` but no audience could be read from it",
75
- "an unreadable model reads as no model, and no model publishes every document — the one parse failure that leaks",
76
- "write the audiences as a list, least-restricted first:\n audiences:\n - public\n - internal",
77
- );
78
- }
23
+ let resolved: readonly string[] | null = null;
79
24
 
80
- // The staging never depends on the checker having run: a
81
- // most-restrictive-first model would make plain `pnpm build` publish
82
- // every restricted document with no label (review finding, 2026-08-18).
83
- if (audiences[0] !== "public") {
84
- refuse(
85
- "ksor-audiences-misordered",
86
- `audiences: must start with public (it starts with "${audiences[0]}")`,
87
- "the list is ordered least- to most-restricted, and an unset KSOR_AUDIENCE builds the FIRST entry — any other first entry makes the default build the leak",
88
- "reorder audiences: with public first",
89
- );
90
- }
91
- if (new Set(audiences).size !== audiences.length) {
92
- refuse(
93
- "ksor-audiences-duplicate",
94
- `audiences: declares a tier twice (${audiences.join(", ")})`,
95
- "a duplicated tier has two positions in the ordering, and which one a build honours is undefined",
96
- "remove the duplicate entry",
97
- );
98
- }
99
- const defaultVisibility = unquote(
100
- stripComment(/^default_visibility:[ \t]*(.*)$/m.exec(block)?.[1] ?? ""),
101
- );
102
- if (defaultVisibility === "") {
103
- refuse(
104
- "ksor-default-visibility-missing",
105
- "instance.md declares `audiences:` without `default_visibility:`",
106
- "there is no safe guess: assuming the widest tier leaks on the first document that forgets the key, assuming the narrowest hides the record",
107
- `add the tier a document without a visibility: key belongs to, e.g. default_visibility: ${audiences[0]}`,
108
- );
109
- }
110
- if (!audiences.includes(defaultVisibility)) {
111
- refuse(
112
- "ksor-default-visibility-undeclared",
113
- `default_visibility: ${defaultVisibility} is not one of the declared audiences (${audiences.join(", ")})`,
114
- "every document without a visibility: key belongs to this tier — a tier no build understands is a record no build can publish honestly",
115
- `set default_visibility: to one of ${audiences.join(", ")}, or declare ${defaultVisibility} in audiences:`,
116
- );
117
- }
118
-
119
- return { audiences, defaultVisibility };
25
+ /**
26
+ * The viewer list, `public` always among it. A function rather than a
27
+ * module constant so the refusal is raised where staging can clean up after
28
+ * it: thrown at import time it escaped the stage lock and left the previous,
29
+ * wider stage on disk (found by the staging suite, 2026-08-25).
30
+ */
31
+ export function viewer(): readonly string[] {
32
+ if (resolved === null) resolved = readViewer();
33
+ return resolved;
120
34
  }
121
35
 
122
- /** The declared model, or null when this record declares none. */
123
- export const audienceModel: AudienceModel | null = readAudienceModel();
124
-
125
- function resolveBuildAudience(model: AudienceModel | null): string {
126
- const requested = process.env.KSOR_AUDIENCE?.trim() ?? "";
127
- if (model === null) {
128
- if (requested !== "") {
129
- refuse(
130
- "ksor-audiences-not-declared",
131
- `KSOR_AUDIENCE="${requested}" was requested, but instance.md declares no audiences`,
132
- "this build would publish every document — a build that cannot filter must never look like one that did",
133
- "declare the model in instance.md (audiences: + default_visibility:), or build without KSOR_AUDIENCE",
134
- );
135
- }
136
- return "";
137
- }
138
- // Unset means the least-restricted tier: the only default that cannot leak,
139
- // so `pnpm build` keeps publishing the public site out of the box.
140
- if (requested === "") return model.audiences[0] as string;
141
- if (!model.audiences.includes(requested)) {
36
+ function readViewer(): readonly string[] {
37
+ const raw = process.env.KSOR_AUDIENCE?.trim() ?? "";
38
+ if (raw === "") return ["public"];
39
+ const list = raw
40
+ .split(",")
41
+ .map((item) => item.trim())
42
+ .filter((item) => item !== "");
43
+ if (!list.includes("public")) {
142
44
  refuse(
143
- "ksor-audience-undeclared",
144
- `KSOR_AUDIENCE="${requested}" is not an audience this record declares (${model.audiences.join(", ")})`,
145
- "an unrecognized audience could only be honoured by publishing more than the record names so it refuses instead of widening",
146
- `build with one of ${model.audiences.join(", ")}, or add "${requested}" to instance.md's audiences: list`,
45
+ "ksor-viewer-omits-public",
46
+ `KSOR_AUDIENCE="${raw}" does not include public`,
47
+ "a viewer list always includes public every reader of a restricted build is also a reader of the open one, and a build for a restricted audience alone would silently drop every public concept",
48
+ `build with KSOR_AUDIENCE=public,${list.join(",")}`,
147
49
  );
148
50
  }
149
- return requested;
150
- }
151
-
152
- /** The audience this build publishes for; "" when the record has no model. */
153
- export const buildAudience: string = resolveBuildAudience(audienceModel);
154
-
155
- /** Whether a document of this visibility belongs in THIS build. */
156
- export function visibleInBuild(visibility: string | null): boolean {
157
- return decideVisible(audienceModel, buildAudience, visibility);
51
+ return [...new Set(list)];
158
52
  }
159
53
 
160
54
  /**
161
- * What a non-public build calls itself, in the site chrome — so a leaked
162
- * screenshot of an internal site says which audience it was built for. The
163
- * public build (the least-restricted tier) says nothing new.
55
+ * What a restricted build calls itself, in the site chrome — so a leaked
56
+ * screenshot of an internal site says which audiences it was built for. The
57
+ * public build says nothing new.
164
58
  */
165
- export const audienceNotice: string | null =
166
- audienceModel === null || buildAudience === audienceModel.audiences[0]
59
+ export function audienceNotice(): string | null {
60
+ const list = viewer();
61
+ return list.length === 1
167
62
  ? null
168
- : `${buildAudience} build — not for publication`;
63
+ : `${list.filter((a) => a !== "public").join(", ")} build — not for publication`;
64
+ }