@panaversity/ksor 0.0.40 → 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 (103) hide show
  1. package/CHANGELOG.md +869 -0
  2. package/README.md +11 -7
  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 +234 -113
  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 +13 -8
  38. package/templates/scaffold/instance.md +21 -17
  39. package/templates/scaffold/knowledge/governance-ladder.md +6 -2
  40. package/templates/scaffold/knowledge/index.md +9 -0
  41. package/templates/scaffold/knowledge/surfaces/for-agents.md +7 -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 +126 -91
  53. package/templates/scaffold/system/site/app/global.css +13 -5
  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/footer-mark.tsx +3 -2
  59. package/templates/scaffold/system/site/components/governance.tsx +205 -87
  60. package/templates/scaffold/system/site/components/record-index.tsx +5 -5
  61. package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
  62. package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
  63. package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
  64. package/templates/scaffold/system/site/lib/attachments.ts +0 -28
  65. package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
  66. package/templates/scaffold/system/site/lib/audience.ts +42 -146
  67. package/templates/scaffold/system/site/lib/embed-rule.ts +9 -0
  68. package/templates/scaffold/system/site/lib/governance.ts +339 -225
  69. package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
  70. package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
  71. package/templates/scaffold/system/site/lib/lock.ts +282 -0
  72. package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
  73. package/templates/scaffold/system/site/lib/record-href.ts +68 -0
  74. package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
  75. package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
  76. package/templates/scaffold/system/site/lib/shared.ts +67 -104
  77. package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
  78. package/templates/scaffold/system/site/lib/source.ts +256 -186
  79. package/templates/scaffold/system/site/lib/stage-knowledge.ts +566 -492
  80. package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
  81. package/templates/scaffold/system/site/package.json +1 -0
  82. package/templates/scaffold/system/site/record/actor.ts +23 -0
  83. package/templates/scaffold/system/site/record/check.ts +571 -0
  84. package/templates/scaffold/system/site/record/citations.ts +312 -0
  85. package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
  86. package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
  87. package/templates/scaffold/system/site/record/hygiene.ts +320 -0
  88. package/templates/scaffold/system/site/record/index-file.ts +150 -0
  89. package/templates/scaffold/system/site/record/index.ts +103 -0
  90. package/templates/scaffold/system/site/record/instance.ts +257 -0
  91. package/templates/scaffold/system/site/record/instant.ts +43 -0
  92. package/templates/scaffold/system/site/record/ledger.ts +694 -0
  93. package/templates/scaffold/system/site/record/load.ts +129 -0
  94. package/templates/scaffold/system/site/record/lock.ts +306 -0
  95. package/templates/scaffold/system/site/record/near-miss.ts +37 -0
  96. package/templates/scaffold/system/site/record/policy.ts +414 -0
  97. package/templates/scaffold/system/site/record/profile.ts +535 -0
  98. package/templates/scaffold/system/site/record/refusal.ts +106 -0
  99. package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
  100. package/templates/scaffold/system/site/source.config.ts +77 -22
  101. package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
  102. package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
  103. package/templates/scaffold/system/site/lib/page-order.ts +0 -93
@@ -32,6 +32,7 @@ import {
32
32
  readHandler,
33
33
  SEARCH_OUTPUT,
34
34
  searchHandler,
35
+ TRUST_TIERS,
35
36
  z,
36
37
  type ServiceContext,
37
38
  } from "@panaversity/ksor/gateway";
@@ -75,6 +76,18 @@ export default function buildGateway(ctx: ServiceContext, version: string): McpS
75
76
  .max(MAX_SEARCH_K)
76
77
  .default(10)
77
78
  .describe(`Maximum passages to return (1–${MAX_SEARCH_K})`),
79
+ // The caller's trust floor. Keep it: dropping the parameter does not
80
+ // weaken the record — the handler still applies `unverified` and this
81
+ // deployment's own floor — but it takes away the only way a caller can
82
+ // ask to be answered ONLY from what a human reviewed, and the door says
83
+ // so at boot instead of failing quietly.
84
+ min_trust_tier: z
85
+ .enum(TRUST_TIERS)
86
+ .optional()
87
+ .describe(
88
+ "Lowest trust tier to answer from (default unverified). This deployment's own " +
89
+ "floor still applies: it can only be raised here, never lowered.",
90
+ ),
78
91
  }),
79
92
  outputSchema: SEARCH_OUTPUT,
80
93
  annotations: READ_ONLY,
