@intentius/chant 0.84.0 → 0.86.0

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 (152) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/mcp/resource-handlers.d.ts +2 -1
  3. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +1 -0
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/tools/composites.d.ts +44 -0
  7. package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
  8. package/dist/cli/mcp/tools/search.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +13 -1
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/cli-support.d.ts +4 -0
  12. package/dist/components/cli-support.d.ts.map +1 -1
  13. package/dist/composite.d.ts +6 -0
  14. package/dist/composite.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +44 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
  20. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  21. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  22. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  23. package/dist/workspace/checks/records.d.ts +1 -0
  24. package/dist/workspace/checks/records.d.ts.map +1 -1
  25. package/dist/workspace/checks.d.ts +4 -0
  26. package/dist/workspace/checks.d.ts.map +1 -1
  27. package/dist/workspace/composites.d.ts +152 -0
  28. package/dist/workspace/composites.d.ts.map +1 -0
  29. package/dist/workspace/conformance/index.d.ts +211 -0
  30. package/dist/workspace/conformance/index.d.ts.map +1 -0
  31. package/dist/workspace/conformance/vitest.d.ts +11 -0
  32. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  33. package/dist/workspace/declaration.d.ts +28 -0
  34. package/dist/workspace/declaration.d.ts.map +1 -1
  35. package/dist/workspace/declaration.schema.json +40 -0
  36. package/dist/workspace/declared-kinds.d.ts +43 -0
  37. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  38. package/dist/workspace/graph-cli.d.ts +24 -2
  39. package/dist/workspace/graph-cli.d.ts.map +1 -1
  40. package/dist/workspace/intent-cli.d.ts +6 -1
  41. package/dist/workspace/intent-cli.d.ts.map +1 -1
  42. package/dist/workspace/intent-joins.d.ts +74 -9
  43. package/dist/workspace/intent-joins.d.ts.map +1 -1
  44. package/dist/workspace/intent.d.ts +90 -6
  45. package/dist/workspace/intent.d.ts.map +1 -1
  46. package/dist/workspace/ls.d.ts +31 -1
  47. package/dist/workspace/ls.d.ts.map +1 -1
  48. package/dist/workspace/member-commands.d.ts +7 -2
  49. package/dist/workspace/member-commands.d.ts.map +1 -1
  50. package/dist/workspace/reason-codes.d.ts +52 -2
  51. package/dist/workspace/reason-codes.d.ts.map +1 -1
  52. package/dist/workspace/record-sessions.d.ts +51 -0
  53. package/dist/workspace/record-sessions.d.ts.map +1 -0
  54. package/dist/workspace/record-source.d.ts +2 -0
  55. package/dist/workspace/record-source.d.ts.map +1 -1
  56. package/dist/workspace/records-cli.d.ts +65 -4
  57. package/dist/workspace/records-cli.d.ts.map +1 -1
  58. package/dist/workspace/records-since.d.ts +90 -0
  59. package/dist/workspace/records-since.d.ts.map +1 -0
  60. package/dist/workspace/records-write.d.ts +164 -0
  61. package/dist/workspace/records-write.d.ts.map +1 -0
  62. package/dist/workspace/records.d.ts +202 -15
  63. package/dist/workspace/records.d.ts.map +1 -1
  64. package/dist/workspace/runtimes.d.ts +60 -0
  65. package/dist/workspace/runtimes.d.ts.map +1 -0
  66. package/dist/workspace/status-gates.d.ts +90 -0
  67. package/dist/workspace/status-gates.d.ts.map +1 -0
  68. package/dist/workspace/status.d.ts +17 -0
  69. package/dist/workspace/status.d.ts.map +1 -1
  70. package/dist/workspace/work.d.ts +56 -0
  71. package/dist/workspace/work.d.ts.map +1 -0
  72. package/package.json +19 -1
  73. package/src/cli/handlers/graph.ts +4 -0
  74. package/src/cli/main.test.ts +9 -0
  75. package/src/cli/main.ts +56 -3
  76. package/src/cli/mcp/resource-handlers.ts +17 -0
  77. package/src/cli/mcp/server.test.ts +140 -4
  78. package/src/cli/mcp/server.ts +5 -1
  79. package/src/cli/mcp/tools/composites.ts +98 -0
  80. package/src/cli/mcp/tools/search.ts +47 -5
  81. package/src/cli/registry.ts +13 -1
  82. package/src/components/cli-support.test.ts +16 -0
  83. package/src/components/cli-support.ts +8 -2
  84. package/src/composite.ts +9 -0
  85. package/src/lexicon.ts +47 -0
  86. package/src/lifecycle/gate-ledger.ts +14 -0
  87. package/src/workspace/__fixtures__/contract-repo.ts +17 -0
  88. package/src/workspace/__fixtures__/sessions.ts +66 -0
  89. package/src/workspace/checks/records.ts +19 -0
  90. package/src/workspace/checks.test.ts +2 -0
  91. package/src/workspace/checks.ts +7 -1
  92. package/src/workspace/composites.schema.json +533 -0
  93. package/src/workspace/composites.test.ts +334 -0
  94. package/src/workspace/composites.ts +316 -0
  95. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  96. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  97. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  98. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
  99. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  100. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  101. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  102. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  103. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  104. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  105. package/src/workspace/conformance/conformance.test.ts +149 -0
  106. package/src/workspace/conformance/index.mjs +31 -0
  107. package/src/workspace/conformance/index.ts +453 -0
  108. package/src/workspace/conformance/vitest.ts +62 -0
  109. package/src/workspace/declaration.schema.json +40 -0
  110. package/src/workspace/declaration.ts +62 -0
  111. package/src/workspace/declared-kinds.test.ts +321 -0
  112. package/src/workspace/declared-kinds.ts +76 -0
  113. package/src/workspace/graph-cli.ts +40 -4
  114. package/src/workspace/intent-cli.ts +54 -7
  115. package/src/workspace/intent-joins.test.ts +60 -0
  116. package/src/workspace/intent-joins.ts +117 -20
  117. package/src/workspace/intent.schema.json +357 -19
  118. package/src/workspace/intent.test.ts +235 -20
  119. package/src/workspace/intent.ts +396 -51
  120. package/src/workspace/ls.schema.json +34 -0
  121. package/src/workspace/ls.ts +69 -4
  122. package/src/workspace/member-commands.ts +11 -5
  123. package/src/workspace/read-contract.test.ts +52 -3
  124. package/src/workspace/reason-codes.test.ts +48 -4
  125. package/src/workspace/reason-codes.ts +67 -2
  126. package/src/workspace/record-assets.test.ts +3 -1
  127. package/src/workspace/record-sessions.ts +105 -0
  128. package/src/workspace/record-source.ts +14 -5
  129. package/src/workspace/records-amend.schema.json +167 -0
  130. package/src/workspace/records-cli.ts +246 -19
  131. package/src/workspace/records-contract.test.ts +77 -2
  132. package/src/workspace/records-formats.test.ts +640 -0
  133. package/src/workspace/records-new.schema.json +158 -0
  134. package/src/workspace/records-quorum.test.ts +196 -0
  135. package/src/workspace/records-review.schema.json +202 -0
  136. package/src/workspace/records-sessions.test.ts +108 -0
  137. package/src/workspace/records-since.schema.json +193 -0
  138. package/src/workspace/records-since.test.ts +174 -0
  139. package/src/workspace/records-since.ts +259 -0
  140. package/src/workspace/records-write-contract.test.ts +125 -0
  141. package/src/workspace/records-write.test.ts +373 -0
  142. package/src/workspace/records-write.ts +736 -0
  143. package/src/workspace/records.schema.json +187 -9
  144. package/src/workspace/records.test.ts +93 -0
  145. package/src/workspace/records.ts +683 -49
  146. package/src/workspace/runtimes.ts +107 -0
  147. package/src/workspace/status-contract.test.ts +163 -0
  148. package/src/workspace/status-gates.ts +215 -0
  149. package/src/workspace/status.schema.json +69 -3
  150. package/src/workspace/status.ts +35 -2
  151. package/src/workspace/work.test.ts +388 -0
  152. package/src/workspace/work.ts +163 -0
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * A record kind is data. It says where its records live, which JSON Schema
5
5
  * they follow and which of their states are closed. This module reads every
