@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
@@ -13,10 +13,11 @@ import { audienceNotice } from "@/lib/audience";
13
13
  * carry the shape of a disclosure.
14
14
  */
15
15
  export function FooterMark(): ReactElement {
16
- if (audienceNotice === null) return <BuiltWith />;
16
+ const notice = audienceNotice();
17
+ if (notice === null) return <BuiltWith />;
17
18
  return (
18
19
  <>
19
- <BuiltWith /> &middot; <span className="text-fd-muted-foreground">{audienceNotice}</span>
20
+ <BuiltWith /> &middot; <span className="text-fd-muted-foreground">{notice}</span>
20
21
  </>
21
22
  );
22
23
  }
@@ -5,18 +5,25 @@ import { Clock } from "lucide-react";
5
5
  import type { ReactElement } from "react";
6
6
 
7
7
  import {
8
- caveatStatus,
9
- statusTone,
8
+ badgeAddsToStatus,
9
+ badgeText,
10
+ badgeTone,
11
+ dayOf,
10
12
  isCalendarDate,
13
+ plainBadge,
11
14
  sourceHref,
15
+ statusLabel,
16
+ statusTone,
17
+ trustSignal,
12
18
  type DocumentGovernance,
13
19
  } from "@/lib/governance";
20
+ import type { LifecycleBadge } from "@/lib/lifecycle-rule";
14
21
 
15
22
  /**
16
23
  * What the record says about the document you are reading.
17
24
  *
18
25
  * The site renders the record; these render the record's governance — the
19
- * frontmatter `pnpm check` enforces on every document. Nothing here is
26
+ * profile's frontmatter `pnpm check` enforces on every concept. Nothing here is
20
27
  * authored in the site (critical rule 1) and nothing is inferred: a key the
21
28
  * document does not declare renders nothing at all.
22
29
  *
@@ -27,9 +34,9 @@ import {
27
34
  */
28
35
 
29
36
  /**
30
- * The successor of a superseded document: its route and the text naming it.
31
- * `href` is null when the pointer could not be resolved to a route, and then
32
- * the pointer itself is shown as text rather than as a dead link.
37
+ * The successor of a deprecated document: its route and the text naming it.
38
+ * `href` is null when the pointer could not be resolved to a route in this
39
+ * build, and then the pointer itself is shown as text rather than as a dead link.
33
40
  */
