@panaversity/ksor 0.0.39 → 0.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +965 -0
  2. package/README.md +29 -9
  3. package/dist/checker/check-main.mjs +14049 -0
  4. package/dist/cli.mjs +11528 -5213
  5. package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
  6. package/dist/gateway.d.mts +52 -13
  7. package/dist/gateway.mjs +2 -2
  8. package/dist/index.d.mts +1 -1
  9. package/dist/index.mjs +1 -1
  10. package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
  11. package/docs/authorization.md +8 -6
  12. package/docs/deploying.md +36 -25
  13. package/docs/index.md +26 -13
  14. package/docs/ingesting.md +70 -22
  15. package/docs/tool-surface.md +69 -16
  16. package/package.json +4 -3
  17. package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
  18. package/schema/schema.sql +77 -14
  19. package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
  20. package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
  21. package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
  22. package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
  23. package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
  24. package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
  25. package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
  26. package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
  27. package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
  28. package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
  29. package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
  30. package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
  31. package/templates/scaffold/.github/workflows/validate.yml +9 -1
  32. package/templates/scaffold/.ksor/governance.yaml +17 -0
  33. package/templates/scaffold/AGENTS.md +293 -119
  34. package/templates/scaffold/Dockerfile +5 -1
  35. package/templates/scaffold/README.md +160 -42
  36. package/templates/scaffold/env.example +37 -6
  37. package/templates/scaffold/gitignore +14 -6
  38. package/templates/scaffold/instance.md +21 -17
  39. package/templates/scaffold/knowledge/governance-ladder.md +11 -2
  40. package/templates/scaffold/knowledge/index.md +9 -0
  41. package/templates/scaffold/knowledge/surfaces/for-agents.md +13 -6
  42. package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
  43. package/templates/scaffold/knowledge/surfaces/index.md +4 -20
  44. package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
  45. package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
  46. package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
  47. package/templates/scaffold/package.json +3 -4
  48. package/templates/scaffold/pnpm-lock.yaml +3 -0
  49. package/templates/scaffold/system/gateways/content.ts +13 -0
  50. package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
  51. package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
  52. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +134 -91
  53. package/templates/scaffold/system/site/app/global.css +198 -6
  54. package/templates/scaffold/system/site/app/layout.tsx +8 -3
  55. package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
  56. package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
  57. package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
  58. package/templates/scaffold/system/site/components/code-block.tsx +87 -0
  59. package/templates/scaffold/system/site/components/embed.tsx +276 -0
  60. package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
  61. package/templates/scaffold/system/site/components/governance.tsx +219 -87
  62. package/templates/scaffold/system/site/components/mdx.tsx +10 -2
  63. package/templates/scaffold/system/site/components/record-index.tsx +5 -5
  64. package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
  65. package/templates/scaffold/system/site/components/record-views.tsx +6 -17
  66. package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
  67. package/templates/scaffold/system/site/lib/alert-rule.ts +214 -0
  68. package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
  69. package/templates/scaffold/system/site/lib/attachments.ts +0 -28
  70. package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
  71. package/templates/scaffold/system/site/lib/audience.ts +42 -146
  72. package/templates/scaffold/system/site/lib/embed-rule.ts +255 -0
  73. package/templates/scaffold/system/site/lib/governance.ts +339 -225
  74. package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
  75. package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
  76. package/templates/scaffold/system/site/lib/lock.ts +282 -0
  77. package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
  78. package/templates/scaffold/system/site/lib/record-href.ts +68 -0
  79. package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
  80. package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
  81. package/templates/scaffold/system/site/lib/shared.ts +67 -104
  82. package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
  83. package/templates/scaffold/system/site/lib/source.ts +256 -186
  84. package/templates/scaffold/system/site/lib/stage-knowledge.ts +638 -467
  85. package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
  86. package/templates/scaffold/system/site/package.json +1 -0
  87. package/templates/scaffold/system/site/record/actor.ts +23 -0
  88. package/templates/scaffold/system/site/record/check.ts +571 -0
  89. package/templates/scaffold/system/site/record/citations.ts +312 -0
  90. package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
  91. package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
  92. package/templates/scaffold/system/site/record/hygiene.ts +320 -0
  93. package/templates/scaffold/system/site/record/index-file.ts +150 -0
  94. package/templates/scaffold/system/site/record/index.ts +103 -0
  95. package/templates/scaffold/system/site/record/instance.ts +257 -0
  96. package/templates/scaffold/system/site/record/instant.ts +43 -0
  97. package/templates/scaffold/system/site/record/ledger.ts +694 -0
  98. package/templates/scaffold/system/site/record/load.ts +129 -0
  99. package/templates/scaffold/system/site/record/lock.ts +306 -0
  100. package/templates/scaffold/system/site/record/near-miss.ts +37 -0
  101. package/templates/scaffold/system/site/record/policy.ts +414 -0
  102. package/templates/scaffold/system/site/record/profile.ts +535 -0
  103. package/templates/scaffold/system/site/record/refusal.ts +106 -0
  104. package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
  105. package/templates/scaffold/system/site/source.config.ts +109 -23
  106. package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
  107. package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
  108. package/templates/scaffold/system/site/lib/page-order.ts +0 -93
@@ -1,54 +1,87 @@
1
1
  // What the record says about a document, projected for rendering.
2
2
  //