6
- * record a kind locates, parses its front matter as the JSON subset of YAML,
6
+ * record a kind locates, parses its structured core (the front matter, as the
7
+ * JSON subset of YAML, or the whole file as one I-JSON object, ws-053),
7
8
  * validates it against the kind's schema and derives supersession from the
8
- * records' own `supersedes` links. It never writes a record.
9
+ * records' own `supersedes` links. It never writes a record; `records-write.ts`
10
+ * does, through the rules here (#2670).
9
11
  *
10
12
  * A record that fails any of that is still returned, with reason codes, and the
11
13
  * read succeeds. Only a failure to read the kind, its schema or the revision is
@@ -16,6 +18,8 @@
16
18
  * runs. The level-0 goldens (#2526) fail if a level-0 command loads it.
17
19
  */
18
20
 
21
+ import { createHash } from "node:crypto";
22
+ import { sha256Hex } from "../content-digest";
19
23
  import { readFileSync, statSync } from "node:fs";
20
24
  import { dirname, posix, relative, resolve, sep } from "node:path";
21
25
  import yaml from "js-yaml";
@@ -23,8 +27,10 @@ import { z } from "zod";
23
27
  import { importLexiconModule, registerLexiconDeclarations } from "../lexicon-module";
24
28
  import type { ReasonCode } from "./reason-codes";
25
29
  import { checkPins, pinEntries, type AssetPin } from "./record-assets";
30
+ import { joinSessions, type SessionCitation } from "./record-sessions";
26
31
  import type { RecordSource } from "./record-source";
27
32
  import type { WorkspaceTree } from "./tree";
33
+ import type { DecisionWork, WorkLink, WorkWarningCode } from "./work";
28
34
 
29
35
  // ── Reason codes ─────────────────────────────────────────────────────────────
30
36
 
@@ -33,9 +39,9 @@ import type { WorkspaceTree } from "./tree";
33
39
  * and a new code is a contract change (#2536).
34
40
  */
35
41
  export const RECORD_REASON_CODES = [
36
- /** No front matter, a YAML error, or a value outside the JSON subset of YAML. */
42
+ /** No front matter, a YAML error or a value outside the JSON subset of YAML, or for a JSON kind a file that is not one object or repeats a member name. */
37
43
  "record-unparseable",
38
- /** The front matter does not match the kind's schema. */
44
+ /** The record's front matter, or its JSON object, does not match the kind's schema. */
39
45
  "record-schema-invalid",
40
46
  /** Another record earlier in path order has the same id. */
41
47
  "record-id-duplicate",
@@ -43,6 +49,10 @@ export const RECORD_REASON_CODES = [
43
49
  "record-supersedes-unknown",
44
50
  /** A second closed record supersedes a record another one already superseded. */
45
51
  "record-supersedes-conflict",
52
+ /** A closed session's seal is not the digest of its text: it changed after it closed (#2673). */
53
+ "session-seal-mismatch",
54
+ /** A session's verdict names a record the kind's subject records do not have (#2673). */
55
+ "session-verdict-unknown-record",
46
56
  ] as const satisfies readonly ReasonCode[];
47
57
  export type RecordReasonCode = (typeof RECORD_REASON_CODES)[number];
48
58
 
@@ -62,11 +72,42 @@ export const RECORD_WARNING_CODES = [
62
72
  "asset-stale",
63
73
  /** A supersedes link from a record whose state is weaker than the one it names, so it has no effect yet (#2524 D4). */
64
74
  "record-supersedes-pending",
75
+ /**
76
+ * The kind's pins field is an empty list: the record cites no evidence and
77
+ * pins no file. Information for a reviewer, such as a decision made in a
78
+ * product's own design flow with nothing to cite (#2654).
79
+ */
80
+ "record-no-evidence",
81
+ /**
82
+ * A verdict in the kind's reviews list names no `digest`, so it is not bound
83
+ * to the text it judged. It still counts toward the quorum, and an
84
+ * amendment does not stop it counting (#2672).
85
+ */
86
+ "review-undigested",
65
87
  ] as const satisfies readonly ReasonCode[];
66
88
  export type RecordWarningCode = (typeof RECORD_WARNING_CODES)[number];
67
89
 
90
+ /**
91
+ * Why a verdict does not count toward a record's quorum (#2671). Closed, like
92
+ * the reason codes. A verdict with none of these counts.
93
+ */
94
+ export const REVIEW_REASON_CODES = [
95
+ /** The reviewer is the record's decider. */
96
+ "review-decider",
97
+ /** The reviewer holds the agent role in the trust policy at base. */
98
+ "review-agent",
99
+ /** A later verdict by the same principal, after normalising, replaces this one. */
100
+ "review-duplicate",
101
+ /** The verdict's digest is not the digest of the record's text now: the record changed after the verdict (#2672). */
102
+ "review-older-digest",
103
+ /** An attestation policy is active at base, and the verdict carries no seal. */
104
+ "review-unattested",
105
+ ] as const satisfies readonly ReasonCode[];
106
+ export type ReviewReasonCode = (typeof REVIEW_REASON_CODES)[number];
107
+
68
108
  export interface RecordWarning {
69
- code: RecordWarningCode;
109
+ /** A work kind's records also carry the codes of `WORK_WARNING_CODES` (#2683). */
110
+ code: RecordWarningCode | WorkWarningCode;
70
111
  message: string;
71
112
  }
72
113
 
@@ -108,6 +149,10 @@ export class RecordReadError extends Error {
108
149
 
109
150
  const idPattern = /^[a-z][a-z0-9-]*$/;
110
151
 
152
+ /** The formats a kind file may name. Closed. */
153
+ export const RECORD_FORMATS = ["markdown-front-matter", "json"] as const;
154
+ export type RecordFormat = (typeof RECORD_FORMATS)[number];
155
+
111
156
  /** The data a kind file exports as `recordKind`. */
112
157
  export const recordKindSchema = z
113
158
  .object({
@@ -121,25 +166,49 @@ export const recordKindSchema = z
121
166
  match: z.string().min(1),
122
167
  })
123
168
  .strict(),
124
- /** Only Markdown with YAML front matter for now. */
125
- format: z.literal("markdown-front-matter"),
169
+ /**
170
+ * How a file holds its record's structured core: `markdown-front-matter`,
171
+ * the YAML front matter of a Markdown file, or `json`, the whole file as
172
+ * one JSON object (ws-053).
173
+ */
174
+ format: z.enum(RECORD_FORMATS),
126
175
  schema: z
127
176
  .object({
128
177
  /** The schema's `$id`. A schema file with a different `$id` is refused. */
129
178
  id: z.string().min(1),
130
179
  /** The schema file, relative to the kind file's directory. */
131
180
  path: z.string().min(1),
181
+ /**
182
+ * Schema files the schema `$ref`s, each by its `$id` and path relative
183
+ * to the kind file's directory (ws-053). Each is checked for its `$id`
184
+ * and added to the validator before the schema compiles. Optional.
185
+ */
186
+ refs: z.array(z.object({ id: z.string().min(1), path: z.string().min(1) }).strict()).optional(),
132
187
  })
133
188
  .strict(),
134
- /** The front-matter field holding the record's id. */
135
- idField: z.string().min(1),
136
- /** The front-matter field holding the record's state. */
137
- stateField: z.string().min(1),
138
- states: z.array(z.string().min(1)).min(1),
189
+ /** The field holding the record's id. A kind has this or `idFrom`, never both. */
190
+ idField: z.string().min(1).optional(),
191
+ /**
192
+ * `sha256`: the record's id is the lowercase hex SHA-256 of the file's
193
+ * bytes, and the file name's stem (up to its first `.`) is the hash the
194
+ * name claims (ws-053). In place of `idField`.
195
+ */
196
+ idFrom: z.literal("sha256").optional(),
197
+ /**
198
+ * The field holding the record's state. `stateField`, `states` and
199
+ * `closedStates` are given together or not at all; a kind without them
200
+ * has no lifecycle, and its records have state null (ws-053).
201
+ */
202
+ stateField: z.string().min(1).optional(),
203
+ states: z.array(z.string().min(1)).min(1).optional(),
139
204
  /** States whose records are final. A `supersedes` link takes effect only from a record in one of them. */
140
- closedStates: z.array(z.string().min(1)),
141
- /** The front-matter list of links to superseded records, and the key in each entry that holds the target id. */
142
- supersedes: z.object({ field: z.string().min(1), key: z.string().min(1) }).strict(),
205
+ closedStates: z.array(z.string().min(1)).optional(),
206
+ /**
207
+ * The field of links to superseded records. With `key`, a list of objects
208
+ * whose `key` holds the target id; without it, one id or a list of ids
209
+ * (ws-053). Optional, and only on a kind with states.
210
+ */
211
+ supersedes: z.object({ field: z.string().min(1), key: z.string().min(1).optional() }).strict().optional(),
143
212
  /**
144
213
  * How strongly each state is approved (#2524 D4). With it, a supersedes
145
214
  * link takes effect when the new record's rank is above 0 and at least the
@@ -159,15 +228,84 @@ export const recordKindSchema = z
159
228
  * (#2549). Optional.
160
229
  */
161
230
  constrains: z.object({ field: z.string().min(1) }).strict().optional(),
231
+ /**
232
+ * The front-matter list of review verdicts and the field naming the
233
+ * decider (#2671, #2672). With it, each record gets a digest of its text
234
+ * without that list, and `records --json` computes its quorum. Each
235
+ * entry holds `reviewer`, `verdict` (agree, dissent or abstain) and
236
+ * optionally `digest`, `note`, `proposes`, `addressed_by` and
237
+ * `withdrawn_on`. Optional.
238
+ */
239
+ reviews: z.object({ field: z.string().min(1), decider: z.string().min(1) }).strict().optional(),
240
+ /**
241
+ * A review-session kind (#2673, #2650 C10): the front-matter list of the
242
+ * verdicts a session produced, the field that seals a closed session, and
243
+ * the records its verdicts name, as the kind file that locates them
244
+ * (relative to this kind file's directory). The entries of that kind's
245
+ * reviews list (its `reviews.field`, or `reviews`) name a session in
246
+ * `session`. Optional.
247
+ */
248
+ session: z
249
+ .object({
250
+ verdicts: z.string().min(1),
251
+ seal: z.string().min(1),
252
+ subjects: z.object({ kind: z.string().min(1) }).strict(),
253
+ })
254
+ .strict()
255
+ .optional(),
256
+ /**
257
+ * A work kind (#2683): the front-matter lists of the work ids a record
258
+ * needs and the decision ids it implements, the decision kind file those
259
+ * ids name (relative to this kind file), the state a ready record is in,
260
+ * the state that satisfies a need, and the field holding the closing
261
+ * date. With it, `work.ts` gives each record `ready`, `blockedBy` and
262
+ * `implements`, and the read lists each decision with `implementedBy`.
263
+ * Optional.
264
+ */
265
+ work: z
266
+ .object({
267
+ needs: z.string().min(1),
268
+ implements: z.string().min(1),
269
+ decisions: z.string().min(1),
270
+ open: z.string().min(1),
271
+ done: z.string().min(1),
272
+ closedOn: z.string().min(1),
273
+ })
274
+ .strict()
275
+ .optional(),
162
276
  })
163
277
  .strict()
164
- .refine((k) => k.closedStates.every((s) => k.states.includes(s)), {
278
+ .refine((k) => (k.idField === undefined) !== (k.idFrom === undefined), {
279
+ message: "a kind names its id with exactly one of idField and idFrom",
280
+ path: ["idField"],
281
+ })
282
+ .refine((k) => new Set([k.stateField === undefined, k.states === undefined, k.closedStates === undefined]).size === 1, {
283
+ message: "stateField, states and closedStates are given together or not at all",
284
+ path: ["states"],
285
+ })
286
+ .refine((k) => k.states !== undefined || k.supersedes === undefined, {
287
+ message: "a kind without states cannot have supersedes: a link takes effect only from a closed or ranked state",
288
+ path: ["supersedes"],
289
+ })
290
+ .refine((k) => k.states !== undefined || k.session === undefined, {
291
+ message: "a session kind must have states: a session is sealed when it reaches a closed state",
292
+ path: ["session"],
293
+ })
294
+ .refine((k) => k.states !== undefined || k.approval === undefined, {
295
+ message: "a kind without states cannot have approval ranks",
296
+ path: ["approval"],
297
+ })
298
+ .refine((k) => (k.closedStates ?? []).every((s) => (k.states ?? []).includes(s)), {
165
299
  message: "every closed state must be listed in states",
166
300
  path: ["closedStates"],
167
301
  })
168
- .refine((k) => Object.keys(k.approval ?? {}).every((s) => k.states.includes(s)), {
302
+ .refine((k) => Object.keys(k.approval ?? {}).every((s) => (k.states ?? []).includes(s)), {
169
303
  message: "every state approval ranks must be listed in states",
170
304
  path: ["approval"],
305
+ })
306
+ .refine((k) => !k.work || (k.states !== undefined && k.states.includes(k.work.open) && k.states.includes(k.work.done)), {
307
+ message: "a work kind must have states, and its work open and done states must be listed in them",
308
+ path: ["work"],
171
309
  });
172
310
 
173
311
  export type RecordKind = z.infer<typeof recordKindSchema>;
@@ -180,6 +318,8 @@ export interface LoadedRecordKind {
180
318
  /** Absolute path of the records directory in the working tree. */
181
319
  dir: string;
182
320
  schema: Record<string, unknown>;
321
+ /** The schema files `schema.refs` names, in order (ws-053). Empty without it. */
322
+ refs: Record<string, unknown>[];
183
323
  }
184
324
 
185
325
  /**
@@ -219,20 +359,26 @@ export async function loadRecordKind(path: string, cwd: string = process.cwd()):
219
359
  }
220
360
  const kind = parsed.data;
221
361
  const base = dirname(file);
222
- const schemaFile = resolve(base, kind.schema.path);
362
+ const schema = readSchemaFile(kind, base, path, kind.schema);
363
+ const refs = (kind.schema.refs ?? []).map((ref) => readSchemaFile(kind, base, path, ref));
364
+ return { kind, file, dir: resolve(base, kind.location.dir), schema, refs };
365
+ }
366
+
367
+ /** A schema file a kind names, refused when it can't be read or its `$id` is not the one the kind names. */
368
+ function readSchemaFile(kind: RecordKind, base: string, kindPath: string, named: { id: string; path: string }): Record<string, unknown> {
223
369
  let schema: Record<string, unknown>;
224
370
  try {
225
- schema = JSON.parse(readFileSync(schemaFile, "utf-8")) as Record<string, unknown>;
371
+ schema = JSON.parse(readFileSync(resolve(base, named.path), "utf-8")) as Record<string, unknown>;
226
372
  } catch (err) {
227
- throw new RecordReadError("schema-unreadable", `schema ${kind.schema.path} named by ${path} could not be read: ${message(err)}`);
373
+ throw new RecordReadError("schema-unreadable", `schema ${named.path} named by ${kindPath} could not be read: ${message(err)}`);
228
374
  }
229
- if (schema.$id !== kind.schema.id) {
375
+ if (schema === null || typeof schema !== "object" || schema.$id !== named.id) {
230
376
  throw new RecordReadError(
231
377
  "schema-id-mismatch",
232
- `kind ${kind.name} names schema ${kind.schema.id}, but ${kind.schema.path} has $id ${JSON.stringify(schema.$id)}`,
378
+ `kind ${kind.name} names schema ${named.id}, but ${named.path} has $id ${JSON.stringify(schema?.$id)}`,
233
379
  );
234
380
  }
235
- return { kind, file, dir: resolve(base, kind.location.dir), schema };
381
+ return schema;
236
382
  }
237
383
 
238
384
  // ── Front matter ─────────────────────────────────────────────────────────────
@@ -262,6 +408,120 @@ export function parseFrontMatter(text: string): FrontMatter {
262
408
  return { ok: true, value: value as Record<string, unknown> };
263
409
  }
264
410
 
411
+ // ── JSON records ─────────────────────────────────────────────────────────────
412
+
413
+ /**
414
+ * A JSON record: the whole file is one object, read as I-JSON (RFC 7493)
415
+ * requires on the two points JSON.parse lets through (ws-053). A top-level
416
+ * value other than an object is refused, and so is a member name that repeats
417
+ * within one object, at any depth, which JSON.parse accepts by keeping the
418
+ * last value. Names compare after their escapes are decoded, so `"a"` and
419
+ * `"\u0061"` are the same name. A number too large for a double is refused as
420
+ * front matter's is.
421
+ */
422
+ export function parseJsonRecord(text: string): FrontMatter {
423
+ let value: unknown;
424
+ try {
425
+ value = JSON.parse(text);
426
+ } catch (err) {
427
+ return { ok: false, message: `not valid JSON: ${message(err).split("\n")[0]}` };
428
+ }
429
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
430
+ return { ok: false, message: "a JSON record must be one object at the top level" };
431
+ }
432
+ const scan = scanJson(text);
433
+ if (scan.duplicate) return { ok: false, message: `${scan.duplicate.at || "/"}: member name ${JSON.stringify(scan.duplicate.name)} repeats, which I-JSON refuses` };
434
+ const problem = nonJson(value, "", new Set());
435
+ if (problem) return { ok: false, message: problem };
436
+ return { ok: true, value: value as Record<string, unknown> };
437
+ }
438
+
439
+ /** A record's structured core, parsed as the kind's format says. */
440
+ export function parseRecord(format: RecordFormat, text: string): FrontMatter {
441
+ return format === "json" ? parseJsonRecord(text) : parseFrontMatter(text);
442
+ }
443
+
444
+ /** Where one top-level member of a JSON object sits in its text. */
445
+ interface JsonMember {
446
+ name: string;
447
+ /** Index of the opening quote of the member's name. */
448
+ start: number;
449
+ /** Index just past the last character of the member's value. */
450
+ end: number;
451
+ }
452
+
453
+ /**
454
+ * One pass over text JSON.parse accepted: the top-level object's members, with
455
+ * where each starts and ends, and the first member name that repeats within
456
+ * one object at any depth.
457
+ */
458
+ function scanJson(text: string): { members: JsonMember[]; duplicate?: { name: string; at: string } } {
459
+ const members: JsonMember[] = [];
460
+ // One frame per open object or array: an object's names so far, or null for an array.
461
+ const stack: { names: Set<string> | null; path: string; key?: string; index: number }[] = [];
462
+ let pendingTop: { name: string; start: number } | undefined;
463
+ let valueStart = -1;
464
+ const closeValue = (end: number): void => {
465
+ // A value just ended at `end`; if it is a top-level member's value, record the member.
466
+ if (stack.length === 1 && pendingTop) {
467
+ members.push({ ...pendingTop, end });
468
+ pendingTop = undefined;
469
+ }
470
+ };
471
+ let i = 0;
472
+ while (i < text.length) {
473
+ const c = text[i];
474
+ if (c === '"') {
475
+ let j = i + 1;
476
+ while (text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
477
+ const raw = text.slice(i, j + 1);
478
+ let k = j + 1;
479
+ while (k < text.length && /[ \t\n\r]/.test(text[k])) k++;
480
+ const top = stack[stack.length - 1];
481
+ if (text[k] === ":" && top?.names) {
482
+ const name = JSON.parse(raw) as string;
483
+ if (top.names.has(name)) return { members, duplicate: { name, at: top.path } };
484
+ top.names.add(name);
485
+ top.key = name;
486
+ if (stack.length === 1) pendingTop = { name, start: i };
487
+ i = k + 1;
488
+ continue;
489
+ }
490
+ closeValue(j + 1);
491
+ i = j + 1;
492
+ continue;
493
+ }
494
+ if (c === "{" || c === "[") {
495
+ const parent = stack[stack.length - 1];
496
+ const at = parent ? `${parent.path}/${parent.names ? parent.key : parent.index}` : "";
497
+ stack.push({ names: c === "{" ? new Set() : null, path: at, index: 0 });
498
+ i++;
499
+ continue;
500
+ }
501
+ if (c === "}" || c === "]") {
502
+ stack.pop();
503
+ closeValue(i + 1);
504
+ i++;
505
+ continue;
506
+ }
507
+ if (c === ",") {
508
+ const top = stack[stack.length - 1];
509
+ if (top && !top.names) top.index++;
510
+ i++;
511
+ continue;
512
+ }
513
+ if (/[ \t\n\r:]/.test(c)) {
514
+ i++;
515
+ continue;
516
+ }
517
+ // A number, true, false or null.
518
+ valueStart = i;
519
+ while (i < text.length && !/[ \t\n\r,\]}]/.test(text[i])) i++;
520
+ if (valueStart < i) closeValue(i);
521
+ }
522
+ return { members };
523
+ }
524
+
265
525
  /** The first thing in `v` that JSON cannot say, or that only an alias can produce. */
266
526
  function nonJson(v: unknown, at: string, seen: Set<object>): string | undefined {
267
527
  if (v === null || typeof v === "string" || typeof v === "boolean") return undefined;
@@ -284,6 +544,241 @@ function nonJson(v: unknown, at: string, seen: Set<object>): string | undefined
284
544
  return undefined;
285
545
  }
286
546
 
547
+ // ── Record digest ────────────────────────────────────────────────────────────
548
+
549
+ /**
550
+ * The digest a review verdict names: the lowercase hex SHA-256 of the record
551
+ * file's text with its reviews block taken out of the front matter (#2672), or
552
+ * for a JSON record its reviews member taken out of the object (ws-053).
553
+ * Adding, changing or removing a verdict leaves it as it was; any other edit
554
+ * to the file changes it, so a verdict given before an amendment stops
555
+ * counting.
556
+ *
557
+ * The rule, which a hand-editor can follow with a text editor and
558
+ * `sha256sum`:
559
+ *
560
+ * 1. Line endings become LF (CRLF and a lone CR each become one LF).
561
+ * 2. When the text starts with a `---` line and a later line is exactly
562
+ * `---`, the lines between them are the front matter. In it, the line
563
+ * that starts, at column 0, with the key `field` (bare, or in single or
564
+ * double quotes), optional spaces or tabs and a `:`, is removed, and so
565
+ * is every line after it, up to the closing `---`, that is empty or
566
+ * starts with a space, a tab, `#` or `-`. Removal stops at the first
567
+ * other line. Everything else, the `---` lines and the body included, is
568
+ * kept byte for byte.
569
+ * 3. The digest is the SHA-256 of the result's UTF-8 bytes.
570
+ *
571
+ * Text with no front matter, or no such key, is hashed after step 1 alone.
572
+ * A writer that adds a verdict must change only the reviews block: a
573
+ * reformatted front matter is a new digest, and every earlier verdict stops
574
+ * counting.
575
+ */
576
+ export function recordTextDigest(text: string, field: string | null = "reviews", format: RecordFormat = "markdown-front-matter"): string {
577
+ const lf = text.replace(/\r\n?/g, "\n");
578
+ const kept = field === null ? lf : format === "json" ? withoutMember(lf, field) : withoutBlock(lf, field);
579
+ return createHash("sha256").update(kept, "utf8").digest("hex");
580
+ }
581
+
582
+ /**
583
+ * `text` with the top-level member `field` removed from a JSON record, by the
584
+ * rule a hand-editor follows for a JSON record (ws-053):
585
+ *
586
+ * 1. Line endings become LF, as for Markdown.
587
+ * 2. When the text is a JSON record (one object, no repeated member name) and
588
+ * its top-level object has a member named `field`, that member is deleted:
589
+ * the whitespace right before its name, the name, the colon, the value,
590
+ * and one comma with the whitespace right before that comma. The comma is
591
+ * the one after the value when another member follows, or else the one
592
+ * before the member, when a member precedes it. Nothing else changes: other
593
+ * members, their order, the indentation and the final newline stay byte
594
+ * for byte.
595
+ * 3. The digest is the SHA-256 of the result's UTF-8 bytes.
596
+ *
597
+ * So in a pretty-printed file, deleting the `"reviews": [...]` lines and the
598
+ * comma that separated the member from its neighbour gives the text to hash.
599
+ * Text that is not a JSON record, or has no such member, is hashed after step 1
600
+ * alone. A writer that adds a verdict changes only that member's value.
601
+ */
602
+ function withoutMember(text: string, field: string): string {
603
+ const parsed = parseJsonRecord(text);
604
+ if (!parsed.ok) return text;
605
+ const { members } = scanJson(text);
606
+ const at = members.findIndex((m) => m.name === field);
607
+ if (at < 0) return text;
608
+ const m = members[at];
609
+ let start = m.start;
610
+ while (start > 0 && /[ \t\n]/.test(text[start - 1])) start--;
611
+ let end = m.end;
612
+ if (at + 1 < members.length) {
613
+ // The comma after the value, and the whitespace before it.
614
+ let k = end;
615
+ while (/[ \t\n]/.test(text[k])) k++;
616
+ end = k + 1;
617
+ } else if (at > 0) {
618
+ // The comma before the member, and the whitespace between it and the previous value.
619
+ let k = start - 1;
620
+ while (k > 0 && text[k] !== ",") k--;
621
+ while (k > 0 && /[ \t\n]/.test(text[k - 1])) k--;
622
+ start = k;
623
+ }
624
+ return text.slice(0, start) + text.slice(end);
625
+ }
626
+
627
+ /** `text` with the top-level `field` block removed from its front matter, by the rule {@link recordTextDigest} states. */
628
+ function withoutBlock(text: string, field: string): string {
629
+ const lines = text.split("\n");
630
+ if (lines[0] !== "---") return text;
631
+ const close = lines.indexOf("---", 1);
632
+ if (close < 0) return text;
633
+ const key = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
634
+ const starts = new RegExp(`^(?:${key}|"${key}"|'${key}')[ \\t]*:(?:[ \\t]|$)`);
635
+ const out: string[] = [];
636
+ for (let i = 0; i < lines.length; i++) {
637
+ if (i > 0 && i < close && starts.test(lines[i])) {
638
+ while (i + 1 < close && /^(?:$|[ \t#-])/.test(lines[i + 1])) i++;
639
+ continue;
640
+ }
641
+ out.push(lines[i]);
642
+ }
643
+ return out.join("\n");
644
+ }
645
+
646
+ // ── Quorum ───────────────────────────────────────────────────────────────────
647
+
648
+ /** The quorum a workspace sets when its declaration names none: two verdicts besides the decider's (#2555). */
649
+ export const DEFAULT_QUORUM = 2;
650
+
651
+ /** A principal as the quorum compares it: NFKC, trimmed and lower-cased, so `alice` and `Alice ` are one reviewer (#2671). */
652
+ export function normalisePrincipal(name: string): string {
653
+ return name.normalize("NFKC").trim().toLowerCase();
654
+ }
655
+
656
+ /** One verdict as the quorum reads it. */
657
+ export interface QuorumVerdict {
658
+ /** The entry's position in the record's reviews list, from 0. */
659
+ index: number;
660
+ /** The reviewer, normalised. */
661
+ principal: string;
662
+ /** The reviewer as written. */
663
+ reviewer: string;
664
+ verdict: "agree" | "dissent" | "abstain";
665
+ /** The digest the verdict names, or null when it names none. */
666
+ digest: string | null;
667
+ /** Why it does not count. Absent on a counted verdict. */
668
+ reason?: { code: ReviewReasonCode; message: string };
669
+ }
670
+
671
+ /** A dissent that is neither addressed nor withdrawn. */
672
+ export interface OpenConcern {
673
+ index: number;
674
+ principal: string;
675
+ reviewer: string;
676
+ note: string | null;
677
+ /** The proposed decision the dissent opened, when it names one. */
678
+ proposes?: string;
679
+ }
680
+
681
+ export interface Quorum {
682
+ /** How many counted agree verdicts, besides the decider's, the record needs. */
683
+ need: number;
684
+ /** Whether `need` comes from the workspace declaration or is the default. */
685
+ needFrom: "declaration" | "default";
686
+ /** Counted verdicts that agree. */
687
+ agreed: number;
688
+ /** Verdicts that count: one per principal, the latest, after the exclusions. */
689
+ counted: QuorumVerdict[];
690
+ /** Verdicts that don't, each with its reason. */
691
+ notCounted: QuorumVerdict[];
692
+ openConcerns: OpenConcern[];
693
+ /** `agreed` reaches `need`. */
694
+ met: boolean;
695
+ /** `met`, with at least one open concern. A met quorum with an open concern is never consensus (RFC 7282). */
696
+ metWithObjections: boolean;
697
+ }
698
+
699
+ export interface QuorumOptions {
700
+ need: number;
701
+ needFrom: "declaration" | "default";
702
+ /** Normalised principals that hold the agent role. */
703
+ agents: ReadonlySet<string>;
704
+ /** Whether an attestation policy is active, so a verdict needs a seal to count. */
705
+ attestation: boolean;
706
+ }
707
+
708
+ const VERDICTS = new Set(["agree", "dissent", "abstain"]);
709
+
710
+ /**
711
+ * The quorum of one record, read from its reviews list, or null when the
712
+ * kind has no reviews list or the front matter could not be read (#2671).
713
+ * Malformed entries are skipped; the schema reports them.
714
+ *
715
+ * A verdict is not counted when its reviewer is the decider, holds the agent
716
+ * role, names a digest other than the record's own now, or carries no seal
717
+ * under an active attestation policy, in that order. Of the rest, the latest
718
+ * verdict per principal counts and each earlier one is a duplicate. No
719
+ * verdict carries a seal yet (#2546), so under an active policy none counts.
720
+ */
721
+ export function computeQuorum(kind: RecordKind, entry: Pick<RecordEntry, "data" | "digest">, options: QuorumOptions): Quorum | null {
722
+ if (!kind.reviews || entry.data === null) return null;
723
+ const list = entry.data[kind.reviews.field];
724
+ const decidedBy = entry.data[kind.reviews.decider];
725
+ const decider = typeof decidedBy === "string" ? normalisePrincipal(decidedBy) : null;
726
+ const verdicts: QuorumVerdict[] = [];
727
+ const openConcerns: OpenConcern[] = [];
728
+ (Array.isArray(list) ? list : []).forEach((raw, index) => {
729
+ if (raw === null || typeof raw !== "object") return;
730
+ const r = raw as Record<string, unknown>;
731
+ if (typeof r.reviewer !== "string" || typeof r.verdict !== "string" || !VERDICTS.has(r.verdict)) return;
732
+ const v: QuorumVerdict = {
733
+ index,
734
+ principal: normalisePrincipal(r.reviewer),
735
+ reviewer: r.reviewer,
736
+ verdict: r.verdict as QuorumVerdict["verdict"],
737
+ digest: typeof r.digest === "string" ? r.digest : null,
738
+ };
739
+ if (v.principal === decider) {
740
+ v.reason = { code: "review-decider", message: `${v.reviewer} decided this record, and the quorum counts verdicts besides the decider's` };
741
+ } else if (options.agents.has(v.principal)) {
742
+ v.reason = { code: "review-agent", message: `${v.reviewer} holds the agent role in the trust policy at base, and an agent's verdict does not count` };
743
+ } else if (v.digest !== null && v.digest !== entry.digest) {
744
+ v.reason = { code: "review-older-digest", message: `${v.reviewer} judged the text at digest ${v.digest.slice(0, 12)}, and the record's text is now at ${entry.digest.slice(0, 12)}` };
745
+ } else if (options.attestation) {
746
+ v.reason = { code: "review-unattested", message: `an attestation policy is active at base, and the verdict by ${v.reviewer} carries no seal` };
747
+ }
748
+ verdicts.push(v);
749
+ if (v.verdict === "dissent" && r.addressed_by == null && r.withdrawn_on == null) {
750
+ openConcerns.push({
751
+ index,
752
+ principal: v.principal,
753
+ reviewer: v.reviewer,
754
+ note: typeof r.note === "string" ? r.note : null,
755
+ ...(typeof r.proposes === "string" ? { proposes: r.proposes } : {}),
756
+ });
757
+ }
758
+ });
759
+ // The latest verdict per principal stands; earlier ones are duplicates.
760
+ const latest = new Map<string, QuorumVerdict>();
761
+ for (const v of verdicts) if (!v.reason) latest.set(v.principal, v);
762
+ for (const v of verdicts) {
763
+ if (v.reason || latest.get(v.principal) === v) continue;
764
+ const later = latest.get(v.principal)!;
765
+ v.reason = { code: "review-duplicate", message: `the later verdict by ${JSON.stringify(later.reviewer)} (entry ${later.index}) replaces this one, and a principal counts once` };
766
+ }
767
+ const counted = verdicts.filter((v) => !v.reason);
768
+ const agreed = counted.filter((v) => v.verdict === "agree").length;
769
+ const met = agreed >= options.need;
770
+ return {
771
+ need: options.need,
772
+ needFrom: options.needFrom,
773
+ agreed,
774
+ counted,
775
+ notCounted: verdicts.filter((v) => v.reason),
776
+ openConcerns,
777
+ met,
778
+ metWithObjections: met && openConcerns.length > 0,
779
+ };
780
+ }
781
+
287
782
  // ── Reading ──────────────────────────────────────────────────────────────────
288
783
 
289
784
  export interface RecordReason {
@@ -302,12 +797,26 @@ export interface RecordEntry {
302
797
  reasons: RecordReason[];
303
798
  /** The id of the closed record whose `supersedes` link replaces this one, or null. */
304
799
  supersededBy: string | null;
305
- /** The front matter as JSON, or null when it could not be parsed. */
800
+ /** The record's structured core as JSON (the front matter, or the whole JSON file), or null when it could not be parsed. */
306
801
  data: Record<string, unknown> | null;
307
- /** Each workspace file the record pins, checked against the tree read (#2549). Empty when nothing was checked. */
802
+ /**
803
+ * Each workspace file the record pins, checked against the tree read
804
+ * (#2549). Empty when nothing was checked. A content-addressed record whose
805
+ * name claims a hash other than its bytes' lists itself, drifted (ws-053).
806
+ */
308
807
  assets: AssetPin[];
309
808
  /** Findings that leave the record valid, such as a pinned file that changed (#2549). */
310
809
  warnings: RecordWarning[];
810
+ /** {@link recordTextDigest} of the file's text, without the kind's reviews list when it has one (#2672). */
811
+ digest: string;
812
+ /** For a session kind only: the subject records' review entries that name this session (#2673). */
813
+ citedBy?: SessionCitation[];
814
+ /** For a work kind (#2683): the record's state is its kind's open state and every need is done. */
815
+ ready?: boolean;
816
+ /** For a work kind: each need that is not done, with its state, or null when no record has the id. */
817
+ blockedBy?: WorkLink[];
818
+ /** For a work kind: each decision the record implements, with its state, or null when no decision has the id. */
819
+ implements?: WorkLink[];
311
820
  }
312
821
 
313
822
  export interface ReadRecordsOptions {
@@ -327,6 +836,18 @@ export interface ReadRecordsOptions {
327
836
  * compared.
328
837
  */
329
838
  history?: RecordHistory;
839
+ /**
840
+ * For a session kind: the records its `session.subjects.kind` locates, read
841
+ * from the same tree (#2673). Without them no verdict is checked and no
842
+ * session is cited.
843
+ */
844
+ subjects?: { records: RecordEntry[]; reviews: string };
845
+ /**
846
+ * The workspace root, from `root` with / separators ("." for `root`
847
+ * itself): where a content-addressed record's own path is reported from
848
+ * when it lists itself in `assets` (ws-053). Defaults to ".".
849
+ */
850
+ workspaceRoot?: string;
330
851
  }
331
852
 
332
853
  /** Commit times, in seconds since the epoch, read from git. */
@@ -340,28 +861,90 @@ export interface RecordHistory {
340
861
  export interface ReadRecordsResult {
341
862
  records: RecordEntry[];
342
863
  summary: { total: number; valid: number; invalid: number; superseded: number };
864
+ /** For a work kind (#2683): every decision its decision kind reads, with the work records implementing it. */
865
+ decisions?: DecisionWork[];
343
866
  }
344
867
 
345
868
  type Validator = (data: unknown) => { ok: true } | { ok: false; errors: string[] };
346
869
 
347
- async function compileSchema(schema: Record<string, unknown>): Promise<Validator> {
870
+ /** One ajv error, compiled with `verbose` so the failing schema and data come with it. */
871
+ interface SchemaError {
872
+ instancePath: string;
873
+ schemaPath: string;
874
+ keyword: string;
875
+ message?: string;
876
+ params?: { failingKeyword?: string };
877
+ parentSchema?: Record<string, unknown>;
878
+ data?: unknown;
879
+ }
880
+
881
+ /**
882
+ * ajv's errors as `<path> <message>` lines. A failed `if` whose `then` or
883
+ * `else` branch has a `description` is reported by that description alone, in
884
+ * place of the branch's own errors, with each `{field}` filled from the value
885
+ * the branch checked. The decision schema words its dissent rule this way, so
886
+ * the message names the reviewer (#2652). The schema stays plain JSON Schema,
887
+ * with no keyword a strict validator would refuse.
888
+ */
889
+ function renderSchemaErrors(errors: readonly SchemaError[]): string[] {
890
+ const replaced: string[] = [];
891
+ const described = new Map<SchemaError, string>();
892
+ for (const e of errors) {
893
+ const branch = e.keyword === "if" ? e.params?.failingKeyword : undefined;
894
+ const text = branch ? (e.parentSchema?.[branch] as { description?: unknown } | undefined)?.description : undefined;
895
+ if (!branch || typeof text !== "string") continue;
896
+ const at = e.data !== null && typeof e.data === "object" ? (e.data as Record<string, unknown>) : {};
897
+ described.set(e, text.replace(/\{([A-Za-z0-9_]+)\}/g, (all, key: string) => (typeof at[key] === "string" ? (at[key] as string) : all)));
898
+ replaced.push(`${e.schemaPath.replace(/\/if$/, "")}/${branch}/`);
899
+ }
900
+ return errors
901
+ .filter((e) => described.has(e) || !replaced.some((prefix) => e.schemaPath.startsWith(prefix)))
902
+ .map((e) => `${e.instancePath || "/"} ${described.get(e) ?? e.message ?? "is invalid"}`);
903
+ }
904
+
905
+ async function compileSchema(schema: Record<string, unknown>, refs: readonly Record<string, unknown>[] = []): Promise<Validator> {
348
906
  const mod = (await import("ajv")) as unknown as { default: unknown };
349
907
  // ajv is CommonJS; its class is the default export, or that export's own default.
350
908
  const Ajv = ((mod.default as { default?: unknown }).default ?? mod.default) as new (opts: object) => {
351
- compile(s: object): ((d: unknown) => boolean) & { errors?: Array<{ instancePath: string; message?: string }> | null };
909
+ addSchema(s: object): unknown;
910
+ compile(s: object): ((d: unknown) => boolean) & { errors?: SchemaError[] | null };
352
911
  };
353
912
  let validate: ReturnType<InstanceType<typeof Ajv>["compile"]>;
354
913
  try {
355
- validate = new Ajv({ allErrors: true, strict: false }).compile(schema);
914
+ const ajv = new Ajv({ allErrors: true, strict: false, verbose: true });
915
+ // The files the schema $refs, registered by their $id first (ws-053).
916
+ for (const ref of refs) ajv.addSchema(ref);
917
+ validate = ajv.compile(schema);
356
918
  } catch (err) {
357
919
  throw new RecordReadError("schema-invalid", `the kind's schema does not compile: ${message(err)}`);
358
920
  }
359
- return (data) =>
360
- validate(data)
361
- ? { ok: true }
362
- : { ok: false, errors: (validate.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message ?? "is invalid"}`) };
921
+ return (data) => (validate(data) ? { ok: true } : { ok: false, errors: renderSchemaErrors(validate.errors ?? []) });
363
922
  }
364
923
 
924
+ /**
925
+ * The ids a record's supersedes field names (ws-053). With the kind's `key`,
926
+ * the field is a list of objects and each one's `key` holds an id; without
927
+ * it, the field holds one id or a list of ids. Anything else names none.
928
+ */
929
+ export function supersedesTargets(kind: Pick<RecordKind, "supersedes">, data: Record<string, unknown> | null): string[] {
930
+ if (!kind.supersedes || data === null) return [];
931
+ const { field, key } = kind.supersedes;
932
+ const value = data[field];
933
+ if (key === undefined && typeof value === "string") return [value];
934
+ if (!Array.isArray(value)) return [];
935
+ const ids = key === undefined ? value : value.map((l) => (l !== null && typeof l === "object" ? (l as Record<string, unknown>)[key] : undefined));
936
+ return ids.filter((x): x is string => typeof x === "string");
937
+ }
938
+
939
+ /** The stem of a file name: the name up to its first `.`, the hash a content-addressed record's name claims (ws-053). */
940
+ function nameStem(path: string): string {
941
+ const name = path.slice(path.lastIndexOf("/") + 1);
942
+ const dot = name.indexOf(".");
943
+ return dot < 0 ? name : name.slice(0, dot);
944
+ }
945
+
946
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
947
+
365
948
  /** Read every record `loaded` locates, through `options.source`. */
366
949
  export async function readRecords(loaded: LoadedRecordKind, options: ReadRecordsOptions): Promise<ReadRecordsResult> {
367
950
  const { kind } = loaded;
@@ -371,31 +954,81 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
371
954
  throw new RecordReadError("location-missing", `records directory ${dirRel} does not exist${options.source.label}`);
372
955
  }
373
956
  const match = new RegExp(kind.location.match);
374
- const validate = await compileSchema(loaded.schema);
957
+ const validate = await compileSchema(loaded.schema, loaded.refs);
958
+ const workspaceRoot = options.workspaceRoot ?? ".";
375
959
 
376
960
  const entries: RecordEntry[] = [];
961
+ const texts = new Map<string, string>();
377
962
  for (const name of names.filter((n) => match.test(n)).sort()) {
378
963
  const path = dirRel === "." ? name : `${dirRel}/${name}`;
379
- const entry: RecordEntry = { id: null, path, state: null, valid: true, reasons: [], supersededBy: null, data: null, assets: [], warnings: [] };
964
+ const text = options.source.read(path);
965
+ const entry: RecordEntry = {
966
+ id: null,
967
+ path,
968
+ state: null,
969
+ valid: true,
970
+ reasons: [],
971
+ supersededBy: null,
972
+ data: null,
973
+ assets: [],
974
+ warnings: [],
975
+ digest: recordTextDigest(text, kind.reviews?.field ?? null, kind.format),
976
+ };
380
977
  entries.push(entry);
381
- const fm = parseFrontMatter(options.source.read(path));
978
+ if (kind.session) texts.set(path, text);
979
+ const fm = parseRecord(kind.format, text);
382
980
  if (!fm.ok) {
383
981
  entry.reasons.push({ code: "record-unparseable", message: fm.message });
384
982
  continue;
385
983
  }
386
984
  entry.data = fm.value;
387
- const id = fm.value[kind.idField];
388
- const state = fm.value[kind.stateField];
389
- if (typeof id === "string") entry.id = id;
985
+ if (kind.idFrom === "sha256") {
986
+ // The id is the hash of the bytes; the name's stem is the hash it claims.
987
+ // A name that claims another is the record pinning itself, drifted (ws-053).
988
+ entry.id = sha256Hex(options.source.bytes(path));
989
+ const stem = nameStem(path);
990
+ if (stem !== entry.id) {
991
+ const self = workspaceRoot === "." ? path : path.startsWith(`${workspaceRoot}/`) ? path.slice(workspaceRoot.length + 1) : path;
992
+ if (SHA256_HEX.test(stem)) entry.assets.push({ path: self, sha256: stem, actual: entry.id, state: "drifted" });
993
+ entry.warnings.push({
994
+ code: "asset-drift",
995
+ message: SHA256_HEX.test(stem)
996
+ ? `${self} is named for sha256 ${stem.slice(0, 12)}, and its bytes${options.source.label} hash to ${entry.id.slice(0, 12)}`
997
+ : `${self} is content-addressed, and its name claims no sha256: its bytes${options.source.label} hash to ${entry.id.slice(0, 12)}`,
998
+ });
999
+ }
1000
+ } else {
1001
+ const id = fm.value[kind.idField!];
1002
+ if (typeof id === "string") entry.id = id;
1003
+ }
1004
+ const state = kind.stateField === undefined ? undefined : fm.value[kind.stateField];
390
1005
  if (typeof state === "string") entry.state = state;
391
1006
  const result = validate(fm.value);
392
1007
  if (!result.ok) {
393
1008
  entry.reasons.push({ code: "record-schema-invalid", message: result.errors.join("; ") });
394
1009
  }
395
- if (kind.pins && options.assets) {
396
- const checked = checkPins(pinEntries(fm.value, kind.pins.field), options.assets);
397
- entry.assets = checked.assets;
398
- entry.warnings = checked.warnings;
1010
+ if (kind.pins) {
1011
+ const cited = fm.value[kind.pins.field];
1012
+ if (Array.isArray(cited) && cited.length === 0) {
1013
+ entry.warnings.push({ code: "record-no-evidence", message: `${kind.pins.field} is empty: the record cites nothing and pins no file` });
1014
+ }
1015
+ if (options.assets) {
1016
+ const checked = checkPins(pinEntries(fm.value, kind.pins.field), options.assets);
1017
+ entry.assets.push(...checked.assets);
1018
+ entry.warnings.push(...checked.warnings);
1019
+ }
1020
+ }
1021
+ if (kind.reviews) {
1022
+ const list = fm.value[kind.reviews.field];
1023
+ const undigested = (Array.isArray(list) ? list : [])
1024
+ .filter((r): r is Record<string, unknown> => r !== null && typeof r === "object" && !Array.isArray(r) && (r as Record<string, unknown>).digest === undefined)
1025
+ .map((r) => (typeof r.reviewer === "string" ? r.reviewer : "an unnamed reviewer"));
1026
+ if (undigested.length > 0) {
1027
+ entry.warnings.push({
1028
+ code: "review-undigested",
1029
+ message: `the ${undigested.length === 1 ? "verdict" : "verdicts"} by ${undigested.join(", ")} name no digest: ${undigested.length === 1 ? "it counts" : "they count"}, and an amendment will not stop ${undigested.length === 1 ? "it" : "them"} counting`,
1030
+ });
1031
+ }
399
1032
  }
400
1033
  }
401
1034
 
@@ -413,17 +1046,12 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
413
1046
  // approval rule: from a record ranked above 0 and at least as high as the
414
1047
  // one it names (#2524 D4). Without them, only from a closed record (#2555).
415
1048
  // A record is superseded at most once.
416
- const closed = new Set(kind.closedStates);
1049
+ const closed = new Set(kind.closedStates ?? []);
417
1050
  const rank = (state: string | null): number => (state === null ? 0 : (kind.approval?.[state] ?? 0));
418
1051
  const takesEffect = (from: RecordEntry, to: RecordEntry): boolean =>
419
1052
  kind.approval ? rank(from.state) > 0 && rank(from.state) >= rank(to.state) : from.state !== null && closed.has(from.state);
420
1053
  for (const e of entries) {
421
- const links = e.data?.[kind.supersedes.field];
422
- if (!Array.isArray(links)) continue;
423
- for (const link of links) {
424
- if (link === null || typeof link !== "object") continue;
425
- const target = (link as Record<string, unknown>)[kind.supersedes.key];
426
- if (typeof target !== "string") continue;
1054
+ for (const target of supersedesTargets(kind, e.data)) {
427
1055
  const old = byId.get(target);
428
1056
  if (!old) {
429
1057
  e.reasons.push({ code: "record-supersedes-unknown", message: `supersedes ${target}, which no record has` });
@@ -470,6 +1098,11 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
470
1098
  }
471
1099
  }
472
1100
 
1101
+ // A session's seal and its verdicts' records (#2673).
1102
+ joinSessions(kind, entries, texts, options.subjects ?? null);
1103
+ // A work kind's links, ready and blocked (#2683), from every record before --current.
1104
+ const work = kind.work ? await (await import("./work")).applyWork(loaded, entries, options) : undefined;
1105
+
473
1106
  for (const e of entries) e.valid = e.reasons.length === 0;
474
1107
  const records = options.current ? entries.filter((e) => e.supersededBy === null) : entries;
475
1108
  return {
@@ -480,6 +1113,7 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
480
1113
  invalid: records.filter((e) => !e.valid).length,
481
1114
  superseded: entries.filter((e) => e.supersededBy !== null).length,
482
1115
  },
1116
+ ...(work ? { decisions: work.decisions } : {}),
483
1117
  };
484
1118
  }
485
1119