34
41
  export interface Successor {
35
42
  readonly href: string | null;
@@ -37,11 +44,12 @@ export interface Successor {
37
44
  }
38
45
 
39
46
  /**
40
- * The supersession notice. Deliberately the first thing on the page, above the
47
+ * The deprecation notice. Deliberately the first thing on the page, above the
41
48
  * title: a reader must not have to notice a subtle badge to learn that what
42
- * they are about to read has been replaced.
49
+ * they are about to read has been withdrawn. `successor` is null when the
50
+ * record deprecated the document without naming a replacement.
43
51
  */
44
- export function SupersededNotice({ successor }: { successor: Successor }): ReactElement {
52
+ export function DeprecatedNotice({ successor }: { successor: Successor | null }): ReactElement {
45
53
  return (
46
54
  <aside
47
55
  // A landmark, not a note: GOV.UK ships this as role="region" with
@@ -49,7 +57,7 @@ export function SupersededNotice({ successor }: { successor: Successor }): React
49
57
  // thing on the page by landmark, rather than only meeting it in reading
50
58
  // order. role="note" is announced but not navigable.
51
59
  role="region"
52
- aria-labelledby="ksor-superseded"
60
+ aria-labelledby="ksor-deprecated"
53
61
  // Tinted and ruled down the left edge in the CAUTION role, never
54
62
  // --color-fd-muted: the shipped light theme defines --color-fd-muted and
55
63
  // --color-fd-background as the same value, so the callout composited to
@@ -57,22 +65,29 @@ export function SupersededNotice({ successor }: { successor: Successor }): React
57
65
  // Chromium, 2026-08-20). The one thing this notice cannot be is missable.
58
66
  className="ksor-caution mb-8 rounded-lg border border-l-4 px-4 py-3 text-sm"
59
67
  >
60
- <p id="ksor-superseded" className="font-medium text-fd-foreground">
61
- Superseded
68
+ <p id="ksor-deprecated" className="font-medium text-fd-foreground">
69
+ Deprecated
62
70
  </p>
63
71
  <p className="mt-1 text-fd-muted-foreground">
64
- This document has been replaced by{" "}
65
- {successor.href === null ? (
66
- <code className="break-words text-fd-foreground">{successor.label}</code>
72
+ {successor === null ? (
73
+ "The record has withdrawn this document."
67
74
  ) : (
68
- <Link
69
- href={successor.href}
70
- className="font-medium text-fd-foreground underline underline-offset-4 transition-colors hover:text-fd-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
71
- >
72
- {successor.label}
73
- </Link>
74
- )}
75
- . It is kept because the record never deletes what it replaces.
75
+ <>
76
+ This document has been replaced by{" "}
77
+ {successor.href === null ? (
78
+ <code className="break-words text-fd-foreground">{successor.label}</code>
79
+ ) : (
80
+ <Link
81
+ href={successor.href}
82
+ className="font-medium text-fd-foreground underline underline-offset-4 transition-colors hover:text-fd-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
83
+ >
84
+ {successor.label}
85
+ </Link>
86
+ )}
87
+ .
88
+ </>
89
+ )}{" "}
90
+ It is kept because the record never deletes what it replaces.
76
91
  </p>
77
92
  </aside>
78
93
  );
@@ -89,13 +104,10 @@ function Fact({
89
104
  <div className="flex items-baseline gap-2.5">
90
105
  {/* Mono, uppercase, letterspaced: these are the record's checkable facts,
91
106
  and they read as a register's column heads rather than as a form.
92
-
93
107
  The label is deliberately SMALLER and more letterspaced than the value
94
- it introduces. Both used to be mono a single pixel apart — 11px label
95
- against a 12px value — so "Owner Product Effective 2026-08-22" read as
96
- one undifferentiated mono run rather than as two facts with names
97
- (reported by the owner, 2026-08-22, quoting the run back verbatim).
98
- The step is now 10px against 13px, and the value carries the weight. */}
108
+ it introduces (10px against 13px), so "Owner Product Effective
109
+ 2026-08-22" reads as two facts with names rather than one mono run
110
+ (reported by the owner, 2026-08-22). */}
99
111
  <dt className="font-mono text-[0.625rem] tracking-[0.18em] text-fd-muted-foreground uppercase">
100
112
  {label}
101
113
  </dt>
@@ -106,20 +118,82 @@ function Fact({
106
118
  );
107
119
  }
108
120
 
121
+ /** One chip: a word the record says about this document, in the register's voice. */
122
+ function Chip({ text, tone = "" }: { text: string; tone?: string }): ReactElement {
123
+ return (
124
+ <span
125
+ className={`rounded-sm border border-fd-border px-1.5 py-0.5 tracking-widest whitespace-nowrap uppercase ${tone}`}
126
+ >
127
+ {text}
128
+ </span>
129
+ );
130
+ }
131
+
132
+ /** The chip a badge wears, in every listing and on the page. */
133
+ export function BadgeChip({
134
+ badge,
135
+ effectiveFrom = null,
136
+ }: {
137
+ badge: LifecycleBadge;
138
+ /** `ksor.effective_from`, which fills record spec §2.5's "effective from …". */
139
+ effectiveFrom?: string | null;
140
+ }): ReactElement {
141
+ return <Chip text={badgeText(badge, effectiveFrom) ?? ""} tone={badgeTone(badge)} />;
142
+ }
143
+
144
+ /**
145
+ * The lifecycle caveat ALONE, for a page with no governance strip under its
146
+ * title — a record whose `site.governance` is off.
147
+ *
148
+ * That key turns off attribution, not caveats: the deprecation notice has
149
+ * always survived it, and the sidebar row, the folder card and the search
150
+ * result for this same document carry their badge whatever it says. The rule
151
+ * for which states reach here is `plainBadge`, beside the vocabulary itself.
152
+ *
153
+ * Typed like the strip's own values (mono, 13px), because a badge is the
154
+ * record speaking about a document and that is the voice it speaks in
155
+ * everywhere else.
156
+ */
157
+ export function LifecycleCaveat({
158
+ badge,
159
+ effectiveFrom = null,
160
+ }: {
161
+ badge: LifecycleBadge | null;
162
+ effectiveFrom?: string | null;
163
+ }): ReactElement | null {
164
+ const caveat = plainBadge(badge);
165
+ if (caveat === null) return null;
166
+ return (
167
+ <div className="mb-7 font-mono text-[0.8125rem] font-medium text-fd-foreground">
168
+ <BadgeChip badge={caveat} effectiveFrom={effectiveFrom} />
169
+ </div>
170
+ );
171
+ }
172
+
109
173
  /**
110
- * The one-line governance strip under the document's title: any caveat on its
111
- * status, who stands behind it, and when it took effect.
174
+ * The one-line governance strip under the document's title: what the record
175
+ * says about this document, and who said it.
176
+ *
177
+ * Two kinds of thing sit here and they are drawn differently on purpose. The
178
+ * CHIPS are states — the lifecycle status, and the date badge when the calendar
179
+ * keeps an otherwise current document off the machine surfaces (record spec
180
+ * §2.5) — and the FACTS are attributions: who owns it, who approved it, who
181
+ * verified it and when. A reader deciding whether to act on a document needs
182
+ * both halves, and the predecessor's failure was showing neither.
112
183
  */
113
184
  export function GovernanceMeta({
114
185
  governance,
186
+ badge,
115
187
  replaces = [],
116
188
  markdownUrl,
117
189
  minutes,
118
190
  }: {
119
191
  governance: DocumentGovernance;
192
+ /** Why the machine surfaces decline this page, or null. */
193
+ badge: LifecycleBadge | null;
120
194
  /** Documents this one replaced — derived from the record, never declared. */
121
195
  replaces?: readonly Successor[];
122
- /** The document's markdown twin, offered beside its governance. */
196
+ /** The document's markdown twin, offered beside its governance — only where one exists. */
123
197
  markdownUrl?: string;
124
198
  /**
125
199
  * How long the document takes to read, when this row is the only place for
@@ -127,30 +201,80 @@ export function GovernanceMeta({
127
201
  * because there the number belongs to the view you picked.
128
202
  */
129
203
  minutes?: number;
130
- }): ReactElement | null {
131
- const { owner, effective } = governance;
132
- const status = caveatStatus(governance.status);
133
- const bare = status === null && owner === null && effective === null && replaces.length === 0;
134
- if (bare && markdownUrl === undefined && minutes === undefined) return null;
204
+ }): ReactElement {
205
+ const { status, owner, effectiveFrom, staleAfter, approval, deprecated } = governance;
206
+ const state = statusLabel(status);
207
+ // The badge is a SECOND chip only where it says something the status does
208
+ // not: `draft` and `deprecated` are both words, and printing either twice
209
+ // reads as two facts about one document.
210
+ const alsoBadge = badgeAddsToStatus(badge, status) ? badge : null;
211
+ const trust = trustSignal(governance.verified);
212
+ // …and where the badge carries the date, the fact beside it would repeat it.
213
+ const showEffective = effectiveFrom !== null && alsoBadge !== "effective-from";
135
214
 
215
+ // There is no "nothing to show" case any more: every concept has a trust
216
+ // tier, `unverified` included, and that is the whole point of printing it.
217
+ // The early return this replaced would have hidden the tier on exactly the
218
+ // documents whose tier is the only governance fact they have.
136
219
  return (
137
220
  <dl className="mb-7 flex flex-wrap items-baseline gap-x-8 gap-y-2.5 border-b border-fd-border pb-4">
138
- {status === null ? null : (
221
+ {state === null && alsoBadge === null ? null : (
139
222
  <Fact label="Status">
140
- <span
141
- className={`rounded-sm border border-fd-border px-1.5 py-0.5 tracking-widest uppercase ${statusTone(status)}`}
142
- >
143
- {status}
223
+ <span className="flex flex-wrap items-baseline gap-1.5">
224
+ {state === null ? null : <Chip text={state} tone={statusTone(status)} />}
225
+ {alsoBadge === null ? null : (
226
+ <BadgeChip badge={alsoBadge} effectiveFrom={effectiveFrom} />
227
+ )}
144
228
  </span>
145
229
  </Fact>
146
230
  )}
231
+ {/* The tier OKF's own vocabulary names, on every document including the
232
+ unverified ones — that is the honest state of a stable, approved
233
+ concept nobody has reviewed, and hiding it would leave a reader unable
234
+ to tell "checked" from "never mentioned" (research/okf-native.md
235
+ §1.1). Never a colour: a tier is a fact about review, not a warning. */}
236
+ <Fact label="Trust">
237
+ <span className="flex flex-wrap items-baseline gap-1.5">
238
+ <Chip text={trust.tier} />
239
+ {trust.by === null ? null : (
240
+ <span className="font-normal text-fd-muted-foreground">
241
+ {trust.by}
242
+ {trust.at === null ? null : <> · {day(trust.at)}</>}
243
+ </span>
244
+ )}
245
+ </span>
246
+ </Fact>
147
247
  {owner === null ? null : <Fact label="Owner">{owner}</Fact>}
248
+ {/* Who let this into the record. `ksor.approval` is what makes a `stable`
249
+ document stable at all (record spec §2.2), so a page that showed the
250
+ word and not the signature would be publishing the claim without its
251
+ author. */}
252
+ {approval === null ? null : (
253
+ <Fact label="Approved">
254
+ <>
255
+ {approval.by} · {day(approval.at)}
256
+ </>
257
+ </Fact>
258
+ )}
259
+ {/* found live 2026-08-25: a deprecated page named its successor and said
260
+ nothing about WHO withdrew it, though `ksor.deprecated` is required on
261
+ every deprecated concept (record spec §2.2) and readGovernance already
262
+ refuses a document that omits it. Withdrawal is the most consequential
263
+ act in a document's life; publishing it unattributed is exactly the
264
+ gap the approver fact above closes at the other end. */}
265
+ {deprecated === null ? null : (
266
+ <Fact label="Withdrawn">
267
+ <>
268
+ {deprecated.by} · {day(deprecated.at)}
269
+ </>
270
+ </Fact>
271
+ )}
148
272
  {replaces.length === 0 ? null : (
149
273
  // The other half of a supersession. The withdrawn document names its
150
274
  // successor above the title; this is the successor naming what it
151
275
  // replaced, so the history the record kept is reachable from the
152
276
  // current document instead of only from the retired one.
153
- <Fact label={replaces.length === 1 ? "Replaces" : "Replaces"}>
277
+ <Fact label="Replaces">
154
278
  <>
155
279
  {replaces.map((entry, index) => (
156
280
  <span key={entry.href ?? `${index}-${entry.label}`}>
@@ -170,39 +294,15 @@ export function GovernanceMeta({
170
294
  </>
171
295
  </Fact>
172
296
  )}
173
- {effective === null ? null : (
174
- <Fact label="Effective">
175
- {/* The machine attribute is stamped only for a real day on the
176
- calendar. A SHAPE test was not enough: the checker's own remedy
177
- for `2026-06-31` is to QUOTE it, and quoted text arrives here — so
178
- a shape test published `<time dateTime="2026-06-31">`, which is
179
- invalid HTML and which a consumer reads as July 1st. That is the
180
- precise hazard the record's date rule exists to prevent. */}
181
- {isCalendarDate(effective) ? (
182
- <time dateTime={effective}>{effective}</time>
183
- ) : (
184
- <span>{effective}</span>
185
- )}
186
- </Fact>
297
+ {effectiveFrom === null || !showEffective ? null : (
298
+ <Fact label="Effective from">{day(effectiveFrom)}</Fact>
187
299
  )}
300
+ {staleAfter === null ? null : <Fact label="Review by">{day(staleAfter)}</Fact>}
188
301
  {markdownUrl === undefined ? null : (
189
302
  // On the governance row, not as a footnote below the sources: it is
190
- // how a reader hands this document to an agent, and it was previously
191
- // the smallest text on the page, last (research/site-design.md F2).
192
- // ONE control rather than two: opening the markdown and copying it are
193
- // different acts, but two bare controls on a row of read-only facts
194
- // made the row look half clickable. It wears neither the bordered badge
195
- // (that means "a status the record declares") nor the accent at rest —
196
- // the page had gone blue enough that the accent had stopped meaning
197
- // anything (owner, 2026-08-22).
198
- // RIGHT-ALIGNED (owner, and a reversal). The 2026-08-21 finding stands
199
- // on its own terms: `ms-auto` once parked this 498px from the nearest
200
- // thing on a two-fact document, where it read as belonging to nothing.
201
- // What changed is the column beside it — the reading time now sits at
202
- // the right end of the strip directly below, so the far edge is no
203
- // longer empty space but a line the eye already follows. The two form
204
- // a right-hand column of things you DO, against a left-hand row of
205
- // things the record DECLARES.
303
+ // how a reader hands this document to an agent (research/site-design.md
304
+ // F2). Right-aligned: a column of things you DO, against a row of
305
+ // things the record DECLARES (owner, 2026-08-22).
206
306
  <span className="ms-auto">
207
307
  <DocumentActions href={markdownUrl} />
208
308
  </span>
@@ -217,36 +317,54 @@ export function GovernanceMeta({
217
317
  );
218
318
  }
219
319
 
320
+ /** An instant as a day, stamped as machine-readable only when it is a real one. */
321
+ function day(instant: string): ReactElement {
322
+ const value = dayOf(instant);
323
+ return isCalendarDate(value) ? <time dateTime={value}>{value}</time> : <span>{value}</span>;
324
+ }
325
+
220
326
  /**
221
- * Where the document came from.
222
- *
223
- * One entry per source, each independently visible — that is the whole point
224
- * of `provenance` being a list: a citation has to be able to point at exactly
225
- * one of them. Rendering them as prose would take that away.
327
+ * Where the document came from: `sources`, one entry each — that is the whole
328
+ * point of it being a list: a footnote has to be able to point at exactly one
329
+ * of them. Rendering them as prose would take that away.
226
330
  */
227
- export function Provenance({ entries }: { entries: readonly string[] }): ReactElement | null {
331
+ export function Provenance({
332
+ entries,
333
+ }: {
334
+ entries: readonly {
335
+ readonly id: string | null;
336
+ readonly title: string | null;
337
+ readonly resource: string;
338
+ }[];
339
+ }): ReactElement | null {
228
340
  if (entries.length === 0) return null;
229
341
 
230
342
  return (
231
343
  <section className="mt-10 border-t border-fd-border pt-5 text-sm">
232
344
  <h2 className="ksor-section-label mb-2">Sources</h2>
233
- {/* break-words, because a citation is often a long unbroken URL: on a
345
+ {/* break-words, because a source is often a long unbroken URL: on a
234
346
  phone it overflowed its row by 175px under an ancestor with
235
347
  `overflow-x: clip`, so the middle of the source was clipped away with
236
348
  no ellipsis and nothing to scroll (measured, 2026-08-20). A source
237
349
  nobody can read is not provenance. */}
238
350
  <ul className="space-y-1 break-words text-fd-muted-foreground">
239
351
  {entries.map((entry, index) => {
240
- // An entry that IS a URL becomes followable; a citation stays text.
241
- // `rel="noreferrer"` because the destination is authored in the
242
- // record, not chosen by this site.
243
- const href = sourceHref(entry);
352
+ // A resource that IS a URL becomes followable; a bundle path or a
353
+ // scope descriptor stays text. `rel="noreferrer"` because the
354
+ // destination is authored in the record, not chosen by this site.
355
+ const href = sourceHref(entry.resource);
356
+ const label = entry.title ?? entry.resource;
244
357
  return (
245
358
  // Position, not text: a record may cite the same source twice, and
246
359
  // duplicate keys are a console error on a governed page.
247
- <li key={`${index}-${entry}`}>
360
+ <li key={`${index}-${entry.resource}`}>
361
+ {entry.id === null ? null : (
362
+ <span className="me-2 font-mono text-xs text-fd-muted-foreground">
363
+ [^{entry.id}]
364
+ </span>
365
+ )}
248
366
  {href === null ? (
249
- entry
367
+ label
250
368
  ) : (
251
369
  <a
252
370
  href={href}
@@ -254,7 +372,7 @@ export function Provenance({ entries }: { entries: readonly string[] }): ReactEl
254
372
  rel="noreferrer"
255
373
  className="underline underline-offset-4 transition-colors hover:text-fd-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
256
374
  >
257
- {entry}
375
+ {label}
258
376
  </a>
259
377
  )}
260
378
  </li>
@@ -2,7 +2,7 @@ import { ArrowRight, FileText, Folder } from "lucide-react";
2
2
  import Link from "next/link";
3
3
  import type { ReactElement } from "react";
4
4
 
5
- import { statusTone } from "@/lib/governance";
5
+ import { badgeLabel, badgeTone } from "@/lib/governance";
6
6
  import type { RecordEntry } from "@/lib/source";
7
7
 
8
8
  /**
@@ -28,7 +28,7 @@ import type { RecordEntry } from "@/lib/source";
28
28
  *
29
29
  * What survives from the register is the part that was never about looks: the
30
30
  * record's serif for its own words, mono for what the record says ABOUT them —
31
- * who owns it, how much it holds, whether it carries a caveat — and `approved`
31
+ * who owns it, how much it holds, whether it carries a caveat — and `stable`
32
32
  * staying silent, because a label on every row is a label nobody reads.
33
33
  *
34
34
  * Server-rendered plain markup — it survives print, a failed bundle and
@@ -70,11 +70,11 @@ export function RecordIndex({
70
70
  <span className="font-display text-lg leading-snug font-semibold tracking-[-0.008em] transition-colors group-hover:text-fd-primary">
71
71
  {entry.title}
72
72
  </span>
73
- {entry.status === null ? null : (
73
+ {entry.badge === null ? null : (
74
74
  <span
75
- className={`rounded-sm border border-fd-border px-1.5 py-0.5 font-mono text-[10px] tracking-widest text-fd-foreground uppercase ${statusTone(entry.status)}`}
75
+ className={`rounded-sm border border-fd-border px-1.5 py-0.5 font-mono text-[10px] tracking-widest text-fd-foreground uppercase ${badgeTone(entry.badge)}`}
76
76
  >
77
- {entry.status}
77
+ {badgeLabel(entry.badge)}
78
78
  </span>
79
79
  )}
80
80
  </span>
@@ -1,7 +1,8 @@
1
1
  import Link from "next/link";
2
2
  import type { ReactElement } from "react";
3
3
 
4
- import { statusTone } from "@/lib/governance";
4
+ import { badgeLabel, badgeTone } from "@/lib/governance";
5
+ import type { LifecycleBadge } from "@/lib/lifecycle-rule";
5
6
  import type { RecordEntry } from "@/lib/source";
6
7
 
7
8
  /**
@@ -53,7 +54,7 @@ export function RecordStack({
53
54
  <span className="font-mono text-[10px] tracking-[0.2em] text-[var(--ksor-cover-muted)] uppercase">
54
55
  Opens here
55
56
  </span>
56
- {lead.status === null ? null : <StatusChip status={lead.status} />}
57
+ {lead.badge === null ? null : <StatusChip badge={lead.badge} />}
57
58
  </div>
58
59
 
59
60
  <h2 className="mt-4 font-display text-2xl leading-snug font-semibold tracking-[-0.008em] transition-colors group-hover:text-fd-primary">
@@ -99,7 +100,7 @@ export function RecordStack({
99
100
  {entry.documents === 0
100
101
  ? null
101
102
  : `${entry.documents} ${entry.documents === 1 ? "doc" : "docs"}`}
102
- {entry.status === null ? null : <StatusChip status={entry.status} />}
103
+ {entry.badge === null ? null : <StatusChip badge={entry.badge} />}
103
104
  </span>
104
105
  </span>
105
106
  </Link>
@@ -116,16 +117,16 @@ export function RecordStack({
116
117
  }
117
118
 
118
119
  /**
119
- * A caveat status, in the same chip the record's listings use — only ever
120
- * `draft`, `review` or `superseded`, because a reader already assumes a
121
- * document in the record is current.
120
+ * A caveat badge, in the same chip the record's listings use — only ever a
121
+ * state the machine surfaces decline (record spec §2.5), because a reader
122
+ * already assumes a document in the record is current and stable.
122
123
  */
123
- function StatusChip({ status }: { status: string }): ReactElement {
124
+ function StatusChip({ badge }: { badge: LifecycleBadge }): ReactElement {
124
125
  return (
125
126
  <span
126
- className={`rounded-sm border border-[var(--ksor-cover-rule)] px-1.5 py-0.5 font-mono text-[10px] tracking-widest text-[var(--ksor-cover-foreground)] uppercase ${statusTone(status)}`}
127
+ className={`rounded-sm border border-[var(--ksor-cover-rule)] px-1.5 py-0.5 font-mono text-[10px] tracking-widest text-[var(--ksor-cover-foreground)] uppercase ${badgeTone(badge)}`}
127
128
  >
128
- {status}
129
+ {badgeLabel(badge)}
129
130
  </span>
130
131
  );
131
132
  }
@@ -1,35 +1,36 @@
1
- import type { ReactElement } from "react";
1
+ import type { ReactElement, ReactNode } from "react";
2
2
 
3
- import { caveatStatus, statusTone } from "@/lib/governance";
3
+ import { badgeLabel, badgeTone } from "@/lib/governance";
4
+ import type { LifecycleBadge } from "@/lib/lifecycle-rule";
4
5
 
5
6
  /**
6
- * The sidebar's status marker, rendered by the shell's own status-badges
7
- * plugin (`lib/source.ts`).
7
+ * The sidebar's badge, drawn beside a row's name (`lib/source.ts` composes it
8
+ * into the page tree while sorting it).
8
9
  *
9
10
  * The sidebar is where a reader chooses. Without this, a withdrawn document and
10
11
  * the one that replaced it were pixel-identical rows — the governance appeared
11
12
  * only after the click, which is the moment it is least useful
12
13
  * (research/site-design.md F3).
13
14
  *
14
- * Only a CAVEAT is drawn. `approved` returns null, because a reader already
15
- * assumes a document in the record is current and a label that never varies
16
- * stops being read — so the marker stays rare enough to be noticed on the rows
17
- * where it matters. That rule is ours; the walk over the tree is the shell's.
15
+ * Only a BADGE is drawn — a state the machine surfaces decline (record spec
16
+ * §2.5). A current document shows nothing, because a reader already assumes a
17
+ * document in the record is current and a label that never varies stops being
18
+ * read; the marker stays rare enough to be noticed on the rows where it matters.
18
19
  */
19
- export function renderCaveatBadge(status: string): ReactElement | null {
20
- const caveat = caveatStatus(status);
21
- if (caveat === null) return null;
22
- // `inline-block` with the row's own wrapping, not a flex wrapper: the plugin
23
- // composes `<>{name}{badge}</>` with no element around the pair, so the badge
20
+ export function renderBadge(name: ReactNode, badge: LifecycleBadge): ReactElement {
21
+ // `inline-block` with the row's own wrapping, not a flex wrapper: the badge
24
22
  // has to survive beside a title that runs two lines in a ~200px column. An
25
23
  // earlier hand-rolled version pinned the chip right with `truncate`, which
26
24
  // clipped the title AND the chip to "sup…" (seen in Chromium, 2026-08-21) —
27
25
  // the marker has to fit around the name, not fight it.
28
26
  return (
29
- <span
30
- className={`ms-1.5 inline-block rounded border border-fd-border px-1 py-px align-middle text-[0.6rem] font-medium whitespace-nowrap text-fd-muted-foreground ${statusTone(caveat)}`}
31
- >
32
- {caveat}
33
- </span>
27
+ <>
28
+ {name}
29
+ <span
30
+ className={`ms-1.5 inline-block rounded border border-fd-border px-1 py-px align-middle text-[0.6rem] font-medium whitespace-nowrap text-fd-muted-foreground ${badgeTone(badge)}`}
31
+ >
32
+ {badgeLabel(badge)}
33
+ </span>
34
+ </>
34
35
  );
35
36
  }
@@ -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);