3
- // `knowledge/` documents carry a governance vocabulary that `pnpm check`
4
- // enforces — status, owner, provenance, effective, superseded_by — and until
5
- // this module existed the site parsed four of those keys and threw them away.
3
+ // A concept carries the profile's governance (record spec §2) — status, type,
4
+ // `ksor.owner`, `sources`, `ksor.effective_from`, `stale_after`,
5
+ // `ksor.superseded_by`, the approval and the verifications and this module
6
+ // is the one projection of those keys the pages and the agent surfaces share.
6
7
  // Provenance is load-bearing: a reader has to be able to see who stands behind
7
8
  // a document and where it came from, or the site is showing them prose while
8
9
  // the agent surface answers with citations.
9
10
  //
10
- // Import-free on purpose: this is the pure half, so it is unit-tested directly
11
- // (packages/ksor/src/site-governance.test.ts) without a site install. Anything
12
- // needing the Fumadocs loader — resolving a successor pointer to its route —
11
+ // Free of VALUE imports on purpose: this is the pure half, so it is unit-tested
12
+ // directly (packages/ksor/src/site-governance.test.ts) without a site install.
13
+ // Anything needing the Fumadocs loader — resolving a successor to its route —
13
14
  // lives outside it.
14
15
  //
15
- // Contract: specs/ksor/site-governance/spec.md
16
+ // The one import is a TYPE, and it alone carries the `.js` that the package's
17
+ // Node-ESM program wants, while every other module here is extensionless
18
+ // because Turbopack resolves neither form onto a `.ts` file. `import type` is
19
+ // erased before any bundler sees it, so the two rules never meet on this line.
20
+
21
+ import type { LifecycleBadge } from "./lifecycle-rule.js";
22
+
23
+ export interface Source {
24
+ readonly id: string | null;
25
+ readonly title: string | null;
26
+ /** A URL, a bundle path, or a scope descriptor (OKF §5.1). */
27
+ readonly resource: string;
28
+ }
29
+
30
+ export interface Act {
31
+ readonly by: string;
32
+ /** The instant as the record wrote it. */
33
+ readonly at: string;
34
+ }
16
35
 
17
36
  export interface DocumentGovernance {
18
- /** `draft` | `review` | `approved` | `superseded`. Required by the checker; null only when a document skipped it. */
37
+ /** `draft` | `stable` | `deprecated` (record spec §2.2). Null only when a document skipped it. */
19
38
  readonly status: string | null;
20
- /** Who stands behind this document. */
39
+ readonly type: string | null;
40
+ /** Who stands behind this document: `ksor.owner`. */
21
41
  readonly owner: string | null;
22
42
  /** One entry per source — a citation must be able to point at exactly one of them. */
23
- readonly provenance: readonly string[];
24
- /** When it took effect, as an ISO date. */
25
- readonly effective: string | null;
26
- /** The successor's pointer as the document declares it, e.g. `./refund-policy-v5.md`. */
43
+ readonly sources: readonly Source[];
44
+ /** `ksor.effective_from`, as the record wrote it. */
45
+ readonly effectiveFrom: string | null;
46
+ readonly staleAfter: string | null;
47
+ /** The successor's concept id, e.g. `policies/purchase-approval-v2`. */
27
48
  readonly supersededBy: string | null;
49
+ readonly approval: Act | null;
50
+ readonly deprecated: Act | null;
51
+ readonly verified: readonly Act[];
28
52
  }
29
53
 
30
54
  /**
31
- * A declared value, or null. Blank and whitespace-only count as undeclared: a
55
+ * A declared scalar, or null. Blank and whitespace-only count as undeclared: a
32
56
  * key an author started and left empty is not a governance fact.
33
57
  *
34
- * An unquoted `effective: 2026-04-01` parses to a Date, so dates normalize to
35
- * their ISO day here — rendering the object would print a locale- and
58
+ * An unquoted instant parses to a Date in the collection's YAML, so dates
59
+ * normalize to ISO here — rendering the object would print a locale- and
36
60
  * timezone-dependent string into the record.
37
61
  */
38
62
  function declared(value: unknown): string | null {
39
- // A bare `effective: 2026` types as a NUMBER in YAML and used to disappear
40
- // from the page entirely — the record declared it and the page said nothing.
41
- // `pnpm check` refuses it now; showing what the author wrote is still the
42
- // honest fallback for a record that skipped the checker.
43
63
  if (typeof value === "number") return Number.isFinite(value) ? String(value) : null;
44
64
  if (value instanceof Date) {
45
- return Number.isNaN(value.getTime()) ? null : (value.toISOString().split("T")[0] ?? null);
65
+ return Number.isNaN(value.getTime()) ? null : value.toISOString();
46
66
  }
47
67
  if (typeof value !== "string") return null;
48
68
  const trimmed = value.trim();
49
69
  return trimmed === "" ? null : trimmed;
50
70
  }
51
71
 
