@panaversity/ksor 0.0.2 → 0.0.3

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.
@@ -23,6 +23,7 @@ const ALLOWED_KEYS = new Set([
23
23
  "title",
24
24
  "description",
25
25
  "status",
26
+ "visibility",
26
27
  "owner",
27
28
  "provenance",
28
29
  "effective",
@@ -96,9 +97,10 @@ function unquote(value) {
96
97
 
97
98
  /**
98
99
  * The frontmatter block, two levels deep (`ksor:` has children; `provenance:`
99
- * has list items). Returns null when there is no block at all, and collects
100
- * every line that is neither `key: value`, a list item, an indented
101
- * continuation, nor blank those mean the block was never closed.
100
+ * and `audiences:` have list items, collected under the key above them).
101
+ * Returns null when there is no block at all, and collects every line that is
102
+ * neither `key: value`, a list item, an indented continuation, nor blank —
103
+ * those mean the block was never closed.
102
104
  */
103
105
  function parseFrontmatter(text) {
104
106
  // An editor's byte-order mark is invisible to the author; it must not be
@@ -108,6 +110,7 @@ function parseFrontmatter(text) {
108
110
  if (!match) return null;
109
111
  const keys = new Map();
110
112
  const children = new Map();
113
+ const lists = new Map();
111
114
  const quoted = new Set();
112
115
  const malformedQuote = new Map();
113
116
  const malformed = [];
@@ -139,6 +142,7 @@ function parseFrontmatter(text) {
139
142
  }
140
143
  keys.set(current, unquote(top[2]));
141
144
  children.set(current, new Map());
145
+ lists.set(current, []);
142
146
  continue;
143
147
  }
144
148
  const nested = /^[ \t]+([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
@@ -146,10 +150,40 @@ function parseFrontmatter(text) {
146
150
  children.get(current).set(nested[1], unquote(nested[2]));
147
151
  continue;
148
152
  }
153
+ // A list item belongs to the key above it: audiences: is a list of
154
+ // audiences, and the rules that read it need the entries, not their count.
155
+ const item = /^[ \t]*-[ \t]+(.*)$/.exec(line);
156
+ if (item && current !== null) {
157
+ // YAML ends a plain scalar at ` #` — the comment is not part of the
158
+ // entry, and reading it as one refuses the documents instead of the
159
+ // list (found live 2026-08-18: `- public # the default` made every
160
+ // public document's visibility undeclared).
161
+ const value = /^["']/.test(item[1]) ? item[1] : item[1].replace(/\s+#.*$/, "");
162
+ lists.get(current).push(unquote(value));
163
+ continue;
164
+ }
165
+ // A dash glued to its value (`-internal`) is a list item to nobody —
166
+ // the indented-continuation escape below swallowed it while the build
167
+ // scanners stopped reading the list there: one green record, two
168
+ // different audience lists (review finding, 2026-08-19).
169
+ if (/^[ \t]*-\S/.test(line)) {
170
+ malformed.push(line.trim());
171
+ continue;
172
+ }
149
173
  if (/^[ \t]*-([ \t]|$)/.test(line) || /^[ \t]+\S/.test(line)) continue;
150
174
  malformed.push(line.trim());
151
175
  }
152
- return { keys, children, quoted, malformedQuote, duplicates, malformed, tightColons, tabIndents };
176
+ return {
177
+ keys,
178
+ children,
179
+ lists,
180
+ quoted,
181
+ malformedQuote,
182
+ duplicates,
183
+ malformed,
184
+ tightColons,
185
+ tabIndents,
186
+ };
153
187
  }
154
188
 
155
189
  /**
@@ -218,13 +252,14 @@ function linkTargets(body) {
218
252
  return raw.map((t) => (t.startsWith("<") && t.endsWith(">") ? t.slice(1, -1).trim() : t));
219
253
  }
220
254
 
255
+ /** Reports a broken target; returns the record document it resolves to, if any. */
221
256
  function checkLinkTarget(rel, docPath, target) {
222
257
  // Anything with a URI scheme (https:, mailto:, tel:, ftp:, …) or a
223
258
  // protocol-relative // host leaves the record on purpose — only relative
224
259
  // paths are the record's own links (review finding 2026-08-18: tel: was
225
260
  // reported as a dead file and //host as an escape).
226
- if (target === "" || target.startsWith("#") || target.startsWith("//")) return;
227
- if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return;
261
+ if (target === "" || target.startsWith("#") || target.startsWith("//")) return null;
262
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return null;
228
263
  const resolved = path.resolve(path.dirname(docPath), target.split("#")[0]);
229
264
  if (!resolved.startsWith(knowledgeDir + path.sep) && resolved !== knowledgeDir) {
230
265
  problem(
@@ -240,9 +275,46 @@ function checkLinkTarget(rel, docPath, target) {
240
275
  "a record with dead internal links serves different truths by path",
241
276
  "fix the path or remove the link",
242
277
  );
278
+ } else if (resolved.endsWith(".md")) {
279
+ return resolved;
243
280
  }
281
+ return null;
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // the audience model: who may read a document, declared in instance.md
286
+ // ---------------------------------------------------------------------------
287
+ const instanceMd = path.join(root, "instance.md");
288
+
289
+ /**
290
+ * The declared audiences, ordered least- to most-restricted, or null when the
291
+ * record declares none — and then every visibility rule below stays inert, so
292
+ * a record without an audience model behaves exactly as it did before the key
293
+ * existed. Read before the record itself: who may read what is a property of
294
+ * the whole record, which no single document can answer.
295
+ */
296
+ function readAudienceModel() {
297
+ if (!existsSync(instanceMd)) return null;
298
+ const fm = parseFrontmatter(readFileSync(instanceMd, "utf8"));
299
+ const audiences = fm?.lists.get("audiences") ?? [];
300
+ if (audiences.length === 0) return null;
301
+ return { audiences, defaultVisibility: scalarValue(fm, "default_visibility") ?? "" };
302
+ }
303
+
304
+ /**
305
+ * A plain scalar ends at ` #` — the rule the list items above already follow
306
+ * and both build scanners apply. The checker not applying it to values let
307
+ * `default_visibility: public # the default` build fine and fail `pnpm check`
308
+ * (review finding, 2026-08-19).
309
+ */
310
+ function scalarValue(fm, key) {
311
+ const value = fm.keys.get(key);
312
+ if (value === undefined || fm.quoted.has(key)) return value;
313
+ return value.replace(/\s+#.*$/, "").trim();
244
314
  }
245
315
 
316
+ const audienceModel = readAudienceModel();
317
+
246
318
  if (!existsSync(knowledgeDir)) {
247
319
  problem(
248
320
  "knowledge/",
@@ -389,6 +461,8 @@ if (!existsSync(knowledgeDir)) {
389
461
  }
390
462
 
391
463
  // frontmatter + links per document
464
+ const visibilityByPath = new Map();
465
+ const crossings = [];
392
466
  for (const p of mdFiles) {
393
467
  const rel = path.relative(root, p);
394
468
  const text = readFileSync(p, "utf8");
@@ -552,22 +626,97 @@ if (!existsSync(knowledgeDir)) {
552
626
  "a replaced document must hand the reader its successor — a broken pointer dead-ends them on stale truth",
553
627
  "fix the path (it resolves relative to this document), or write the successor first",
554
628
  );
629
+ } else {
630
+ crossings.push({ kind: "superseded_by", rel, from: p, to: resolved, target: successor });
631
+ }
632
+ }
633
+ // visibility: one audience per document, from the set instance.md declares
634
+ const visibility = fm.keys.get("visibility");
635
+ const listed = fm.lists.get("visibility") ?? [];
636
+ // A flow list ([a, b]) is already named by the shape rule above.
637
+ const flowList = !fm.quoted.has("visibility") && /^\[.*\]$/.test(visibility ?? "");
638
+ if (listed.length > 0) {
639
+ problem(
640
+ rel,
641
+ "visibility is one value, not a list",
642
+ "a list makes every document a set-membership question, and set intersection is where access-control bugs live — one document belongs to exactly one audience",
643
+ `write a single audience: visibility: ${audienceModel?.audiences.at(-1) ?? "<audience>"}`,
644
+ );
645
+ } else if (visibility !== undefined && !flowList) {
646
+ if (audienceModel === null) {
647
+ problem(
648
+ rel,
649
+ `visibility: ${visibility} — the record declares no audience model`,
650
+ "who may read a document is governance, not a comment: with no audiences: in instance.md nothing constrains this value, and every surface publishes the document to everyone regardless",
651
+ "add audiences: to instance.md (ordered least- to most-restricted, public first) with default_visibility:, or remove the visibility: key",
652
+ );
653
+ } else if (!audienceModel.audiences.includes(visibility)) {
654
+ problem(
655
+ rel,
656
+ `visibility "${visibility}" is not a declared audience`,
657
+ "the audience set is closed in instance.md — a value outside it names a build that does not exist, so the document reaches either nobody or everybody",
658
+ `use one of: ${audienceModel.audiences.join(", ")} — or remove the key to take the default (${audienceModel.defaultVisibility})`,
659
+ );
555
660
  }
556
661
  }
662
+ if (audienceModel !== null) {
663
+ visibilityByPath.set(p, visibility ?? audienceModel.defaultVisibility);
664
+ }
557
665
  }
558
666
  // links: resolve, and never escape the record
559
- for (const target of linkTargets(stripCode(text))) checkLinkTarget(rel, p, target);
667
+ for (const target of linkTargets(stripCode(text))) {
668
+ const to = checkLinkTarget(rel, p, target);
669
+ if (to !== null) crossings.push({ kind: "link", rel, from: p, to, target });
670
+ }
671
+ }
672
+
673
+ // Pointers across audiences: the leak no single build can catch, because the
674
+ // build that publishes the pointer has already dropped its target and cannot
675
+ // know it ever existed. Only the whole record sees both ends.
676
+ if (audienceModel !== null) {
677
+ const audienceOf = (file) => visibilityByPath.get(file) ?? audienceModel.defaultVisibility;
678
+ const tier = (file) => audienceModel.audiences.indexOf(audienceOf(file));
679
+ for (const { kind, rel, from, to, target } of crossings) {
680
+ const here = tier(from);
681
+ const there = tier(to);
682
+ // An undeclared audience at either end is already reported; comparing
683
+ // against a tier that does not exist would invent a second problem.
684
+ if (here === -1 || there === -1 || there <= here) continue;
685
+ const relTo = path.relative(root, to);
686
+ const both = `${relTo} is ${audienceOf(to)}, this document is ${audienceOf(from)}`;
687
+ if (kind === "link") {
688
+ problem(
689
+ rel,
690
+ `link to a more restricted document: ${target} — ${both}`,
691
+ "the build that publishes this link has already dropped its target: the link text and URL ship to readers who cannot open them, naming a document they were never meant to know exists",
692
+ `raise this document to ${audienceOf(to)}, widen ${relTo} to ${audienceOf(from)}, or remove the link`,
693
+ );
694
+ } else {
695
+ problem(
696
+ rel,
697
+ `superseded_by points at a more restricted document: ${target} — ${both}`,
698
+ "it strands the very readers the supersession exists to redirect: they are told this document is replaced, by a successor their build does not contain",
699
+ `widen ${relTo} to ${audienceOf(from)}, raise this document to ${audienceOf(to)}, or supersede it with a document its readers can reach`,
700
+ );
701
+ }
702
+ }
560
703
  }
561
704
  }
562
705
 
563
706
  // ---------------------------------------------------------------------------
564
707
  // instance.md: the identity of this SoR — format 1, closed key set
565
708
  // ---------------------------------------------------------------------------
566
- const INSTANCE_KEYS = new Set(["format", "name", "ksor", "site"]);
709
+ const INSTANCE_KEYS = new Set([
710
+ "format",
711
+ "name",
712
+ "ksor",
713
+ "site",
714
+ "audiences",
715
+ "default_visibility",
716
+ ]);
567
717
  const INSTANCE_KSOR_KEYS = new Set(["requires", "scaffolded"]);
568
718
  const INSTANCE_SITE_KEYS = new Set(["url"]);
569
719
 
570
- const instanceMd = path.join(root, "instance.md");
571
720
  if (!existsSync(instanceMd)) {
572
721
  problem(
573
722
  "instance.md",
@@ -673,6 +822,63 @@ if (!existsSync(instanceMd)) {
673
822
  );
674
823
  }
675
824
  }
825
+ // the audience model: ordered, public first, and never without its default
826
+ const audiences = fm.lists.get("audiences") ?? [];
827
+ const defaultVisibility = scalarValue(fm, "default_visibility") ?? "";
828
+ if (fm.keys.has("audiences") && audiences.length === 0) {
829
+ const value = fm.keys.get("audiences");
830
+ problem(
831
+ "instance.md",
832
+ value === ""
833
+ ? "audiences: declares no audiences"
834
+ : `audiences is a list, not a value: ${value}`,
835
+ "the audience list is the record's whole access model, ordered least- to most-restricted — with nothing in it, no document's visibility: can be answered",
836
+ "write it as list items:\n audiences:\n - public\n - internal",
837
+ );
838
+ } else if (audiences.length > 0) {
839
+ if (audiences[0] !== "public") {
840
+ problem(
841
+ "instance.md",
842
+ `audiences: does not start with public (it starts with "${audiences[0]}")`,
843
+ "the order is the restriction level, and that ordering is what makes an internal build mean public-and-internal with no further configuration — public is the least restricted tier by definition",
844
+ "list public first, then each narrower audience in turn",
845
+ );
846
+ }
847
+ const seen = new Set();
848
+ for (const audience of audiences) {
849
+ if (seen.has(audience)) {
850
+ problem(
851
+ "instance.md",
852
+ `duplicate audience: ${audience}`,
853
+ "an audience's position in the list is its restriction level — named twice, it has two levels and neither can be trusted",
854
+ `keep one ${audience} entry`,
855
+ );
856
+ }
857
+ seen.add(audience);
858
+ }
859
+ if (defaultVisibility === "") {
860
+ problem(
861
+ "instance.md",
862
+ "audiences: without default_visibility:",
863
+ "there is no safe inference for a document that declares no audience: guessing the widest leaks the first document whose key is forgotten, guessing the narrowest hides the record from everyone it was written for",
864
+ `add default_visibility: — one of ${audiences.join(", ")} — the audience a document belongs to when it declares none`,
865
+ );
866
+ } else if (!audiences.includes(defaultVisibility)) {
867
+ problem(
868
+ "instance.md",
869
+ `default_visibility "${defaultVisibility}" is not one of the declared audiences`,
870
+ "every document without a visibility: key takes this value — a default outside the list puts most of the record in an audience that does not exist",
871
+ `use one of: ${audiences.join(", ")}`,
872
+ );
873
+ }
874
+ } else if (fm.keys.has("default_visibility")) {
875
+ problem(
876
+ "instance.md",
877
+ "default_visibility: without audiences:",
878
+ "a default audience with no audience list is a setting with nothing to select from — the owner believes the record has a visibility model while every surface publishes every document to everyone",
879
+ "add audiences: (ordered least- to most-restricted, public first), or remove default_visibility:",
880
+ );
881
+ }
676
882
  for (const [parent, allowed] of [
677
883
  ["ksor", INSTANCE_KSOR_KEYS],
678
884
  ["site", INSTANCE_SITE_KEYS],
@@ -757,6 +963,9 @@ if (existsSync(siteDir)) {
757
963
  (p) =>
758
964
  !p.includes(`${path.sep}.next${path.sep}`) &&
759
965
  !p.includes(`${path.sep}.source${path.sep}`) &&
966
+ // The per-audience stage: generated copies of the record a build makes
967
+ // for one audience, never authored content (specs/ksor/visibility).
968
+ !p.includes(`${path.sep}.staged-knowledge${path.sep}`) &&
760
969
  !p.includes(`${path.sep}out${path.sep}`),
761
970
  )
762
971
  .filter((p) => p.toLowerCase().endsWith(".md") || p.toLowerCase().endsWith(".mdx"));
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: intake-interview
3
- description: The first conversation with the owner of this Knowledge System of Record — five questions that define what it is authoritative for, then write instance.md together. Use when the owner asks to set up, configure, or "get started with" this project, when instance.md still contains its scaffold placeholder text, or when the scope of the corpus is unclear.
3
+ description: The first conversation with the owner of this Knowledge System of Record — six questions that define what it is authoritative for and who may read it, then write instance.md together. Use when the owner asks to set up, configure, or "get started with" this project, when instance.md still contains its scaffold placeholder text, or when the scope of the corpus is unclear.
4
4
  metadata:
5
- version: "1.1.0"
5
+ version: "1.2.0"
6
6
  ---
7
7
 
8
8
  # Intake interview
@@ -12,7 +12,7 @@ prose will one day be the agent surface's system prompt. Do not draft it from
12
12
  guesses — interview the owner, one question at a time, and write down what
13
13
  they actually say.
14
14
 
15
- ## The five questions
15
+ ## The six questions
16
16
 
17
17
  Ask these one at a time; follow up until each answer is concrete enough to
18
18
  act on:
@@ -30,6 +30,18 @@ act on:
30
30
  5. **Strictness** — "When the record doesn't cover a question, how firmly
31
31
  should it decline? ('Not in this corpus' is a correct answer here —
32
32
  confirm the owner wants that behavior and where they want it softened.)"
33
+ 6. **Audiences** — "Does every reader of this record see every document? If
34
+ not, what are the audiences, from most public to most restricted?" A yes
35
+ is the common answer and the whole answer: write no `audiences:` key and
36
+ nothing about the project changes. A list means writing it into
37
+ `instance.md`'s frontmatter — ordered least- to most-restricted with
38
+ `public` first, plus `default_visibility:` naming the audience a document
39
+ takes when it says nothing (there is no safe guess, so the checker
40
+ requires it). Tell the owner what the key does and does not do:
41
+ documents carry `visibility:` and builds are made per audience, but
42
+ anyone who can clone the repository reads everything in it — if someone
43
+ must not read a document and can clone, that document belongs in a
44
+ different repository.
33
45
 
34
46
  ## Then write
35
47
 
@@ -37,7 +49,10 @@ act on:
37
49
  record's **display title**, the human name every page will lead with
38
50
  ("Acme Operations Handbook", not the slug) — then the authority sentence,
39
51
  boundary, audience, and strictness — plain prose, written for a reader
40
- who must act on it. Do not touch the frontmatter keys.
52
+ who must act on it. Leave the frontmatter keys alone, with one exception:
53
+ an audience model from question 6 is written there as `audiences:` (a
54
+ list) and `default_visibility:`, and `pnpm check` will hold the record to
55
+ it from that moment on.
41
56
  - Restart `pnpm dev` afterwards so the site picks the new title up, and
42
57
  show the owner their name on the page.
43
58
  - Offer to capture the source list from question 4 as the first real
@@ -15,8 +15,11 @@ The record survives the system: `knowledge/` must stay readable and complete
15
15
  even if `system/` is deleted. Dependency flows one way — the system reads the
16
16
  record; the record never references the system.
17
17
 
18
- `instance.md` carries a closed key set — `format`, `name`, `ksor`, `site` —
19
- and everything that matters about it is the prose below that frontmatter;
18
+ `instance.md` carries a closed key set — `format`, `name`, `ksor`, `site`,
19
+ and the optional pair `audiences` + `default_visibility` (the record's reader
20
+ audiences, ordered least- to most-restricted with `public` first, and the one
21
+ a document takes when it names none — declared together or not at all) — and
22
+ everything that matters about it is the prose below that frontmatter;
20
23
  `pnpm check` names any other key rather than ignoring it.
21
24
 
22
25
  ## Critical rules
@@ -27,6 +30,8 @@ and everything that matters about it is the prose below that frontmatter;
27
30
  2. **`knowledge/` is CommonMark `.md` only.** No `.mdx`, no `meta.json`, no
28
31
  framework files. A document must read cleanly in any markdown viewer.
29
32
  3. **Never edit generated files** — `system/site/.source/`, `.next/`, `out/`,
33
+ `system/site/.staged-knowledge/` (a build's per-audience copy of the
34
+ record — edit `knowledge/`, or the next build erases the change),
30
35
  lockfiles by hand.
31
36
 
32
37
  ## Commands (run at the repo root)
@@ -38,6 +43,17 @@ pnpm build # static site into system/site/out/
38
43
  pnpm check # the format checker — run before handing off any knowledge change
39
44
  ```
40
45
 
46
+ ## Publishing
47
+
48
+ `pnpm build` emits a fully static site (`system/site/out/`) deployable to
49
+ any host — Vercel reads the shipped `vercel.json` (deploy from the repo
50
+ ROOT, never `system/site/`), and every other host just serves the folder.
51
+ `KSOR_BASE_PATH=/repo pnpm build` targets sub-path hosting. With
52
+ `audiences:` declared, plain `pnpm build` is always the public tier;
53
+ `KSOR_AUDIENCE=<audience> pnpm build` builds a wider tier that belongs
54
+ behind that audience's own access control, never on a public host.
55
+ Details in README → Deploying.
56
+
41
57
  ## Writing knowledge
42
58
 
43
59
  - One document per file under `knowledge/`; the path is the document's
@@ -51,10 +67,23 @@ pnpm check # the format checker — run before handing off any knowledge c
51
67
  - Frontmatter: `title` and `status` (`draft | review | approved | superseded`)
52
68
  are required. `owner` and `provenance` (a list naming real sources) are
53
69
  strongly encouraged — they become required as this project climbs the
54
- governance ladder. `description`, `order` (sidebar position), `effective`
55
- (the date the document takes effect) and `superseded` (a legacy marker —
56
- prefer `status`) are available. No other keys; never `id:` or `name:` — the
57
- path is the identity.
70
+ governance ladder. `description`, `visibility` (below), `order` (sidebar
71
+ position), `effective` (the date the document takes effect) and `superseded`
72
+ (a legacy marker — prefer `status`) are available. No other keys; never
73
+ `id:` or `name:` — the path is the identity.
74
+ - `visibility:` names the one audience a document belongs to — a single value
75
+ from `instance.md`'s `audiences:`, never a list, and orthogonal to `status:`
76
+ (an approved document can be restricted, and a draft is not hidden). Leave
77
+ it off and the document takes `default_visibility`. The key does nothing
78
+ until `instance.md` declares `audiences:`; once it does, `pnpm check`
79
+ refuses any link or `superseded_by:` pointing from a wider audience at a
80
+ narrower one — the leak no single build can catch, because the build that
81
+ publishes the link has already dropped its target.
82
+
83
+ **Publication, not authorship: anyone who can clone the repository reads
84
+ every document regardless of frontmatter; if someone must not read a
85
+ document and can clone, the answer is a second repository.**
86
+
58
87
  - A replaced document is marked `status: superseded` with `superseded_by:`
59
88
  pointing at its successor — superseded documents are never deleted.
60
89
  - Images and assets live in `knowledge/` beside the document that uses them,
@@ -94,7 +123,7 @@ You own `system/site/` outright — these are the seams, cheapest first:
94
123
  and the home-page mark are the same file.
95
124
  - **Anything deeper** — edit the site like the Next.js app it is; the only
96
125
  rule that survives customization is critical rule 1. The whole shell is
97
- replaceable behind a four-clause contract (a themed Docusaurus shell with
126
+ replaceable behind a five-clause contract (a themed Docusaurus shell with
98
127
  a swap recipe lives in the ksor repository under `workbench/shells/`).
99
128
 
100
129
  ## What this project owns
@@ -55,6 +55,33 @@ and how to fix it.
55
55
  Everything here is yours to change. The kit exists so that any coding agent can
56
56
  operate this project without being taught it first.
57
57
 
58
+ ## Deploying
59
+
60
+ The built site is a folder of files — 2 MB of HTML, JS and CSS with zero
61
+ host-specific dependencies. `pnpm build` writes it to `system/site/out/`,
62
+ and anything that can serve files can serve it.
63
+
64
+ - **Vercel** — connect the repository (or run `vercel`); the shipped
65
+ `vercel.json` answers the setup interview: deploy from the repo root
66
+ (never pin `system/site` as the root directory — the record lives
67
+ outside it), build with `pnpm build`, serve `system/site/out/`. If the
68
+ build image's pnpm predates the `packageManager` pin, set the
69
+ `ENABLE_EXPERIMENTAL_COREPACK=1` build environment variable.
70
+ - **GitHub Pages, nginx, S3, anything static** — run `pnpm build` and
71
+ upload `system/site/out/`. Hosted under a sub-path (like
72
+ `user.github.io/repo`)? Build with `KSOR_BASE_PATH=/repo pnpm build`.
73
+ - **Verify any deploy** the same way: the home page, one document page,
74
+ and `/llms.txt` all load; nothing else is required.
75
+
76
+ If `instance.md` declares `audiences:`, what you deploy is a **tier**.
77
+ Plain `pnpm build` always builds the public tier — safe for any host.
78
+ `KSOR_AUDIENCE=<audience> pnpm build` builds a wider tier for that
79
+ audience's own deployment, and that build carries an
80
+ "— not for publication" label because it must never reach a public host:
81
+ put it behind access control you already trust (VPN, SSO proxy,
82
+ authenticated host). The tiers govern what a build contains; where each
83
+ build may be served is yours to enforce.
84
+
58
85
  ## Ownership
59
86
 
60
87
  Everything here is yours. The scaffold was generated by
@@ -5,6 +5,9 @@ node_modules/
5
5
  system/site/.next/
6
6
  system/site/.source/
7
7
  system/site/out/
8
+ # the per-audience copy of the record a build stages — a filtered derivative,
9
+ # never a second record; committing it would publish what a build excluded
10
+ system/site/.staged-knowledge/
8
11
  *.tsbuildinfo
9
12
 
10
13
  # secrets never enter the record — system/ is their future home (serve)
@@ -3,7 +3,7 @@ import Link from "next/link";
3
3
  // The same file Next serves as the favicon (app/icon.png) — one mark, one
4
4
  // asset. Replace it with your own and the tab icon changes with the page.
5
5
  import mark from "@/app/icon.png";
6
- import { BuiltWith } from "@/components/built-with";
6
+ import { FooterMark } from "@/components/footer-mark";
7
7
  import { appName, appTitle } from "@/lib/shared";
8
8
  import { basePath, getSortedPages } from "@/lib/source";
9
9
 
@@ -75,7 +75,7 @@ export default function HomePage() {
75
75
 
76
76
  <footer className="mx-auto w-full max-w-2xl px-6 pb-10">
77
77
  <p className="border-t border-fd-border pt-6 text-xs">
78
- <BuiltWith />
78
+ <FooterMark />
79
79
  </p>
80
80
  </footer>
81
81
  </main>
@@ -1,7 +1,7 @@
1
1
  import { getSortedPageTree } from "@/lib/source";
2
2
  import { DocsLayout } from "fumadocs-ui/layouts/docs";
3
3
  import { baseOptions } from "@/lib/layout.shared";
4
- import { BuiltWith } from "@/components/built-with";
4
+ import { FooterMark } from "@/components/footer-mark";
5
5
 
6
6
  export default function Layout({ children }: LayoutProps<"/docs">) {
7
7
  return (
@@ -13,7 +13,7 @@ export default function Layout({ children }: LayoutProps<"/docs">) {
13
13
  sidebar={{
14
14
  footer: (
15
15
  <p className="mt-3 text-xs">
16
- <BuiltWith />
16
+ <FooterMark />
17
17
  </p>
18
18
  ),
19
19
  }}
@@ -0,0 +1,22 @@
1
+ import type { ReactElement } from "react";
2
+ import { BuiltWith } from "@/components/built-with";
3
+ import { audienceNotice } from "@/lib/audience";
4
+
5
+ /**
6
+ * The foot of the site chrome: who built it, and — on any build below the
7
+ * public tier — which audience that build was for, so a screenshot of an
8
+ * internal site names itself and a page that escapes carries its own
9
+ * warning.
10
+ *
11
+ * The public build renders the attribution ALONE, exactly as a site with no
12
+ * audience model does: the one build with nothing to disclose must not even
13
+ * carry the shape of a disclosure.
14
+ */
15
+ export function FooterMark(): ReactElement {
16
+ if (audienceNotice === null) return <BuiltWith />;
17
+ return (
18
+ <>
19
+ <BuiltWith /> &middot; <span className="text-fd-muted-foreground">{audienceNotice}</span>
20
+ </>
21
+ );
22
+ }