@@ -42,8 +42,8 @@ export default function HomePage(): ReactElement {
42
42
  // entries standing behind it. The lead is looked up among the top-level
43
43
  // entries so a folder keeps the count of what it holds, and falls back to
44
44
  // building an entry directly — the first document in governed order can sit
45
- // BELOW the top level, where `entriesUnder(null)` would never return it.
46
- const entries = entriesUnder(null);
45
+ // BELOW the top level, where the root's own listing would never return it.
46
+ const entries = entriesUnder("");
47
47
  const lead = entries.find((entry) => entry.url === first.url) ?? entryFor(first);
48
48
  const behind = entries.filter((entry) => entry.url !== lead.url).slice(0, 3);
49
49
 
@@ -1,4 +1,5 @@
1
1
  import { appName, mcpEndpoint, mcpNamespace, recordDescription, recordVersion } from "@/lib/shared";
2
+ import { readStageManifest } from "@/lib/stage-manifest";
2
3
 
3
4
  /**
4
5
  * `/.well-known/mcp/server.json` — how an agent DISCOVERS this record's MCP
@@ -16,6 +17,10 @@ import { appName, mcpEndpoint, mcpNamespace, recordDescription, recordVersion }
16
17
  * `name:` has no slash, so it was rejected outright.
17
18
  * version REQUIRED. Absent, the document failed validation on its own.
18
19
  * capabilities NOT a field in the schema; it was invented here.
20
+ * _meta the schema's extension point, namespaced by reverse DNS —
21
+ * where the build's stamps go (build spec §3, R14), so a
22
+ * validating client still accepts the document and a consumer
23
+ * can connect it to the publication it describes.
19
24
  *
20
25
  * (Checked against the 2025-12-11 schema, round-6 review of #43, which built
21
26
  * the scaffold and validated the emitted file.)
@@ -27,11 +32,16 @@ export const dynamic = "force-static";
27
32
 
28
33
  const SCHEMA = "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json";
29
34
 
35
+ /** The `_meta` key the stamps live under: a namespace this project controls. */
36
+ const META_KEY = "com.panaversity.ksor/build";
37
+
30
38
  export function GET(): Response {
31
39
  const endpoint = mcpEndpoint();
40
+ const { build_id, source_commit, dirty, ksor_version, unstamped } = readStageManifest().stamps;
32
41
  return Response.json(
33
42
  {
34
43
  $schema: SCHEMA,
44
+ _meta: { [META_KEY]: { build_id, source_commit, dirty, ksor_version, unstamped } },
35
45
  name: `${mcpNamespace()}/${appName}`,
36
46
  // The record's OWN account of itself — see recordDescription. A
37
47
  // description identical in every ksor record cannot help an agent choose
@@ -1,19 +1,31 @@
1
- import { entriesUnder, getSortedPages, markdownPath, source } from "@/lib/source";
1
+ import {
2
+ basePath,
3
+ entriesUnder,
4
+ folderHeading,
5
+ folderOfRoute,
6
+ folderSlugs,
7
+ getSortedPages,
8
+ markdownPath,
9
+ source,
10
+ } from "@/lib/source";
2
11
  import { RecordIndex } from "@/components/record-index";
3
12
  import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page";
4
13
  import { TOCPopover, TOCProvider } from "fumadocs-ui/layouts/docs/page/slots/toc";
5
14
  import { notFound } from "next/navigation";
6
15
  import { getMDXComponents } from "@/components/mdx";
7
16
  import type { Metadata } from "next";
8
- import { createRelativeLink } from "fumadocs-ui/mdx";
17
+ import type { ReactElement } from "react";
18
+ import { recordLink } from "@/lib/record-link";
9
19
  import {
20
+ DeprecatedNotice,
10
21
  GovernanceMeta,
22
+ LifecycleCaveat,
11
23
  Provenance,
12
- SupersededNotice,
13
24
  type Successor,
14
25
  } from "@/components/governance";
15
26
  import { predecessorsOf, readGovernance, resolveSuccessorUrl } from "@/lib/governance";
16
27
  import { showGovernance } from "@/lib/shared";
28
+ import { stagePageOf } from "@/lib/stage-manifest";
17
29
  import { RecordBreadcrumb } from "@/components/record-breadcrumb";
18
30
  import { RecordToc, TocItems } from "@/components/record-toc";
19
31
  import { RecordViews } from "@/components/record-views";
@@ -24,10 +36,53 @@ import { StudyAids } from "@/components/study-aids";
24
36
  import { deckFor, quizFor, slidesFor, summaryFor } from "@/lib/attachments";
25
37
  import { readingMinutes } from "@/lib/reading-time";
26
38
 
39
+ /** The route a slug names: `/docs`, or `/docs/a/b`. */
40
+ function routeOf(slug: readonly string[] | undefined): string {
41
+ return slug === undefined || slug.length === 0 ? "/docs" : `/docs/${slug.join("/")}`;
42
+ }
43
+
44
+ /**
45
+ * Every page reads `rel="describedby"`: the record describes itself in
46
+ * `llms.txt`, and a consumer that follows the link learns what this record is
47
+ * authoritative for before it reads a page of it (build spec §3).
48
+ */
49
+ function DescribedBy(): ReactElement {
50
+ return <link rel="describedby" href={`${basePath}/llms.txt`} type="text/markdown" />;
51
+ }
52
+
53
+ /**
54
+ * A directory's page: the regenerated `index.md` of this viewer's stage,
55
+ * rendered as a listing. A folder has no body and no governance of its own
56
+ * (record spec §1) — it is admitted iff a descendant is, and its page is the
57
+ * list of what it holds. Excluded from the docs collection, so it has no
58
+ * twin, no `llms.txt` line and no search entry.
59
+ */
60
+ function FolderPage({ dir }: { dir: string }): ReactElement {
61
+ return (
62
+ <TocItems items={[]}>
63
+ <DocsPage
64
+ toc={[]}
65
+ slots={{
66
+ breadcrumb: RecordBreadcrumb,
67
+ toc: { provider: TOCProvider, main: RecordToc, popover: TOCPopover },
68
+ }}
69
+ >
70
+ <DescribedBy />
71
+ <DocsTitle>{folderHeading(dir)}</DocsTitle>
72
+ <RecordIndex entries={entriesUnder(dir)} heading="In this section" />
73
+ </DocsPage>
74
+ </TocItems>
75
+ );
76
+ }
77
+
27
78
  export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
28
79
  const params = await props.params;
29
80
  const page = source.getPage(params.slug);
30
- if (!page) notFound();
81
+ if (!page) {
82
+ const dir = folderOfRoute(routeOf(params.slug));
83
+ if (dir === null) notFound();
84
+ return <FolderPage dir={dir} />;
85
+ }
31
86
 
32
87
  const MDX = page.data.body;
33
88
  // Attachments of THIS document, found by suffix on its own path. Null is the
@@ -39,40 +94,35 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
39
94
  const presentation = slidesFor(page.path);
40
95
  // Counted at BUILD time from the document's own markdown, so the figure is in
41
96
  // the shipped HTML for a reader with a failed bundle, a crawler and an agent
42
- // alike. The predecessor measured the rendered DOM after paint, which put it
43
- // out of reach of all three.
97
+ // alike.
44
98
  const minutes = readingMinutes(await page.data.getText("processed"));
45
99
  const summaryMinutes =
46
100
  summary === null ? null : readingMinutes(await summary.getText("processed"));
47
101
  // What the record says about this document. The page renders it; it never
48
- // supplies it — an undeclared key shows nothing (specs/ksor/site-governance).
102
+ // supplies it — an undeclared key shows nothing.
49
103
  const governance = readGovernance(page.data, page.path);
104
+ // What staging decided about it: the badge, and whether a twin exists.
105
+ const staged = stagePageOf(page.path);
106
+ const badge = staged?.badge ?? null;
107
+ const twin = staged?.machine === true ? markdownPath(page.url) : undefined;
50
108
 
109
+ const allPages = getSortedPages();
51
110
  let successor: Successor | null = null;
52
111
  // Gated on the STATUS, not on the pointer: a document the record calls
53
- // current must never be published under a "Superseded" banner, whatever
54
- // stale successor pointer it still carries (`pnpm check` refuses that
55
- // combination too this is the second lock on the same door).
56
- if (governance.status === "superseded" && governance.supersededBy !== null) {
57
- const pages = getSortedPages();
58
- // Against page.path, not page.url: a route cannot tell a file from a
59
- // folder index, and `./terms.md` means a different document in each.
60
- const href = resolveSuccessorUrl(governance.supersededBy, page.path, pages);
61
- // Name the successor by its title, not by its path: the notice is for a
112
+ // current must never be published under a "Deprecated" banner, whatever
113
+ // stale successor pointer it still carries.
114
+ if (governance.status === "deprecated" && governance.supersededBy !== null) {
115
+ const href = resolveSuccessorUrl(governance.supersededBy, allPages);
116
+ // Name the successor by its title, not by its id: the notice is for a
62
117
  // reader, and the pointer is only the fallback when the route did not
63
118
  // resolve — never a dead link.
64
- const target = href === null ? undefined : pages.find((c) => c.url === href.split("#")[0]);
119
+ const target = href === null ? undefined : allPages.find((c) => c.url === href.split("#")[0]);
65
120
  successor = { href, label: target?.data.title ?? governance.supersededBy };
66
121
  }
67
122
 
68
123
  // What this document replaced, derived by asking every document in the record
69
124
  // where its successor pointer lands (research/site-design.md F4). No new
70
125
  // frontmatter key: the record already says it, in the other direction.
71
- // The same address `generateMetadata` advertises, shown to a person as well:
72
- // the human surface handing an agent the record's own bytes is a better
73
- // demonstration of the product than any copy on the home page.
74
- const markdownUrl = markdownPath(page.url);
75
- const allPages = getSortedPages();
76
126
  const replaces = predecessorsOf(
77
127
  page.url,
78
128
  allPages,
@@ -91,16 +141,9 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
91
141
  toc={page.data.toc}
92
142
  full={page.data.full}
93
143
  // The rail is ours; the provider and the small-screen popover stay the
94
- // shell's. Its own rail marks a heading active when 90% of it is visible
95
- // anywhere in the viewport and then highlights whichever became active
96
- // last, which on this record's short-sectioned documents ran two to four
97
- // headings AHEAD of the reader. The observer's options are not
98
- // configurable and the observer is not exported, so the selection could
99
- // only be replaced — `slots.toc.main` is the seam for that.
100
- // The breadcrumb is ours for one reason, recorded in the component:
101
- // the shell's renders nothing at all on a top-level document, so the
102
- // block above the title came and went as a reader moved through the
103
- // record.
144
+ // shell's (its own rail ran two to four headings ahead of the reader on
145
+ // this record's short-sectioned documents). The breadcrumb is ours
146
+ // because the shell's renders nothing on a top-level document.
104
147
  slots={{
105
148
  breadcrumb: RecordBreadcrumb,
106
149
  toc: {
@@ -110,91 +153,68 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
110
153
  },
111
154
  }}
112
155
  // The table-of-contents column is HELD on every page, including the many
113
- // in a governed record that have no headings at all and render nothing
114
- // into it. Collapsing it for those documents (which this file did, from
115
- // 2026-08-21) bought a wider column at the price of a moving one: the
116
- // article is centred in whatever the column leaves, so the prose jumped
117
- // 134px sideways between a document with headings and one without
118
- // (measured at 1728px: text at x=446 against x=580). A reader clicking
119
- // through a record saw the page slide under them.
120
- //
121
- // Held, the grid is `0 | 268 | main | 268 | 0` — sidebar and rail the
122
- // same width, so the main column is centred in the viewport and the
123
- // reading measure capped in global.css sits centred inside it, in the
124
- // same place on every document. The rail is the natural home for the
125
- // governance facts this record already carries; until it holds them it
126
- // is quiet space on the side, which is what the earlier collapse was
127
- // really objecting to — the 900px measure beside it, since fixed.
156
+ // in a governed record that have no headings, so the prose sits in the
157
+ // same place on every document (measured 134px of drift without it).
128
158
  >
129
- {successor === null ? null : <SupersededNotice successor={successor} />}
159
+ <DescribedBy />
160
+ {governance.status === "deprecated" ? <DeprecatedNotice successor={successor} /> : null}
130
161
  <DocsTitle>{page.data.title}</DocsTitle>
131
162
  <DocsDescription>{page.data.description}</DocsDescription>
132
163
  {showGovernance ? (
133
164
  <GovernanceMeta
134
165
  governance={governance}
166
+ badge={badge}
135
167
  replaces={replaces}
136
- markdownUrl={markdownUrl}
168
+ markdownUrl={twin}
137
169
  // Only when there is no view strip to carry it — with a summary,
138
170
  // the number belongs to the view the reader picked, not to the
139
171
  // document as a whole.
140
172
  minutes={summary === null ? minutes : undefined}
141
173
  />
142
- ) : null}
143
- {/* grow-0, against the shell's own `flex-1`: the article is a flex column
144
- stretched to the viewport, so the body inflated from ~150px of text to
145
- 402px and pushed Sources and everything after it to the bottom of the
146
- screen a governance block floating 400px below the document it
147
- describes (measured, 2026-08-21). Short documents now end where their
148
- text ends. */}
174
+ ) : (
175
+ // …and with the strip off, the CAVEAT still shows. `site.governance`
176
+ // hides attribution, not the record's word on whether this document
177
+ // is in force which the sidebar, the folder listing and the search
178
+ // result all keep saying about this same page (`plainBadge`).
179
+ <LifecycleCaveat badge={badge} effectiveFrom={governance.effectiveFrom} />
180
+ )}
181
+ {/* grow-0, against the shell's own `flex-1`: short documents end where
182
+ their text ends rather than pushing Sources to the bottom of the
183
+ screen (measured, 2026-08-21). */}
149
184
  <DocsBody style={{ flexGrow: 0 }}>
150
185
  {/* The summary panel is built HERE, on the server, and handed to the
151
186
  client tab strip as a prop — so it is in the shipped HTML whether or
152
- not the bundle runs, which is what an agent parsing the page and a
153
- reader with a failed bundle both depend on. Presence-driven: with no
154
- summary, RecordViews renders the body alone and no tab strip exists
155
- (specs/ksor/study-attachments C3, C20). */}
187
+ not the bundle runs. Presence-driven: with no summary, RecordViews
188
+ renders the body alone and no tab strip exists. */}
156
189
  <RecordViews
157
190
  documentMinutes={minutes}
158
191
  summaryMinutes={summaryMinutes ?? undefined}
159
192
  summary={
160
193
  Summary === null ? null : (
161
- <Summary components={getMDXComponents({ a: createRelativeLink(source, page) })} />
194
+ <Summary components={getMDXComponents({ a: recordLink(source, page) })} />
162
195
  )
163
196
  }
164
197
  >
165
198
  <MDX
166
199
  components={getMDXComponents({
167
- // relative links between documents in knowledge/ resolve to
168
- // their rendered pages
169
- a: createRelativeLink(source, page),
200
+ // links between documents in knowledge/, in both OKF forms,
201
+ // resolve to their rendered pages
202
+ a: recordLink(source, page),
170
203
  // The deck, rendered where the record's own shape puts it:
171
- // after the introduction, before the first section. The
172
- // rehype plugin marks the place on every document and this
173
- // decides whether there is anything to put there — so "does
174
- // this document have a teaching aid" stays one question,
175
- // answered by the attachment, not two.
204
+ // after the introduction, before the first section.
176
205
  TeachingAid: () =>
177
206
  presentation === null ? null : <Slides slides={presentation} />,
178
207
  })}
179
208
  />
180
209
  </RecordViews>
181
210
  </DocsBody>
182
- {/* What a reader DOES with this document once they have read it. One
183
- region, so the quiz that will sit beside the deck is a child here and
184
- not a new argument about where it goes. Renders nothing at all when
185
- the document has no study aids. */}
186
- {/* Recall first, then the check on it — and each renders only if the
187
- document carries one, so a page with just a quiz shows just a quiz. */}
211
+ {/* What a reader DOES with this document once they have read it.
212
+ Renders nothing at all when the document has no study aids. */}
188
213
  <StudyAids>
189
214
  {deck === null ? null : <Flashcards deck={deck} />}
190
215
  {quiz === null ? null : <Quiz quiz={quiz} />}
191
216
  </StudyAids>
192
- {/* A folder's index page lists what the folder holds. Without it the
193
- page ended at its own sentence and the documents below it were
194
- reachable only from the sidebar (research/site-design.md F5). Empty
195
- for a leaf document, which renders nothing. */}
196
- <RecordIndex entries={entriesUnder(page.url)} heading="In this section" />
197
- {showGovernance ? <Provenance entries={governance.provenance} /> : null}
217
+ {showGovernance ? <Provenance entries={governance.sources} /> : null}
198
218
  </DocsPage>
199
219
  </TocItems>
200
220
  );
@@ -202,29 +222,44 @@ export default async function Page(props: PageProps<"/docs/[[...slug]]">) {
202
222
 
203
223
  export async function generateStaticParams() {
204
224
  const params = source.generateParams();
205
- if (params.length === 0) {
225
+ // Every directory this viewer's stage holds an index for is a page too —
226
+ // the root included, which is the record's own map.
227
+ const folders = folderSlugs().map((slug) => ({ slug }));
228
+ if (params.length === 0 && folders.length === 0) {
206
229
  // Without this, Next fails the empty-record build with an error that
207
230
  // names neither the record nor the rule (found live, 2026-08-18).
231
+ //
232
+ // NOT the same state as "this build publishes no document". The emitted
233
+ // starter is all drafts by design (R25), and build spec §4 acceptance 4
234
+ // requires it to BUILD and publish none of them — so a stage with the root
235
+ // index and no page is a record waiting for its first approval, and
236
+ // throwing here failed `pnpm build` on every freshly scaffolded project
237
+ // (found live 2026-08-25 through the scaffold e2e). What is left is the
238
+ // genuinely impossible state: no page AND no index, which means the stage
239
+ // itself is missing.
208
240
  throw new Error(
209
- "the record has no documents a KSoR is never empty; add one to knowledge/ or restore one from git history (pnpm check says the same).",
241
+ "the staged record holds neither a document nor a folder index the stage is missing or empty; run `ksor build` and check knowledge/ (`pnpm check` says the same).",
210
242
  );
211
243
  }
212
- return params;
244
+ return [...params, ...folders];
213
245
  }
214
246
 
215
247
  export async function generateMetadata(props: PageProps<"/docs/[[...slug]]">): Promise<Metadata> {
216
248
  const params = await props.params;
217
249
  const page = source.getPage(params.slug);
218
- if (!page) notFound();
219
-
220
- // The markdown twin, advertised rather than left to be guessed: a consumer
221
- // that follows `rel="alternate"` reaches the record's own bytes instead of
222
- // scraping this page (research/site-design.md F2).
223
- const markdownUrl = markdownPath(page.url);
250
+ if (!page) {
251
+ const dir = folderOfRoute(routeOf(params.slug));
252
+ if (dir === null) notFound();
253
+ return { title: folderHeading(dir) };
254
+ }
224
255
 
256
+ // The markdown twin, advertised rather than left to be guessed — and only
257
+ // where one exists: a page the machine surfaces decline has no twin, and
258
+ // advertising one would be a dead link (build spec §3).
259
+ const twin = stagePageOf(page.path)?.machine === true;
225
260
  return {
226
261
  title: page.data.title,
227
262
  description: page.data.description,
228
- alternates: { types: { "text/markdown": markdownUrl } },
263
+ ...(twin ? { alternates: { types: { "text/markdown": markdownPath(page.url) } } } : {}),
229
264
  };
230
265
  }
@@ -262,7 +262,7 @@ html > body[data-scroll-locked] {
262
262
  /* The caution colour is a TOKEN, declared as a light/dark pair on the root
263
263
  rather than inside the one class that first used it. Every surface that has
264
264
  to say "the record withdrew this" now spends the same colour — the banner
265
- over a superseded document, and the chip that marks it in the sidebar, the
265
+ over a deprecated document, and the chip that marks it in the sidebar, the
266
266
  listings, the front door and search. Declared in both themes deliberately:
267
267
  a colour written only on `:root` leaks into dark, because `:root` and
268
268
  `.dark` have equal specificity and whichever comes later wins. */
@@ -489,10 +489,10 @@ html > body[data-scroll-locked] {
489
489
  }
490
490
 
491
491
  /* A withdrawn document's status chip, wherever one renders. A `draft` chip and
492
- a `superseded` chip were pixel-identical — the same hairline border and the
492
+ a `deprecated` chip were pixel-identical — the same hairline border and the
493
493
  same muted text — so the two statuses that mean the most different things
494
494
  looked the same at the moment a reader picks between two documents. The
495
- colour is additive, never the whole signal: the word "superseded" is beside
495
+ colour is additive, never the whole signal: the word "deprecated" is beside
496
496
  it in every one of these places (GOV.UK's rule — never rely on colour alone).
497
497
  Unlayered, so it beats the Tailwind utility that sets the chip's border and
498
498
  text colour; the banner class above has always worked this way. */
@@ -519,8 +519,16 @@ html > body[data-scroll-locked] {
519
519
  }
520
520
 
521
521
  /* …and in search too, where a reader is choosing between results. The chip is
522
- drawn by `content`, so the tone has to be set on the same pseudo-element. */
523
- [data-ksor-status="superseded"]::after {
522
+ drawn by `content`, so the tone has to be set on the same pseudo-element.
523
+
524
+ found live 2026-08-25: this selector still read `superseded`, the PRE-PROFILE
525
+ status word. Under the profile the withdrawn state is `deprecated` (record
526
+ spec §2.2), so it matched nothing and a withdrawn document's search row wore
527
+ the ordinary grey chip — on the one surface whose snippet quotes the
528
+ withdrawn figure, which is why the rule exists. The value here is
529
+ `lifecycleBadge`'s, not `status`'s; the two agree on this word and the badge
530
+ is what the map carries. */
531
+ [data-ksor-status="deprecated"]::after {
524
532
  border-color: color-mix(in oklab, var(--ksor-caution) 45%, transparent);
525
533
  color: var(--ksor-caution);
526
534
  }
@@ -2,7 +2,8 @@ import { RootProvider } from "fumadocs-ui/provider/next";
2
2
  import "./global.css";
3
3
  import type { Metadata } from "next";
4
4
  import { appTitle } from "@/lib/shared";
5
- import { basePath, caveatStatusByUrl } from "@/lib/source";
5
+ import { basePath, badgeByUrl } from "@/lib/source";
6
+ import { readStageManifest } from "@/lib/stage-manifest";
6
7
  import KsorSearchDialog from "@/components/search-dialog";
7
8
 
8
9
  // No next/font/google: it fetches the face from Google at BUILD time, so a
@@ -17,13 +18,17 @@ export const metadata: Metadata = {
17
18
  template: `%s | ${appTitle}`,
18
19
  },
19
20
  description: "The Knowledge System of Record for humans and AI agents.",
21
+ // A build that shows drafts (`KSOR_DRAFTS=show`) is a preview, and a static
22
+ // site's pages are open-web artefacts: it says so to every crawler rather
23
+ // than letting a draft be indexed under the record's name (build spec §3).
24
+ ...(readStageManifest().drafts === "shown" ? { robots: { index: false, follow: false } } : {}),
20
25
  };
21
26
 
22
27
  export default function Layout({ children }: LayoutProps<"/">) {
23
28
  return (
24
29
  <html lang="en" suppressHydrationWarning>
25
30
  <body className="flex flex-col min-h-screen">
26
- {/* Which documents carry a caveat status, for the search dialog — it
31
+ {/* Which documents carry a badge, for the search dialog — it
27
32
  runs in the browser over a static index that has no field for it.
28
33
  Delivered in the document rather than as a dialog prop because
29
34
  RootProvider types `options` against the SHIPPED dialog's props, and
@@ -34,7 +39,7 @@ export default function Layout({ children }: LayoutProps<"/">) {
34
39
  type="application/json"
35
40
  id="ksor-statuses"
36
41
  dangerouslySetInnerHTML={{
37
- __html: JSON.stringify(caveatStatusByUrl()).replaceAll("<", "\\u003c"),
42
+ __html: JSON.stringify(badgeByUrl()).replaceAll("<", "\\u003c"),
38
43
  }}
39
44
  />
40
45
  <RootProvider
@@ -1,12 +1,18 @@
1
- import { getLLMText, getSortedPages } from "@/lib/source";
1
+ import { getLLMText, getMachinePages } from "@/lib/source";
2
+ import { stampLines } from "@/lib/governance";
3
+ import { appName, appTitle } from "@/lib/shared";
4
+ import { readStageManifest } from "@/lib/stage-manifest";
2
5
 
3
6
  export const revalidate = false;
4
7
 
8
+ /**
9
+ * The whole machine-admitted record in one file, opened by the stamps that
10
+ * connect it to one publication (R14) — so a consumer holding a copy can say
11
+ * which build it came from — then every document with its own governance
12
+ * block, exactly as its twin serves it.
13
+ */
5
14
  export async function GET(): Promise<Response> {
6
- // The whole set is threaded through so each document's successor pointer
7
- // resolves to a route rather than the `./x.md` a consumer cannot follow.
8
- const pages = getSortedPages();
9
- const scanned = await Promise.all(pages.map((page) => getLLMText(page, pages)));
10
-
11
- return new Response(scanned.join("\n\n"));
15
+ const head = `---\ntitle: ${JSON.stringify(appTitle)}\nname: ${appName}\n${stampLines(readStageManifest().stamps).join("\n")}\n---`;
16
+ const scanned = getMachinePages().map((page) => getLLMText(page));
17
+ return new Response([head, ...scanned].join("\n\n"));
12
18
  }
@@ -3,15 +3,20 @@ import { recordIndexText } from "@/lib/source";
3
3
  export const revalidate = false;
4
4
 
5
5
  // The agent-facing index of the record: this instance's name, then every
6
- // document in sidebar order, each link usable as-is on a sub-path host — and
7
- // each carrying its governance when the governance is a caveat.
6
+ // document a MACHINE surface may carry, in sidebar order, each link usable
7
+ // as-is on a sub-path host.
8
8
  //
9
- // Without that last part a withdrawn document and the one that replaced it are
10
- // two adjacent entries told apart only by whatever a human happened to type
11
- // into a title, and an agent picks either (research/site-design.md F1).
9
+ // There is no caveat marker any more, because there is nothing to mark: the
10
+ // §2.5 table admits only stable, effective, unexpired, undenied concepts here,
11
+ // so a draft, a deprecated document and one past its `stale_after` are not
12
+ // entries at all. That replaced marking with exclusion — the older worry was a
13
+ // withdrawn document and its replacement sitting adjacent, told apart only by
14
+ // whatever a human typed into a title (research/site-design.md F1), and an
15
+ // agent picking either.
12
16
  //
13
- // The bytes are built in lib/source (`recordIndexText`) because the home page
14
- // shows this same index to a reader one index, one spelling.
17
+ // The set is decided ONCE, by staging, and read back here: the bytes are built
18
+ // in lib/source (`recordIndexText`) because the home page shows this same index
19
+ // to a reader — one index, one spelling.
15
20
  export function GET(): Response {
16
21
  return new Response(recordIndexText());
17
22
  }
@@ -1,36 +1,38 @@
1
1
  import { notFound } from "next/navigation";
2
2
 
3
- import { getLLMText, getSortedPages, source } from "@/lib/source";
3
+ import { getLLMText, getMachinePages, rootIndexTwin, source } from "@/lib/source";
4
+ import { machineAdmits } from "@/lib/stage-manifest";
4
5
 
5
6
  export const revalidate = false;
6
7
 
7
8
  /**
8
- * Every document as markdown, at a stable address derived from its path:
9
- * `/docs/policies/purchase-approval` is also served at
10
- * `/md/policies/purchase-approval.md`.
9
+ * Every MACHINE-admitted document as markdown, at a stable address derived
10
+ * from its path: `/docs/policies/purchase-approval` is also served at
11
+ * `/md/policies/purchase-approval.md`, and the record's own index at
12
+ * `/md/index.md` — the one index with a twin (build spec §3).
11
13
  *
12
14
  * The record already IS markdown; without this an agent handed a document URL
13
15
  * had to scrape a React app to reach text the record holds verbatim
14
- * (research/site-design.md F2). The body carries the document's governance as
15
- * frontmatter, exactly as `llms-full.txt` does, so a consumer reading one
16
- * document knows its status, owner, sources and successor.
16
+ * (research/site-design.md F2). The body carries the document's governance and
17
+ * the build's stamps as frontmatter, exactly as `llms-full.txt` does.
17
18
  *
18
- * Why a `/md/` prefix rather than appending `.md` to the document's own URL,
19
- * which is the convention the field has settled on: under `output: "export"` a
20
- * Route Handler cannot share a route segment with a Page, and there is no
21
- * middleware to rewrite one onto the other. The prefix is the shape that
19
+ * A page the machine surfaces decline a draft, a deprecated, a not-yet-
20
+ * effective or a stale concept (record spec §2.5) — has no twin at all: the
21
+ * decision is staging's, read back here, never re-derived.
22
+ *
23
+ * Why a `/md/` prefix rather than appending `.md` to the document's own URL:
24
+ * under `output: "export"` a Route Handler cannot share a route segment with a
25
+ * Page, and there is no middleware to rewrite one onto the other. The prefix
22
26
  * survives a static host, and the page advertises it in a `rel="alternate"`
23
- * link so a consumer discovers it rather than guessing. Appending `.md` to the
24
- * canonical URL becomes possible the day a build emits these artifacts itself.
27
+ * link so a consumer discovers it rather than guessing.
25
28
  */
26
29
  export function generateStaticParams(): { slug: string[] }[] {
27
- return source.generateParams().map(({ slug }) => {
28
- const segments = slug ?? [];
29
- const last = segments.at(-1);
30
- return {
31
- slug: last === undefined ? ["index.md"] : [...segments.slice(0, -1), `${last}.md`],
32
- };
30
+ const twins = getMachinePages().map((page) => {
31
+ const segments = page.url.replace(/^\/docs\/?/, "").split("/");
32
+ const last = segments.at(-1) ?? "";
33
+ return { slug: [...segments.slice(0, -1), `${last}.md`] };
33
34
  });
35
+ return [{ slug: ["index.md"] }, ...twins];
34
36
  }
35
37
 
36
38
  export async function GET(
@@ -40,12 +42,11 @@ export async function GET(
40
42
  const { slug = [] } = await params;
41
43
  const last = slug.at(-1);
42
44
  if (last === undefined || !last.endsWith(".md")) notFound();
43
- const docSlug = last === "index.md" ? [] : [...slug.slice(0, -1), last.slice(0, -".md".length)];
45
+ const headers = { "content-type": "text/markdown; charset=utf-8" };
46
+ if (slug.length === 1 && last === "index.md") return new Response(rootIndexTwin(), { headers });
44
47
 
45
- const page = source.getPage(docSlug);
46
- if (!page) notFound();
48
+ const page = source.getPage([...slug.slice(0, -1), last.slice(0, -".md".length)]);
49
+ if (!page || !machineAdmits(page.path)) notFound();
47
50
 
48
- return new Response(await getLLMText(page, getSortedPages()), {
49
- headers: { "content-type": "text/markdown; charset=utf-8" },
50
- });
51
+ return new Response(getLLMText(page), { headers });
51
52
  }