72
+ function mapping(value: unknown): Record<string, unknown> {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value)
74
+ ? (value as Record<string, unknown>)
75
+ : {};
76
+ }
77
+
78
+ function act(value: unknown): Act | null {
79
+ const m = mapping(value);
80
+ const by = declared(m["by"]);
81
+ const at = declared(m["at"]);
82
+ return by === null || at === null ? null : { by, at };
83
+ }
84
+
52
85
  /**
53
86
  * The document's governance, exactly as it declares it.
54
87
  *
@@ -63,93 +96,128 @@ export function readGovernance(data: unknown, where: string): DocumentGovernance
63
96
  // frontmatter, which is whatever the author wrote, and a shell that crashes
64
97
  // on a shape the checker would have named is worse than one that renders
65
98
  // what it can.
66
- const record: Record<string, unknown> =
67
- typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
99
+ const record = mapping(data);
100
+ const ksor = mapping(record["ksor"]);
68
101
 
69
102
  const status = declared(record["status"]);
70
- const supersededBy = declared(record["superseded_by"]);
103
+ const supersededBy = declared(ksor["superseded_by"]);
71
104
 
72
- // Defense in depth: `pnpm check` refuses this, so reaching it means the
73
- // adopter skipped the checker. Failing the build is the honest outcome —
74
- // the alternative is serving a document that says it was replaced and
75
- // cannot say by what.
76
- if (status === "superseded" && supersededBy === null) {
105
+ // Defense in depth: the checker refuses a deprecated concept with no
106
+ // attribution. Failing the build is the honest outcome — the alternative is
107
+ // serving a document that says it was withdrawn and cannot say who did it.
108
+ if (status === "deprecated" && act(ksor["deprecated"]) === null) {
77
109
  throw new Error(
78
- `${where} is status: superseded with no superseded_by — a document that says it was ` +
79
- "replaced must say by what, or the reader is told to stop trusting it and given nowhere " +
80
- "to go. Add superseded_by: ./<successor>.md (pnpm check refuses this too).",
110
+ `${where} is status: deprecated with no ksor.deprecated — a document the record withdrew ` +
111
+ "must say who withdrew it and when, or the reader is told to stop trusting it by nobody. " +
112
+ "Add ksor.deprecated: { by, at } (pnpm check refuses this too).",
81
113
  );
82
114
  }
83
115
 
84
- // A scalar provenance is a checker finding, not a crash: turning one into an
85
- // unexplained build failure hides the real message `pnpm check` would print.
86
- const raw: unknown = record["provenance"];
87
- const provenance = Array.isArray(raw)
88
- ? raw.map(declared).filter((entry): entry is string => entry !== null)
116
+ // A malformed sources list is a checker finding, not a crash: turning one
117
+ // into an unexplained build failure hides the real message `pnpm check` prints.
118
+ const raw: unknown = record["sources"];
119
+ const sources = Array.isArray(raw)
120
+ ? raw.flatMap((entry): Source[] => {
121
+ const m = mapping(entry);
122
+ const resource = declared(m["resource"]);
123
+ return resource === null
124
+ ? []
125
+ : [{ id: declared(m["id"]), title: declared(m["title"]), resource }];
126
+ })
89
127
  : [];
128
+ // OKF §5.2: a bare `verified` mapping is a one-element list.
129
+ const verifiedRaw: unknown = record["verified"];
130
+ const verified = (Array.isArray(verifiedRaw) ? verifiedRaw : [verifiedRaw])
131
+ .map(act)
132
+ .filter((entry): entry is Act => entry !== null);
90
133
 
91
134
  return {
92
135
  status,
93
- owner: declared(record["owner"]),
94
- provenance,
95
- effective: declared(record["effective"]),
136
+ type: declared(record["type"]),
137
+ owner: declared(ksor["owner"]),
138
+ sources,
139
+ effectiveFrom: declared(ksor["effective_from"]),
140
+ staleAfter: declared(record["stale_after"]),
96
141
  supersededBy,
142
+ approval: act(ksor["approval"]),
143
+ deprecated: act(ksor["deprecated"]),
144
+ verified,
97
145
  };
98
146
  }
99
147
 
148
+ /** The three tiers, in the order OKF ranks them. */
149
+ export type TrustTier = "unverified" | "machine-confirmed" | "human-reviewed";
150
+
151
+ /** Trust tier derives from `verified` (record spec §2.3): none, machine only, or any human. */
152
+ export function trustTierOf(verified: readonly Act[]): TrustTier {
153
+ if (verified.length === 0) return "unverified";
154
+ return verified.some((v) => v.by.startsWith("human:")) ? "human-reviewed" : "machine-confirmed";
155
+ }
156
+
157
+ /** The tier, and the verification that put the document there. */
158
+ export interface TrustSignal {
159
+ readonly tier: TrustTier;
160
+ /** Null at `unverified`, where by definition nobody has said anything. */
161
+ readonly by: string | null;
162
+ readonly at: string | null;
163
+ }
164
+
165
+ /**
166
+ * The tier a page shows, WITH its verifier — "human-reviewed" alone is a claim
167
+ * with nobody behind it, and provenance is load-bearing.
168
+ *
169
+ * The verification named is the latest one OF THE KIND THAT SET THE TIER: the
170
+ * tier keys on the `human:` prefix (record spec §2.3), so a nightly machine
171
+ * pass running after a human review must not end up credited with the review.
172
+ * A tier is never inferred from anything but `verified`, and `unverified` is
173
+ * the honest state a stable, approved, unreviewed concept sits at — reported,
174
+ * not hidden (research/okf-native.md §1.1).
175
+ */
176
+ export function trustSignal(verified: readonly Act[]): TrustSignal {
177
+ const tier = trustTierOf(verified);
178
+ if (tier === "unverified") return { tier, by: null, at: null };
179
+ const deciding =
180
+ tier === "human-reviewed" ? verified.filter((v) => v.by.startsWith("human:")) : verified;
181
+ // Latest by instant; an unparsable `at` (which the checker refuses) keeps
182
+ // declaration order rather than crashing the page it was handed.
183
+ let latest = deciding[0] as Act;
184
+ for (const entry of deciding.slice(1)) {
185
+ const a = Date.parse(entry.at);
186
+ const b = Date.parse(latest.at);
187
+ if (!Number.isNaN(a) && (Number.isNaN(b) || a >= b)) latest = entry;
188
+ }
189
+ return { tier, by: latest.by, at: latest.at };
190
+ }
191
+
100
192
  /** One document as the loader reports it: its source path, and its route. */
101
193
  export interface RecordPage {
102
- /** Path under `knowledge/`, e.g. `legal.md` or `handbook/index.md`. */
194
+ /** Path under `knowledge/`, e.g. `legal.md` or `policies/terms.md`. */
103
195
  readonly path: string;
104
196
  /** The route it renders at, e.g. `/docs/legal`. */
105
197
  readonly url: string;
106
198
  }
107
199
 
108
- /** Resolve a relative pointer against the directory holding `from`. */
109
- function resolveFrom(from: string, relative: string): string {
110
- // The source path is the authority, so it is normalized like one: Windows
111
- // separators included (the loader reports whatever the filesystem gave it).
112
- const segments = from.replaceAll("\\", "/").split("/").slice(0, -1);
113
- for (const part of relative.split("/")) {
114
- if (part === "" || part === ".") continue;
115
- if (part === "..") segments.pop();
116
- else segments.push(part);
117
- }
118
- return segments.join("/");
200
+ /** The concept id of a page: its path without `.md`, forward slashes. */
201
+ export function conceptIdOfPath(pagePath: string): string {
202
+ return pagePath.replaceAll("\\", "/").replace(/\.mdx?$/, "");
119
203
  }
120
204
 
121
205
  /**
122
- * The route a `superseded_by` pointer names, or null.
123
- *
124
- * Resolved against the document's SOURCE PATH, never against its route. A
125
- * route cannot tell `knowledge/legal.md` from `knowledge/handbook/index.md` —
126
- * both render at one path segment — yet `./terms.md` means a sibling in the
127
- * first and a folder child in the second. Resolving on routes had to guess,
128
- * and refused to link a record the checker calls well-formed (found live,
129
- * 2026-08-20: `legal.md` pointing at `./terms.md` beside a `legal/terms.md`
130
- * rendered the raw pointer instead of a link).
206
+ * The route a `ksor.superseded_by` pointer names, or null.
131
207
  *
132
- * Null means the successor is not in THIS build legitimate for a
133
- * per-audience build, which stages a subset and the caller then shows the
134
- * pointer as text. A dead link on a supersession notice is the worst outcome:
208
+ * The pointer is a CONCEPT ID bundle-relative, `.md` optional (record spec
209
+ * §2, `ksor.superseded_by: policies/purchase-approval-v2`)resolved against
210
+ * the pages in THIS build. Null means the successor is not here: legitimate
211
+ * for a per-viewer build, which stages a subset, and the caller then shows the
212
+ * pointer as text. A dead link on a deprecation notice is the worst outcome:
135
213
  * it tells the reader to stop trusting the page and then strands them.
136
214
  */
137
- export function resolveSuccessorUrl(
138
- pointer: string,
139
- currentPath: string,
140
- pages: readonly RecordPage[],
141
- ): string | null {
142
- // Leaves the record: an absolute URL or a site-absolute path is not a
143
- // pointer into knowledge/ at all.
144
- if (pointer.includes("://") || pointer.startsWith("/")) return null;
145
-
215
+ export function resolveSuccessorUrl(pointer: string, pages: readonly RecordPage[]): string | null {
216
+ if (pointer.includes("://") || pointer.startsWith("/") || pointer.startsWith(".")) return null;
146
217
  const [target = "", anchor] = pointer.split("#", 2);
147
- if (!target.endsWith(".md")) return null;
148
-
149
- const resolved = resolveFrom(currentPath, target);
150
- const match = pages.find((page) => page.path.replaceAll("\\", "/") === resolved);
218
+ const id = target.replace(/\.md$/, "");
219
+ const match = pages.find((page) => conceptIdOfPath(page.path) === id);
151
220
  if (match === undefined) return null;
152
-
153
221
  return anchor === undefined ? match.url : `${match.url}#${anchor}`;
154
222
  }
155
223
 
@@ -158,9 +226,7 @@ export function resolveSuccessorUrl(
158
226
  *
159
227
  * `2026-06-31` and `2026-13-45` both match a `\d{4}-\d{2}-\d{2}` shape, and
160
228
  * stamping either into `<time datetime>` publishes a day that does not exist:
161
- * invalid HTML, and a consumer parsing it gets July 1st. The record's checker
162
- * refuses those unquoted and offers QUOTING as the escape hatch — which is
163
- * exactly how one reaches this function (found 2026-08-21).
229
+ * invalid HTML, and a consumer parsing it gets July 1st.
164
230
  */
165
231
  export function isCalendarDate(value: string): boolean {
166
232
  const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
@@ -174,6 +240,11 @@ export function isCalendarDate(value: string): boolean {
174
240
  );
175
241
  }
176
242
 
243
+ /** The calendar day of an instant, for a reader: `2026-09-01T00:00:00Z` → `2026-09-01`. */
244
+ export function dayOf(instant: string): string {
245
+ return instant.split("T")[0] ?? instant;
246
+ }
247
+
177
248
  /**
178
249
  * Whether this record's site shows the governance it declares — the
179
250
  * `site.governance` key in instance.md, default **on**:
@@ -181,118 +252,175 @@ export function isCalendarDate(value: string): boolean {
181
252
  * site:
182
253
  * governance: false
183
254
  *
184
- * The record often wants `owner:` and `provenance:` filled in for the agent
185
- * surface and the audit trail while the published page stays plain. That is a
255
+ * The record often wants owners and sources filled in for the agent surface
256
+ * and the audit trail while the published page stays plain. That is a
186
257
  * publication choice, so it belongs to the instance, not to each document —
187
258
  * per-document control is already the frontmatter itself (declare a key and it
188
259
  * shows; leave it off and nothing does).
189
260
  *
190
261
  * Default on, and additive: every record written before this key existed keeps
191
- * rendering exactly as it did. Turning it off never hides the SUPERSESSION
262
+ * rendering exactly as it did. Turning it off never hides the DEPRECATION
192
263
  * notice — that is a correctness warning, not decoration, and a reader handed a
193
264
  * replaced document with no word of its successor has been misled.
194
265
  *
195
- * Takes the frontmatter block (not a path) so it stays pure and testable; the
196
- * site binds it once in lib/shared.ts.
266
+ * Takes the parsed instance frontmatter (not a path) so it stays pure and
267
+ * testable; the site binds it once in lib/shared.ts. Real YAML now, so a flow
268
+ * mapping and a block read the same — the line scanner this replaced had to
269
+ * refuse the flow form because it could not read it.
197
270
  */
198
- export function governanceVisible(instanceFrontmatterBlock: string): boolean {
199
- const lines = instanceFrontmatterBlock.split("\n");
200
- const start = lines.findIndex((line) => /^site[ \t]*:[ \t]*(?:#.*)?$/.test(line));
201
- if (start === -1) {
202
- // A flow mapping (`site: { governance: false }`) is a scalar to every
203
- // reader of this block, so a block-only scan would fall through to the
204
- // default and publish what the owner turned off. `pnpm check` refuses the
205
- // shape; refuse it here too rather than default past it silently.
206
- const inline = lines.find((line) => /^site[ \t]*:[ \t]*\S/.test(line));
207
- if (inline !== undefined) {
208
- throw new Error(
209
- `instance.md has ${JSON.stringify(inline.trim())} — a site: group written on one line is ` +
210
- "not read as a group, so every key inside it is dropped without a word. Write it as an " +
211
- "indented block:\n site:\n governance: false",
212
- );
213
- }
214
- return true;
215
- }
271
+ export function governanceVisible(instance: Readonly<Record<string, unknown>>): boolean {
272
+ const site = instance["site"];
273
+ if (typeof site !== "object" || site === null || Array.isArray(site)) return true;
274
+ const value = (site as Record<string, unknown>)["governance"];
275
+ if (value === undefined || value === null) return true;
276
+ if (typeof value === "boolean") return value;
277
+ throw new Error(
278
+ `instance.md site.governance is ${JSON.stringify(value)} — it must be true or false. ` +
279
+ "Defaulting silently would publish the governance you asked to hide, or hide what you " +
280
+ "asked to publish. Write `governance: false` to keep the pages plain, or remove the key.",
281
+ );
282
+ }
216
283
 
217
- for (const line of lines.slice(start + 1)) {
218
- if (line.trim() === "") continue;
219
- // A non-indented line ends the block: a TOP-LEVEL `governance:` is a
220
- // different key and must never be mistaken for this setting.
221
- if (!/^[ \t]/.test(line)) break;
222
- const match = /^[ \t]+governance[ \t]*:[ \t]*(.*)$/.exec(line);
223
- if (match === null) continue;
224
-
225
- const raw = (match[1] ?? "").trim();
226
- // ` #` starts a YAML comment on an unquoted value (the grammar the
227
- // audience model already follows).
228
- const value = (/^["']/.test(raw) ? raw : raw.replace(/\s+#.*$/, ""))
229
- .trim()
230
- .replace(/^(['"])(.*)\1$/, "$2")
231
- .toLowerCase();
232
-
233
- if (value === "true") return true;
234
- if (value === "false") return false;
235
- throw new Error(
236
- `instance.md site.governance is ${JSON.stringify(raw)} — it must be true or false. ` +
237
- "Defaulting silently would publish the governance you asked to hide, or hide what you " +
238
- "asked to publish. Write `governance: false` to keep the pages plain, or remove the key.",
239
- );
284
+ // ---------------------------------------------------------------------------
285
+ // The badge: what a human surface says beside a document the machine surfaces
286
+ // decline (record spec §2.5). ONE vocabulary for the page chip, the sidebar,
287
+ // the listings and the search results, so a reader picking between a document
288
+ // and its successor sees the same word everywhere.
289
+
290
+ /**
291
+ * The chip text for a badge, or null for a document every surface admits.
292
+ *
293
+ * The words are record spec §2.5's own "effective from …" and "past its
294
+ * review date" — because the reader who meets one on a sidebar row and again
295
+ * on the page must not have to work out that two phrasings mean one state.
296
+ * §2.5's ellipsis is the date, and `badgeText` fills it in where there is
297
+ * room; this is the same sentence with the value left off, never a second one.
298
+ */
299
+ export function badgeLabel(badge: LifecycleBadge | null): string | null {
300
+ switch (badge) {
301
+ case null:
302
+ return null;
303
+ case "draft":
304
+ return "draft";
305
+ case "deprecated":
306
+ return "deprecated";
307
+ case "effective-from":
308
+ return "effective from";
309
+ case "stale":
310
+ return "past its review date";
240
311
  }
241
- return true;
242
312
  }
243
313
 
244
- // ---------------------------------------------------------------------------
245
- // The AGENT surface's projection of the same record.
246
- //
247
- // The page shows a superseded document under an unmissable notice; llms.txt and
248
- // llms-full.txt used to serve that same document as ordinary prose no status,
249
- // no successor, no owner so an agent answered from a policy the reader had
250
- // been warned about, and could not know (measured on shipped bytes,
251
- // research/site-design.md F1). Two surfaces, two truths, which product
252
- // principle 2 forbids.
253
- //
254
- // Deliberately NOT gated on `site.governance`: that key decides what the PAGES
255
- // publish. The record keeps every key for the agent surface and the audit trail
256
- // (specs/ksor/site-governance/spec.md), so gating this on it would rebuild the
257
- // defect above on purpose.
314
+ /**
315
+ * The badge as a PAGE says it: §2.5's words with the ellipsis filled in from
316
+ * the document's own `ksor.effective_from`.
317
+ *
318
+ * Only `effective-from` carries a date, and only forwards: "past its review
319
+ * date" is about a day that has gone, and the day itself is already a fact in
320
+ * the strip beside it ("Review by"), so repeating it in the chip would say the
321
+ * same thing twice in one line.
322
+ */
323
+ export function badgeText(
324
+ badge: LifecycleBadge | null,
325
+ effectiveFrom: string | null,
326
+ ): string | null {
327
+ const label = badgeLabel(badge);
328
+ if (label === null) return null;
329
+ if (badge !== "effective-from" || effectiveFrom === null) return label;
330
+ return `${label} ${dayOf(effectiveFrom)}`;
331
+ }
258
332
 
259
- /** What a reader already assumes of a document in a system of record. */
260
- const ASSUMED_STATUS = "approved";
333
+ /**
334
+ * Does the badge say anything the status chip does not?
335
+ *
336
+ * `draft` and `deprecated` are both a status word and a badge word, so a page
337
+ * that drew both would print one state twice. The two date states have no
338
+ * status word of their own — the document is `stable` and something about the
339
+ * calendar keeps it off the machine surfaces — and those are exactly the ones
340
+ * a reader cannot infer from the status alone.
341
+ */
342
+ export function badgeAddsToStatus(badge: LifecycleBadge | null, status: string | null): boolean {
343
+ return badge !== null && badge !== status;
344
+ }
261
345
 
262
346
  /**
263
- * The document's status when it is worth showing, or null.
347
+ * The badge a PLAIN page still draws the page of a record whose
348
+ * `site.governance` is off.
349
+ *
350
+ * That key turns off ATTRIBUTION: who owns this document, who approved it, who
351
+ * verified it, where it came from. A badge is not attribution. It is the record
352
+ * saying this document is not one the machine surfaces will serve today, which
353
+ * is the same class of thing as the deprecation notice the key already exempts
354
+ * ("a correctness warning, not decoration") — and the sidebar, the folder
355
+ * listings and the search dialog draw it whatever the key says. Gating it on
356
+ * the page alone put an "effective from 2027-01-01" chip on a document in three
357
+ * places and opened it as a current, in-force policy in the fourth, while the
358
+ * MCP door refused it outright (record spec §2.5). One record, one voice.
264
359
  *
265
- * ONE definition, shared by every surface that shows a status the page chip,
266
- * the agent index, the record listings. `approved` is silent because that is
267
- * what a reader already assumes of a document in a system of record, and a
268
- * label that appears everywhere and always says the same thing trains people to
269
- * skip it, including on the page where it mattered. An unrecognized state is
270
- * passed through rather than swallowed: `pnpm check` holds status to a closed
271
- * set, so reaching here with one means the record skipped the checker, and
272
- * showing what it wrote beats hiding it.
360
+ * `deprecated` is the one state dropped, because a plain page still carries the
361
+ * DeprecatedNotice above its title: the same rule `badgeAddsToStatus` applies
362
+ * when the status chip is the thing already saying it.
273
363
  */
364
+ export function plainBadge(badge: LifecycleBadge | null): LifecycleBadge | null {
365
+ return badge === "deprecated" ? null : badge;
366
+ }
367
+
368
+ /** The three lifecycle states (record spec §2.2), and nothing else — a word the record does not define is not a status. */
369
+ const STATUSES: readonly string[] = ["draft", "stable", "deprecated"];
370
+
274
371
  /**
275
- * The tone class a status chip wears, in every surface that renders one.
372
+ * The status chip every page carries including a `stable` one, which is the
373
+ * one difference from the badge.
276
374
  *
277
- * Only a WITHDRAWN document gets a colour. `draft` and `review` are ordinary
278
- * states of a live document; `superseded` says the record has replaced it, and
279
- * before this the three were pixel-identical chips the same hairline, the
280
- * same muted text at exactly the moment a reader picks between a document
281
- * and its successor. One function rather than the ternary repeated at five
282
- * call sites, so the rule cannot drift between the sidebar and search.
375
+ * A badge is rare on purpose: it marks the documents the machine surfaces
376
+ * decline. The status is not a caveat, it is the record's own word for where
377
+ * the document stands, and research/okf-native.md §1.1 has the page saying it
378
+ * out loud from day one "the chips say `stable` with approver and date, and
379
+ * the badge says unverified". A reader who cannot see `stable` cannot tell a
380
+ * governed record from a site that simply never said.
283
381
  */
382
+ export function statusLabel(status: string | null): string | null {
383
+ return status !== null && STATUSES.includes(status) ? status : null;
384
+ }
385
+
386
+ /** The status chip's tone — the same rule the badge uses, so one state is one colour. */
284
387
  export function statusTone(status: string | null): string {
285
- return status === "superseded" ? "ksor-withdrawn" : "";
388
+ return status === "deprecated" ? "ksor-withdrawn" : "";
389
+ }
390
+
391
+ /**
392
+ * The tone class a badge wears. Only a WITHDRAWN document gets a colour:
393
+ * `draft` and the two date states are ordinary states of a live document;
394
+ * `deprecated` says the record has replaced it, and that is the one chip a
395
+ * reader must not mistake for the others at the moment of choosing.
396
+ */
397
+ export function badgeTone(badge: LifecycleBadge | null): string {
398
+ return badge === "deprecated" ? "ksor-withdrawn" : "";
286
399
  }
287
400
 
288
- export function caveatStatus(status: string | null): string | null {
289
- return status === null || status === ASSUMED_STATUS ? null : status;
401
+ // ---------------------------------------------------------------------------
402
+ // The AGENT surface's projection of the same record.
403
+ //
404
+ // The machine surfaces admit only stable, effective, unexpired concepts, so a
405
+ // consumer never meets a deprecated or draft document there — what it needs is
406
+ // the governance that makes the passage citable, and the stamps that connect
407
+ // it to one publication (R14). Deliberately NOT gated on `site.governance`:
408
+ // that key decides what the PAGES publish; the record keeps every key for the
409
+ // agent surface and the audit trail.
410
+
411
+ /** The stamps every machine artefact carries (build spec §3). */
412
+ export interface Stamps {
413
+ readonly build_id: string | null;
414
+ readonly source_commit: string | null;
415
+ readonly dirty: boolean;
416
+ readonly ksor_version: string | null;
417
+ readonly unstamped: boolean;
290
418
  }
291
419
 
292
420
  /**
293
421
  * A YAML scalar a consumer can parse back to exactly what the record said. The
294
- * shapes below are the ones the record's own checker refuses unquoted, for the
295
- * same reason: unquoted, YAML reads them as something else.
422
+ * shapes below are the ones a plain scalar cannot carry: unquoted, YAML reads
423
+ * them as something else.
296
424
  */
297
425
  function yamlScalar(value: string): string {
298
426
  const risky =
@@ -304,78 +432,64 @@ function yamlScalar(value: string): string {
304
432
  return risky ? JSON.stringify(value) : value;
305
433
  }
306
434
 
307
- /**
308
- * The suffix for one line of the compact index (`llms.txt`), or "" when the
309
- * document carries no caveat.
310
- *
311
- * Caveats only, unlike the full block below: the index is one line per
312
- * document, and a marker on every line is noise an agent learns to skip — the
313
- * same argument that keeps the page's status chip rare.
314
- *
315
- * `successorUrl` is the successor's RESOLVED route (the caller owns
316
- * resolution, including any base path), or null when it is not in this build —
317
- * a per-audience build stages a subset. A missing successor never suppresses
318
- * the SUPERSEDED marker: dropping the warning with the link would serve the
319
- * withdrawn document looking clean, which is the whole defect.
320
- */
321
- export function agentIndexSuffix(
322
- governance: DocumentGovernance,
323
- successorUrl: string | null,
324
- ): string {
325
- const status = caveatStatus(governance.status);
326
- if (status === null) return "";
327
- const replaced =
328
- status === "superseded" && successorUrl !== null ? `, replaced by ${successorUrl}` : "";
329
- return ` — ${status.toUpperCase()}${replaced}`;
435
+ /** The stamp lines, as YAML keys — one spelling for every artefact. */
436
+ export function stampLines(stamps: Stamps): string[] {
437
+ if (stamps.unstamped) return ["build_id: null", "unstamped: true"];
438
+ const lines = [`build_id: ${yamlScalar(stamps.build_id ?? "")}`];
439
+ if (stamps.source_commit !== null)
440
+ lines.push(`source_commit: ${yamlScalar(stamps.source_commit)}`);
441
+ if (stamps.dirty) lines.push("dirty: true");
442
+ if (stamps.ksor_version !== null) lines.push(`ksor_version: ${yamlScalar(stamps.ksor_version)}`);
443
+ return lines;
330
444
  }
331
445
 
332
446
  /**
333
- * The governance block that precedes a document's body in `llms-full.txt`,
334
- * written as frontmatter — the record's own grammar, so a consumer parses the
335
- * corpus the way the corpus is authored.
447
+ * The frontmatter a document's markdown twin and its `llms-full.txt` block
448
+ * carry: the record's OWN frontmatter, intact, then the derived trust tier and
449
+ * the build's stamps (R14).
450
+ *
451
+ * `raw` is the concept's frontmatter exactly as the staged file holds it,
452
+ * between its fences and unparsed. Intact, because the twin is the record's
453
+ * bytes: an OKF consumer that fetches one must be able to parse the concept the
454
+ * profile describes, and record spec §2.7 keeps unknown keys for exactly that
455
+ * reason — a re-serialisation drops whatever this shell did not think to
456
+ * project.
336
457
  *
337
- * `status` is emitted even when it is `approved`, which is the opposite call to
338
- * the page's. A reader assumes a document in a record is current; a consumer
339
- * assumes nothing, and that silence is exactly what F1 was.
458
+ * The projection this replaced flattened `ksor.owner` into a top-level `owner:`
459
+ * and `ksor.effective_from` into `effective_from:`. Both are keys record spec
460
+ * §2.7 refuses BY NAME as pre-profile leftovers, so every twin published a
461
+ * frontmatter the record's own checker would have rejected — the corpus
462
+ * describing itself in a grammar it forbids (found while implementing build
463
+ * spec §3's twin clause).
340
464
  *
341
- * Nothing is inferred: an undeclared key is absent, never an empty one, and a
342
- * document declaring no governance at all yields "".
465
+ * Two keys are ADDED rather than copied, and both are the build speaking about
466
+ * the document rather than the document speaking about itself: `trust_tier`,
467
+ * which record spec §2.3 derives from `verified` and no reader should have to
468
+ * re-derive, and the stamps, which are what connect these bytes to one
469
+ * publication.
343
470
  */
344
471
  export function agentFrontmatter(
472
+ raw: string,
345
473
  governance: DocumentGovernance,
346
- successorUrl: string | null,
474
+ stamps: Stamps,
347
475
  ): string {
348
- const { status, owner, effective, supersededBy, provenance } = governance;
349
- const lines: string[] = [];
350
- if (status !== null) lines.push(`status: ${yamlScalar(status)}`);
351
- if (owner !== null) lines.push(`owner: ${yamlScalar(owner)}`);
352
- if (effective !== null) lines.push(`effective: ${yamlScalar(effective)}`);
353
- // The resolved route, never the raw `./successor.md` pointer: a consumer that
354
- // never sees the record's file tree cannot follow one.
355
- if (status === "superseded" && supersededBy !== null) {
356
- lines.push(`superseded_by: ${yamlScalar(successorUrl ?? supersededBy)}`);
357
- }
358
- if (provenance.length > 0) {
359
- lines.push("provenance:");
360
- for (const entry of provenance) lines.push(` - ${yamlScalar(entry)}`);
361
- }
362
- return lines.length === 0 ? "" : `---\n${lines.join("\n")}\n---\n`;
476
+ const own = raw.replace(/\s+$/, "");
477
+ const lines = own === "" ? [] : [own];
478
+ lines.push(`trust_tier: ${trustTierOf(governance.verified)}`, ...stampLines(stamps));
479
+ return `---\n${lines.join("\n")}\n---\n`;
363
480
  }
364
481
 
365
482
  /**
366
- * The href for a provenance entry that IS a source URL, or null when the entry
367
- * is an ordinary citation.
483
+ * The href for a source that IS a URL, or null when it is a bundle path or a
484
+ * scope descriptor.
368
485
  *
369
- * Deliberately narrow in two directions. The WHOLE entry must be the URL —
370
- * linkifying a fragment inside "See https://x for the signed copy" would have
371
- * to guess where the URL ends, and the citation is the entry, not the fragment.
372
- * And only `http(s)` is accepted: `provenance` is AUTHORED content, so a
486
+ * Only `http(s)` is accepted: `resource` is AUTHORED content, so a
373
487
  * `javascript:` or `data:` entry rendered into an href would let the record
374
488
  * execute a script in the page that serves it. Other schemes (`mailto:`,
375
- * `ftp:`) are refused as citations rather than links widen only with a reason.
489
+ * `ftp:`) are refused as links rather than widened without a reason.
376
490
  */
377
- export function sourceHref(entry: string): string | null {
378
- const value = entry.trim();
491
+ export function sourceHref(resource: string): string | null {
492
+ const value = resource.trim();
379
493
  if (value === "" || /\s/.test(value)) return null;
380
494
  let url: URL;
381
495
  try {
@@ -393,7 +507,7 @@ export function sourceHref(entry: string): string | null {
393
507
  export interface SupersessionPointer {
394
508
  /** Path under `knowledge/`, e.g. `policies/purchase-approval-2019.md`. */
395
509
  readonly path: string;
396
- /** Its `superseded_by:` value, or null. */
510
+ /** Its `ksor.superseded_by` value, or null. */
397
511
  readonly supersededBy: string | null;
398
512
  }
399
513
 
@@ -407,8 +521,8 @@ export interface SupersessionPointer {
407
521
  * `Obsoleted by:` on the old).
408
522
  *
409
523
  * Derived, never declared — there is no new frontmatter key. Each pointer is
410
- * resolved through the same path-relative rule the forward notice uses, so the
411
- * two directions can never disagree about what points where.
524
+ * resolved through the same rule the forward notice uses, so the two
525
+ * directions can never disagree about what points where.
412
526
  */
413
527
  export function predecessorsOf(
414
528
  currentUrl: string,
@@ -419,7 +533,7 @@ export function predecessorsOf(
419
533
  const found: string[] = [];
420
534
  for (const pointer of pointers) {
421
535
  if (pointer.supersededBy === null) continue;
422
- const resolved = resolveSuccessorUrl(pointer.supersededBy, pointer.path, pages);
536
+ const resolved = resolveSuccessorUrl(pointer.supersededBy, pages);
423
537
  if (resolved === null || resolved.split("#")[0] !== currentUrl) continue;
424
538
  // A document that supersedes itself would otherwise render "Replaces: this
425
539
  // page" on the page you are reading. `pnpm check` refuses it; this is the