@intentius/chant 0.100.0 → 0.101.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 (276) hide show
  1. package/dist/build.d.ts +6 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/build-options.d.ts +2 -0
  4. package/dist/cli/build-options.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/import.d.ts.map +1 -1
  7. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/mcp/workspace-tools.d.ts +8 -0
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +22 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/config.d.ts +11 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +24 -1
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  18. package/dist/lifecycle/plan-digest.d.ts +26 -5
  19. package/dist/lifecycle/plan-digest.d.ts.map +1 -1
  20. package/dist/lint/config.d.ts +4 -4
  21. package/dist/op/activities/activity-contracts.d.ts +1 -0
  22. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  23. package/dist/op/activities/propose-upgrade.d.ts +2 -0
  24. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  25. package/dist/op/index.d.ts +1 -1
  26. package/dist/op/index.d.ts.map +1 -1
  27. package/dist/serializer.d.ts +8 -0
  28. package/dist/serializer.d.ts.map +1 -1
  29. package/dist/telemetry-attribution.d.ts +77 -0
  30. package/dist/telemetry-attribution.d.ts.map +1 -0
  31. package/dist/workspace/agent-cli.d.ts +83 -0
  32. package/dist/workspace/agent-cli.d.ts.map +1 -0
  33. package/dist/workspace/changes-cli.d.ts.map +1 -1
  34. package/dist/workspace/changes.d.ts +8 -1
  35. package/dist/workspace/changes.d.ts.map +1 -1
  36. package/dist/workspace/checks/links.d.ts +1 -0
  37. package/dist/workspace/checks/links.d.ts.map +1 -1
  38. package/dist/workspace/checks/live.d.ts +40 -0
  39. package/dist/workspace/checks/live.d.ts.map +1 -0
  40. package/dist/workspace/checks.d.ts +21 -2
  41. package/dist/workspace/checks.d.ts.map +1 -1
  42. package/dist/workspace/compose-graph.d.ts +63 -0
  43. package/dist/workspace/compose-graph.d.ts.map +1 -1
  44. package/dist/workspace/decide.d.ts +1 -1
  45. package/dist/workspace/decide.d.ts.map +1 -1
  46. package/dist/workspace/declaration.d.ts +32 -0
  47. package/dist/workspace/declaration.d.ts.map +1 -1
  48. package/dist/workspace/declaration.schema.json +138 -3
  49. package/dist/workspace/export-cli.d.ts +12 -0
  50. package/dist/workspace/export-cli.d.ts.map +1 -0
  51. package/dist/workspace/export.d.ts +145 -0
  52. package/dist/workspace/export.d.ts.map +1 -0
  53. package/dist/workspace/graph-cli.d.ts.map +1 -1
  54. package/dist/workspace/import.d.ts +73 -0
  55. package/dist/workspace/import.d.ts.map +1 -0
  56. package/dist/workspace/kinds.d.ts +6 -2
  57. package/dist/workspace/kinds.d.ts.map +1 -1
  58. package/dist/workspace/lineage-adopt-cli.d.ts +15 -0
  59. package/dist/workspace/lineage-adopt-cli.d.ts.map +1 -0
  60. package/dist/workspace/lineage-adopt.d.ts +106 -0
  61. package/dist/workspace/lineage-adopt.d.ts.map +1 -0
  62. package/dist/workspace/lineage-check.d.ts +9 -2
  63. package/dist/workspace/lineage-check.d.ts.map +1 -1
  64. package/dist/workspace/lineage-cli.d.ts +6 -1
  65. package/dist/workspace/lineage-cli.d.ts.map +1 -1
  66. package/dist/workspace/lineage-hash-index.d.ts +110 -0
  67. package/dist/workspace/lineage-hash-index.d.ts.map +1 -0
  68. package/dist/workspace/lineage-init.d.ts +10 -0
  69. package/dist/workspace/lineage-init.d.ts.map +1 -1
  70. package/dist/workspace/lineage-lock.d.ts +147 -0
  71. package/dist/workspace/lineage-lock.d.ts.map +1 -1
  72. package/dist/workspace/lineage-migrations.d.ts +15 -3
  73. package/dist/workspace/lineage-migrations.d.ts.map +1 -1
  74. package/dist/workspace/lineage-provenance.d.ts +18 -0
  75. package/dist/workspace/lineage-provenance.d.ts.map +1 -0
  76. package/dist/workspace/lineage-upgrade-cli.d.ts +2 -0
  77. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  78. package/dist/workspace/lineage-upgrade.d.ts +30 -1
  79. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  80. package/dist/workspace/lineage-versions.d.ts +116 -0
  81. package/dist/workspace/lineage-versions.d.ts.map +1 -0
  82. package/dist/workspace/links.d.ts +31 -5
  83. package/dist/workspace/links.d.ts.map +1 -1
  84. package/dist/workspace/ls-generated.d.ts +37 -0
  85. package/dist/workspace/ls-generated.d.ts.map +1 -0
  86. package/dist/workspace/ls.d.ts +4 -0
  87. package/dist/workspace/ls.d.ts.map +1 -1
  88. package/dist/workspace/member-commands.d.ts.map +1 -1
  89. package/dist/workspace/nested-graph.d.ts +56 -0
  90. package/dist/workspace/nested-graph.d.ts.map +1 -0
  91. package/dist/workspace/nesting.d.ts +21 -0
  92. package/dist/workspace/nesting.d.ts.map +1 -0
  93. package/dist/workspace/pin-cli.d.ts +10 -0
  94. package/dist/workspace/pin-cli.d.ts.map +1 -0
  95. package/dist/workspace/pin-integrity.d.ts +51 -0
  96. package/dist/workspace/pin-integrity.d.ts.map +1 -0
  97. package/dist/workspace/reason-codes.d.ts +26 -2
  98. package/dist/workspace/reason-codes.d.ts.map +1 -1
  99. package/dist/workspace/record-sessions.d.ts +7 -11
  100. package/dist/workspace/record-sessions.d.ts.map +1 -1
  101. package/dist/workspace/records-cli.d.ts +30 -1
  102. package/dist/workspace/records-cli.d.ts.map +1 -1
  103. package/dist/workspace/records-close.d.ts +5 -2
  104. package/dist/workspace/records-close.d.ts.map +1 -1
  105. package/dist/workspace/records-write.d.ts +22 -4
  106. package/dist/workspace/records-write.d.ts.map +1 -1
  107. package/dist/workspace/records.d.ts +43 -5
  108. package/dist/workspace/records.d.ts.map +1 -1
  109. package/dist/workspace/returns.d.ts +129 -0
  110. package/dist/workspace/returns.d.ts.map +1 -0
  111. package/dist/workspace/status-gates.d.ts.map +1 -1
  112. package/dist/workspace/template-manifest.d.ts +11 -3
  113. package/dist/workspace/template-manifest.d.ts.map +1 -1
  114. package/dist/workspace/trust/attestor.d.ts +8 -0
  115. package/dist/workspace/trust/attestor.d.ts.map +1 -1
  116. package/dist/workspace/trust/dsse.d.ts +58 -0
  117. package/dist/workspace/trust/dsse.d.ts.map +1 -0
  118. package/dist/workspace/trust/evidence-cli.d.ts +66 -0
  119. package/dist/workspace/trust/evidence-cli.d.ts.map +1 -0
  120. package/dist/workspace/trust/evidence.d.ts +93 -0
  121. package/dist/workspace/trust/evidence.d.ts.map +1 -0
  122. package/dist/workspace/trust/policy.d.ts +54 -1
  123. package/dist/workspace/trust/policy.d.ts.map +1 -1
  124. package/dist/workspace/trust/provenance.d.ts +21 -1
  125. package/dist/workspace/trust/provenance.d.ts.map +1 -1
  126. package/dist/workspace/trust/rotation.d.ts +132 -0
  127. package/dist/workspace/trust/rotation.d.ts.map +1 -0
  128. package/dist/workspace/trust/seal.d.ts.map +1 -1
  129. package/dist/workspace/trust/signers-cli.d.ts +49 -0
  130. package/dist/workspace/trust/signers-cli.d.ts.map +1 -0
  131. package/dist/workspace/trust/ssh-commit.d.ts +12 -0
  132. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  133. package/dist/workspace/trust/test-repo.d.ts +13 -0
  134. package/dist/workspace/trust/test-repo.d.ts.map +1 -1
  135. package/dist/workspace/trust/verify.d.ts +15 -0
  136. package/dist/workspace/trust/verify.d.ts.map +1 -1
  137. package/dist/workspace/work-evidence.d.ts +1 -1
  138. package/dist/workspace/work-evidence.d.ts.map +1 -1
  139. package/dist/workspace/write-scope.d.ts +199 -0
  140. package/dist/workspace/write-scope.d.ts.map +1 -0
  141. package/package.json +1 -1
  142. package/src/build.ts +8 -0
  143. package/src/cli/build-options.ts +4 -1
  144. package/src/cli/commands/build.ts +2 -0
  145. package/src/cli/commands/import-live.test.ts +69 -1
  146. package/src/cli/commands/import.ts +48 -22
  147. package/src/cli/handlers/fan-out.test.ts +6 -6
  148. package/src/cli/handlers/fan-out.ts +2 -1
  149. package/src/cli/handlers/graph.test.ts +42 -0
  150. package/src/cli/handlers/graph.ts +22 -0
  151. package/src/cli/handlers/operator.ts +1 -1
  152. package/src/cli/main.test.ts +32 -0
  153. package/src/cli/main.ts +103 -6
  154. package/src/cli/mcp/workspace-tools.test.ts +1 -1
  155. package/src/cli/mcp/workspace-tools.ts +33 -2
  156. package/src/cli/registry.ts +22 -0
  157. package/src/cli/serve-mcp-workspace.test.ts +1 -1
  158. package/src/codegen/release-wiring.test.ts +5 -1
  159. package/src/components/fan-out-output.test.ts +1 -1
  160. package/src/components/fan-out.test.ts +1 -1
  161. package/src/components/promote.test.ts +1 -1
  162. package/src/config.ts +12 -0
  163. package/src/content-digest.test.ts +2 -2
  164. package/src/lexicon.ts +25 -1
  165. package/src/lifecycle/gate-ledger.test.ts +14 -0
  166. package/src/lifecycle/gate-ledger.ts +2 -1
  167. package/src/lifecycle/plan-digest.test.ts +54 -3
  168. package/src/lifecycle/plan-digest.ts +38 -8
  169. package/src/op/activities/activity-contracts.ts +1 -0
  170. package/src/op/activities/propose-upgrade.ts +8 -5
  171. package/src/op/gate-approval.test.ts +17 -0
  172. package/src/op/gate.ts +3 -3
  173. package/src/op/index.ts +1 -1
  174. package/src/serializer.ts +9 -0
  175. package/src/telemetry-attribution.test.ts +91 -0
  176. package/src/telemetry-attribution.ts +145 -0
  177. package/src/workspace/agent-cli.ts +134 -0
  178. package/src/workspace/agent.schema.json +356 -0
  179. package/src/workspace/behold-kinds.test.ts +1 -1
  180. package/src/workspace/changes-cli.ts +5 -0
  181. package/src/workspace/changes.schema.json +179 -1
  182. package/src/workspace/changes.ts +58 -4
  183. package/src/workspace/check-live.test.ts +192 -0
  184. package/src/workspace/check.schema.json +64 -0
  185. package/src/workspace/checks/links.ts +23 -2
  186. package/src/workspace/checks/live.ts +113 -0
  187. package/src/workspace/checks.test.ts +2 -2
  188. package/src/workspace/checks.ts +20 -3
  189. package/src/workspace/compose-graph.test.ts +47 -0
  190. package/src/workspace/compose-graph.ts +120 -3
  191. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +7 -1
  192. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +5 -0
  193. package/src/workspace/declaration.schema.json +138 -3
  194. package/src/workspace/declaration.ts +106 -1
  195. package/src/workspace/declared-kinds.test.ts +33 -0
  196. package/src/workspace/evidence.schema.json +279 -0
  197. package/src/workspace/export-cli.ts +180 -0
  198. package/src/workspace/export-import.test.ts +282 -0
  199. package/src/workspace/export.ts +486 -0
  200. package/src/workspace/graph-cli.ts +47 -1
  201. package/src/workspace/graph-contract.test.ts +89 -4
  202. package/src/workspace/graph.schema.json +206 -1
  203. package/src/workspace/import.ts +325 -0
  204. package/src/workspace/kinds.test.ts +5 -5
  205. package/src/workspace/kinds.ts +25 -3
  206. package/src/workspace/lineage-adopt-cli.ts +103 -0
  207. package/src/workspace/lineage-adopt.test.ts +552 -0
  208. package/src/workspace/lineage-adopt.ts +452 -0
  209. package/src/workspace/lineage-check.ts +26 -5
  210. package/src/workspace/lineage-cli.ts +12 -2
  211. package/src/workspace/lineage-hash-index.ts +305 -0
  212. package/src/workspace/lineage-init.test.ts +9 -0
  213. package/src/workspace/lineage-init.ts +29 -9
  214. package/src/workspace/lineage-lock.ts +54 -0
  215. package/src/workspace/lineage-migrations.test.ts +27 -0
  216. package/src/workspace/lineage-migrations.ts +44 -14
  217. package/src/workspace/lineage-provenance.ts +40 -0
  218. package/src/workspace/lineage-upgrade-cli.ts +9 -5
  219. package/src/workspace/lineage-upgrade.test.ts +92 -1
  220. package/src/workspace/lineage-upgrade.ts +111 -16
  221. package/src/workspace/lineage-versions.ts +348 -0
  222. package/src/workspace/links.test.ts +121 -2
  223. package/src/workspace/links.ts +97 -6
  224. package/src/workspace/ls-contract.test.ts +84 -1
  225. package/src/workspace/ls-generated.ts +111 -0
  226. package/src/workspace/ls.schema.json +19 -0
  227. package/src/workspace/ls.ts +8 -1
  228. package/src/workspace/member-commands.ts +3 -1
  229. package/src/workspace/nested-graph.test.ts +176 -0
  230. package/src/workspace/nested-graph.ts +169 -0
  231. package/src/workspace/nesting.ts +37 -0
  232. package/src/workspace/pin-cli.test.ts +71 -0
  233. package/src/workspace/pin-cli.ts +57 -0
  234. package/src/workspace/pin-integrity.test.ts +121 -0
  235. package/src/workspace/pin-integrity.ts +104 -0
  236. package/src/workspace/points-write.schema.json +1 -0
  237. package/src/workspace/read-contract.test.ts +24 -0
  238. package/src/workspace/reason-codes.test.ts +10 -0
  239. package/src/workspace/reason-codes.ts +29 -2
  240. package/src/workspace/record-sessions.ts +12 -14
  241. package/src/workspace/records-amend.schema.json +4 -0
  242. package/src/workspace/records-cli.ts +93 -12
  243. package/src/workspace/records-close.schema.json +6 -2
  244. package/src/workspace/records-close.ts +10 -2
  245. package/src/workspace/records-formats.test.ts +13 -5
  246. package/src/workspace/records-new.schema.json +4 -0
  247. package/src/workspace/records-review.schema.json +4 -0
  248. package/src/workspace/records-sessions.test.ts +23 -6
  249. package/src/workspace/records-write.test.ts +65 -8
  250. package/src/workspace/records-write.ts +70 -6
  251. package/src/workspace/records.schema.json +55 -3
  252. package/src/workspace/records.ts +96 -5
  253. package/src/workspace/returns.ts +328 -0
  254. package/src/workspace/signers.schema.json +206 -0
  255. package/src/workspace/status-gates.ts +2 -1
  256. package/src/workspace/template-manifest.ts +22 -4
  257. package/src/workspace/trust/attestor.ts +15 -0
  258. package/src/workspace/trust/dsse.ts +134 -0
  259. package/src/workspace/trust/evidence-cli.ts +195 -0
  260. package/src/workspace/trust/evidence.test.ts +241 -0
  261. package/src/workspace/trust/evidence.ts +207 -0
  262. package/src/workspace/trust/policy.ts +110 -3
  263. package/src/workspace/trust/provenance.ts +41 -4
  264. package/src/workspace/trust/record-seal.test.ts +1 -1
  265. package/src/workspace/trust/rotation.test.ts +258 -0
  266. package/src/workspace/trust/rotation.ts +336 -0
  267. package/src/workspace/trust/seal.ts +11 -0
  268. package/src/workspace/trust/signers-cli.ts +178 -0
  269. package/src/workspace/trust/ssh-commit.ts +53 -4
  270. package/src/workspace/trust/test-repo.ts +18 -0
  271. package/src/workspace/trust/trust.test.ts +8 -2
  272. package/src/workspace/trust/verify-cli.ts +1 -0
  273. package/src/workspace/trust/verify.ts +22 -0
  274. package/src/workspace/work-evidence.schema.json +4 -0
  275. package/src/workspace/write-scope.test.ts +340 -0
  276. package/src/workspace/write-scope.ts +448 -0
@@ -50,9 +50,9 @@ import {
50
50
  } from "./records";
51
51
  import { gitTree, workingTree, type WorkspaceTree } from "./tree";
52
52
  import type { DecisionWork } from "./work";
53
- import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
53
+ import { activeAttestors, type CommitAttestor, type ProvenanceLevel } from "./trust/attestor";
54
54
  import { policyAtBase, recordProvenance, resolveBase, type BaseSource, type RecordProvenance } from "./trust/provenance";
55
- import type { TrustPolicy } from "./trust/policy";
55
+ import { returnedPolicy, type TrustPolicy } from "./trust/policy";
56
56
  import type { SealedRecord } from "./trust/seal";
57
57
 
58
58
  /** The version of the `records` output this chant writes. */
@@ -129,7 +129,7 @@ export type RecordsDocument =
129
129
  | {
130
130
  $schema: string;
131
131
  contract: number;
132
- kind: { name: string; schema: string; file: string; format: RecordFormat };
132
+ kind: { name: string; schema: string; file: string; format: RecordFormat; spec: boolean };
133
133
  at: string | null;
134
134
  /** The directory pinned paths resolve in, from the repository root: the workspace holding the kind file, or the repository root (#2549). */
135
135
  workspaceRoot: string;
@@ -161,6 +161,47 @@ export interface RecordsSetDocument {
161
161
  $schema: string;
162
162
  contract: number;
163
163
  kinds: (RecordsDocument & { declared: DeclaredKindView })[];
164
+ /** With `--current`: the workspace's spec (#2524 D20, #2546), from the kinds marked `spec: true`. */
165
+ spec?: SpecView;
166
+ }
167
+
168
+ /**
169
+ * The spec (#2524 D20): the current records of every declared kind marked
170
+ * `spec: true`, each with the workspace files it pins. `records --current
171
+ * --json` prints it beside the kinds, so an agent resumes from the
172
+ * declaration and this one read.
173
+ */
174
+ export interface SpecView {
175
+ /** The spec kinds, as the declaration names them, in its order. */
176
+ kinds: DeclaredKindView[];
177
+ records: SpecRecord[];
178
+ }
179
+
180
+ /** One current record of a spec kind. */
181
+ export interface SpecRecord {
182
+ /** The kind's name, such as decision. */
183
+ kind: string;
184
+ id: string | null;
185
+ path: string;
186
+ state: string | null;
187
+ valid: boolean;
188
+ /** The digest a verdict names (#2672). */
189
+ digest: string;
190
+ /** Each workspace file the record pins: its path from the workspace root, the sha256 pinned, and how it compares with the tree read (#2549). */
191
+ assets: RecordEntry["assets"];
192
+ }
193
+
194
+ /** The spec of a set of declared kinds already read with `--current`. */
195
+ export function specOf(kinds: RecordsSetDocument["kinds"]): SpecView {
196
+ const spec: SpecView = { kinds: [], records: [] };
197
+ for (const doc of kinds) {
198
+ if ("error" in doc || !doc.kind.spec) continue;
199
+ spec.kinds.push(doc.declared);
200
+ for (const r of doc.records) {
201
+ spec.records.push({ kind: doc.kind.name, id: r.id, path: r.path, state: r.state, valid: r.valid, digest: r.digest, assets: r.assets });
202
+ }
203
+ }
204
+ return spec;
164
205
  }
165
206
 
166
207
  /**
@@ -186,7 +227,7 @@ export async function queryDeclaredRecords(kinds: { declared: RecordKindDeclarat
186
227
  const doc = await queryRecords({ ...query, kind: k.file });
187
228
  out.push({ ...doc, declared: { member: k.declared.member, path: k.declared.path, name: k.declared.name } });
188
229
  }
189
- return { $schema: RECORDS_OUTPUT_SCHEMA_ID, contract: RECORDS_CONTRACT_VERSION, kinds: out };
230
+ return { $schema: RECORDS_OUTPUT_SCHEMA_ID, contract: RECORDS_CONTRACT_VERSION, kinds: out, ...(query.current ? { spec: specOf(out) } : {}) };
190
231
  }
191
232
 
192
233
  /** A records read, before provenance. */
@@ -320,21 +361,30 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
320
361
  // Provenance, judged by the policy at base and never by the tree read (#2547).
321
362
  const base = top ? resolveBase(top, query.base) : { commit: null, from: null };
322
363
  const policy = top ? policyAtBase(top, base) : policyAtBase(root, base);
364
+ const attestors = policy.active ? await activeAttestors() : [];
323
365
  const provenance = recordProvenance({
324
366
  repo: top,
325
367
  policy,
326
368
  at,
327
369
  paths: result.records.map((r) => r.path),
328
- attestors: policy.active ? await activeAttestors() : [],
370
+ attestors,
371
+ ...(policy.active ? { revisit: await returnedRevisit(tree, workspaceRoot, policy, attestors) } : {}),
329
372
  });
330
373
  const seals = loaded.kind.reviews ? await import("./trust/seal") : undefined;
331
374
  const quorumOptions = await quorumOptionsFor(loaded, tree, policy);
332
- const records: RecordView[] = result.records.map((r) => ({
333
- ...r,
334
- provenance: provenance.get(r.path)!,
335
- ...(quorumOptions ? { quorum: computeQuorum(loaded.kind, r, quorumOptions) } : {}),
336
- ...(seals ? authorSeal(loaded.kind, r, policy, seals.checkRecordSeal) : {}),
337
- }));
375
+ const records: RecordView[] = [];
376
+ for (const r of result.records) {
377
+ const p = provenance.get(r.path)!;
378
+ // Returned work (#2552): its seals verify against the signers admitted for its return too.
379
+ const recordPolicy = p.returned ? returnedPolicy(policy, p.returned.id) : policy;
380
+ const options = p.returned ? await quorumOptionsFor(loaded, tree, recordPolicy) : quorumOptions;
381
+ records.push({
382
+ ...r,
383
+ provenance: p,
384
+ ...(options ? { quorum: computeQuorum(loaded.kind, r, options) } : {}),
385
+ ...(seals ? authorSeal(loaded.kind, r, recordPolicy, seals.checkRecordSeal) : {}),
386
+ });
387
+ }
338
388
  if (loaded.kind.approval && !loaded.kind.work && top) {
339
389
  const decided = decidedCommits(top, at ?? "HEAD", records.map((r) => r.path), loaded.kind);
340
390
  for (const r of records) r.decidedIn = decided.get(r.path) ?? null;
@@ -353,6 +403,7 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
353
403
  schema: loaded.kind.schema.id,
354
404
  file: relative(root, loaded.file).split("\\").join("/"),
355
405
  format: loaded.kind.format,
406
+ spec: loaded.kind.spec === true,
356
407
  },
357
408
  at,
358
409
  workspaceRoot,
@@ -368,6 +419,36 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
368
419
  }
369
420
  }
370
421
 
422
+ /**
423
+ * The second look recordProvenance takes at each record (#2552): a record
424
+ * whose bytes came back in a return is judged by the commit it was made in,
425
+ * which the return carries, rather than by the import here. `tree` is the
426
+ * workspace root's tree, as read; record paths are from the repository root.
427
+ */
428
+ async function returnedRevisit(
429
+ tree: WorkspaceTree,
430
+ workspaceRoot: string,
431
+ policy: TrustPolicy,
432
+ attestors: readonly CommitAttestor[],
433
+ ): Promise<((path: string, host: RecordProvenance) => RecordProvenance | undefined) | undefined> {
434
+ const { readReturns, returnedProvenance } = await import("./returns");
435
+ const { returns } = readReturns(tree);
436
+ if (returns.length === 0) return undefined;
437
+ const prefix = workspaceRoot === "." ? "" : `${workspaceRoot}/`;
438
+ return (path, host) => {
439
+ if (!path.startsWith(prefix)) return undefined;
440
+ const rel = path.slice(prefix.length);
441
+ const holding = returns.filter((r) => r.paths[rel] !== undefined);
442
+ if (holding.length === 0 || tree.stat(rel) !== "file") return undefined;
443
+ const content = Buffer.from(tree.bytes ? tree.bytes(rel) : tree.read(rel));
444
+ for (const r of holding) {
445
+ const p = returnedProvenance(policy, attestors, r, rel, content, host);
446
+ if (p) return p;
447
+ }
448
+ return undefined;
449
+ };
450
+ }
451
+
371
452
  /**
372
453
  * A record's author seal checked against the policy at base (#2688), for a
373
454
  * kind with a reviews list: its author is the kind's `reviews.decider` field.
@@ -676,7 +757,7 @@ export async function runWorkspaceUnknown(ctx: CommandContext): Promise<number>
676
757
  console.error(
677
758
  formatError({
678
759
  message: sub ? `Unknown workspace subcommand: ${sub}` : "chant workspace needs a subcommand",
679
- hint: `Workspace subcommands: audit, build, check, graph, init, lineage, lint, ls, records, status, upgrade, verify, work. Run "chant --help" for their options.`,
760
+ hint: `Workspace subcommands: adopt-lineage, audit, build, check, evidence, graph, hash-index, init, lineage, lint, ls, pin, records, signers, status, upgrade, verify, versions, work. Run "chant --help" for their options.`,
680
761
  }),
681
762
  );
682
763
  return 1;
@@ -84,8 +84,8 @@
84
84
  },
85
85
  "digest": {
86
86
  "type": "string",
87
- "pattern": "^[0-9a-f]{64}$",
88
- "description": "The seal written: the lowercase hex SHA-256 of the file with LF line endings and without its seal line, which records checks on every read (session-seal-mismatch)."
87
+ "pattern": "^sha256:[0-9a-f]{64}$",
88
+ "description": "The seal written: sha256: and the lowercase hex SHA-256 of the JCS form of the whole session without its seal field (its front matter as core and the text below it as body, line endings LF), which records checks on every read (session-seal-mismatch). Before #2546 it was the bare hex SHA-256 of the file's text without the seal line."
89
89
  }
90
90
  }
91
91
  },
@@ -171,6 +171,9 @@
171
171
  "write-usage-invalid",
172
172
  "record-not-found",
173
173
  "record-closed",
174
+ "write-scope-member",
175
+ "write-scope-kind",
176
+ "agent-unknown",
174
177
  "record-unparseable",
175
178
  "record-schema-invalid",
176
179
  "record-id-duplicate",
@@ -178,6 +181,7 @@
178
181
  "record-supersedes-conflict",
179
182
  "record-remediates-unknown",
180
183
  "record-remediates-not-closed",
184
+ "record-seal-mismatch",
181
185
  "session-seal-mismatch",
182
186
  "session-verdict-unknown-record"
183
187
  ]
@@ -3,7 +3,8 @@
3
3
  * session in one write. It sets the session's state to its kind's closed
4
4
  * state, the time it closed and the commit it closed at (the fields the
5
5
  * kind's `session` block names in `closedOn` and `closedRev`), and then the
6
- * seal by chant's rule ({@link sessionSeal}), so a UI never computes a seal.
6
+ * seal by chant's whole-file rule ({@link sessionSeal}, #2546), so a UI never
7
+ * computes a seal.
7
8
  *
8
9
  * Like `new`, `amend` and `review`, it reads the records again with the file
9
10
  * in place and writes only when the session comes back valid: a verdict
@@ -26,6 +27,7 @@ import {
26
27
  readAll,
27
28
  RECORDS_WRITE_CONTRACT_VERSION,
28
29
  RecordWriteError,
30
+ refuseOutOfScope,
29
31
  replaceFields,
30
32
  stableJson,
31
33
  validateWrite,
@@ -33,6 +35,7 @@ import {
33
35
  type WriteResult,
34
36
  } from "./records-write";
35
37
  import { headCommit } from "./session-kinds";
38
+ import { WRITE_SCOPE_CODES } from "./write-scope";
36
39
 
37
40
  export const RECORDS_CLOSE_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records-close/v1/records-close.schema.json";
38
41
 
@@ -42,6 +45,7 @@ export const CLOSE_ERROR_CODES = [
42
45
  "write-usage-invalid",
43
46
  "record-not-found",
44
47
  "record-closed",
48
+ ...WRITE_SCOPE_CODES,
45
49
  ...RECORD_REASON_CODES,
46
50
  ] as const satisfies readonly ReasonCode[];
47
51
  export type CloseErrorCode = (typeof CLOSE_ERROR_CODES)[number];
@@ -65,6 +69,8 @@ export interface CloseRecordOptions {
65
69
  cwd: string;
66
70
  /** The close time. Defaults to now. */
67
71
  now?: Date;
72
+ /** The agent session the write is made in (#2548). */
73
+ agent?: string;
68
74
  }
69
75
 
70
76
  /** An ISO 8601 time in UTC to the second, as the reference session schema's dateTime takes it. */
@@ -76,6 +82,7 @@ function isoSeconds(d: Date): string {
76
82
  export async function closeRecord(opts: CloseRecordOptions): Promise<CloseDocument> {
77
83
  try {
78
84
  const o = await open(opts.kind, opts.cwd);
85
+ refuseOutOfScope(o, "close", opts.cwd, opts);
79
86
  const { kind } = o.loaded;
80
87
  const decl = kind.session;
81
88
  if (!decl || kind.stateField === undefined) {
@@ -97,12 +104,13 @@ export async function closeRecord(opts: CloseRecordOptions): Promise<CloseDocume
97
104
  ...(decl.closedOn ? { [decl.closedOn]: isoSeconds(opts.now ?? new Date()) } : {}),
98
105
  ...(decl.closedRev ? { [decl.closedRev]: closedRev } : {}),
99
106
  };
100
- // The seal line is written last, into text that already holds everything else, so the seal is the digest of the file without it.
107
+ // The seal is written last, into text that already holds everything else: the whole-file seal of the session without its seal field (#2546).
101
108
  const merged = { ...old, ...set };
102
109
  delete merged[decl.seal];
103
110
  const unsealed = replaceFields(o.source.read(target.path), set, merged);
104
111
  if (unsealed === undefined) throw new RecordWriteError("record-unparseable", `${target.path}: its fields can't be rewritten in place without changing the rest of the file`);
105
112
  const digest = sessionSeal(unsealed, decl.seal, kind.format);
113
+ if (digest === null) throw new RecordWriteError("record-unparseable", `${target.path} can't be read, so it can't be sealed`);
106
114
  const text = replaceFields(unsealed, { [decl.seal]: digest }, { ...merged, [decl.seal]: digest });
107
115
  if (text === undefined || sessionSeal(text, decl.seal, kind.format) !== digest) {
108
116
  throw new RecordWriteError("record-unparseable", `${target.path}: the ${decl.seal} line can't be added without changing the text it seals`);
@@ -154,7 +154,8 @@ const SESSION_SCHEMA = {
154
154
  comments: { type: "array" },
155
155
  follow_ups: { type: "array", items: ref("contractId") },
156
156
  closed_at: ref("datetime"),
157
- seal: ref("sha256"),
157
+ // The whole-file seal (#2546): sha256: and the hex SHA-256 of the session's JCS form without it.
158
+ seal: { type: "string", pattern: "^sha256:[0-9a-f]{64}$" },
158
159
  },
159
160
  };
160
161
 
@@ -294,7 +295,7 @@ const SESSION = {
294
295
  comments: [],
295
296
  follow_ups: ["C-002"],
296
297
  closed_at: "2026-09-24T11:00:00.000Z",
297
- seal: "e".repeat(64),
298
+ seal: `sha256:${"e".repeat(64)}`,
298
299
  };
299
300
 
300
301
  // ── 1. The json format ───────────────────────────────────────────────────────
@@ -372,11 +373,16 @@ describe("format json", () => {
372
373
  });
373
374
 
374
375
  describe("a JSON session kind (#2673)", () => {
375
- test("its seal is the digest without the seal member, by the JSON rule", async () => {
376
+ test("its seal is the whole-file JCS seal without the seal member (#2546)", async () => {
376
377
  write("design/session.kind.mjs", kindFile({ ...KINDS.session, session: { verdicts: "approvals", seal: "seal", subjects: { kind: "contract.kind.mjs" } } }));
377
378
  write("design/contracts/C-001-first.md", '---\nid: "C-001"\nstatus: "approved"\n---\n');
379
+ // JCS of the object without its seal: keys sorted, no whitespace. The member order and indentation of the file play no part.
380
+ const sorted = (v: unknown): unknown =>
381
+ v === null || typeof v !== "object" ? v : Array.isArray(v) ? v.map(sorted) : Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted((v as Record<string, unknown>)[k])]));
382
+ const jcsSeal = (v: object): string => `sha256:${sha256(JSON.stringify(sorted(v)))}`;
378
383
  const { seal: _drop, ...unsealed } = SESSION;
379
- const seal = sha256(json(unsealed));
384
+ const seal = jcsSeal(unsealed);
385
+ // Sealed over SESSION, then changed: a verdict added, or the id.
380
386
  write("design/sessions/S-0001.json", json({ ...SESSION, approvals: [{ record: "C-001" }], seal }));
381
387
  write("design/sessions/S-0002.json", json({ ...SESSION, id: "S-0002", seal }));
382
388
  const doc = await query("session");
@@ -385,7 +391,7 @@ describe("a JSON session kind (#2673)", () => {
385
391
  ["S-0002", ["session-seal-mismatch"]],
386
392
  ]);
387
393
  const good = { ...unsealed, id: "S-0003" };
388
- write("design/sessions/S-0003.json", json({ ...good, seal: sha256(json(good)) }));
394
+ write("design/sessions/S-0003.json", `${JSON.stringify({ seal: jcsSeal(good), ...Object.fromEntries(Object.entries(good).reverse()) })}\n`);
389
395
  const [, , third] = (await query("session")).records;
390
396
  expect([third.id, codes(third)]).toEqual(["S-0003", []]);
391
397
  });
@@ -547,6 +553,8 @@ describe("kinds without a lifecycle", () => {
547
553
  ["neither idField nor idFrom", { ...KINDS.closure, idField: undefined }, /idField: a kind names its id with exactly one of idField and idFrom/],
548
554
  ["an unknown format", { ...KINDS.closure, format: "yaml" }, /format/],
549
555
  ["a session block without states", { ...KINDS.closure, session: { verdicts: "approvals", seal: "seal", subjects: { kind: "contract.kind.mjs" } } }, /session: a session kind must have states/],
556
+ ["a seal without states (#2546)", { ...KINDS.closure, seal: { field: "seal" } }, /seal: a kind without states cannot have seal/],
557
+ ["a seal naming another field than session.seal (#2546)", { ...KINDS.session, session: { verdicts: "approvals", seal: "seal", subjects: { kind: "contract.kind.mjs" } }, seal: { field: "closed_digest" } }, /seal: seal.field and session.seal name the field/],
550
558
  ])("a kind with %s is kind-invalid, naming the field", async (_label, kind, why) => {
551
559
  write("design/closure.kind.mjs", kindFile(kind));
552
560
  const error = await failure("closure");
@@ -165,6 +165,9 @@
165
165
  "source-harvest-not-proposed",
166
166
  "record-state-not-initial",
167
167
  "ratify-quorum-not-met",
168
+ "write-scope-member",
169
+ "write-scope-kind",
170
+ "agent-unknown",
168
171
  "record-unparseable",
169
172
  "record-schema-invalid",
170
173
  "record-id-duplicate",
@@ -172,6 +175,7 @@
172
175
  "record-supersedes-conflict",
173
176
  "record-remediates-unknown",
174
177
  "record-remediates-not-closed",
178
+ "record-seal-mismatch",
175
179
  "session-seal-mismatch",
176
180
  "session-verdict-unknown-record"
177
181
  ]
@@ -259,6 +259,9 @@
259
259
  "review-sign-failed",
260
260
  "session-unknown",
261
261
  "session-not-open",
262
+ "write-scope-member",
263
+ "write-scope-kind",
264
+ "agent-unknown",
262
265
  "record-unparseable",
263
266
  "record-schema-invalid",
264
267
  "record-id-duplicate",
@@ -266,6 +269,7 @@
266
269
  "record-supersedes-conflict",
267
270
  "record-remediates-unknown",
268
271
  "record-remediates-not-closed",
272
+ "record-seal-mismatch",
269
273
  "session-seal-mismatch",
270
274
  "session-verdict-unknown-record"
271
275
  ]
@@ -5,9 +5,10 @@
5
5
  * decision reviews that cite it.
6
6
  */
7
7
 
8
- import { execFileSync } from "node:child_process";
8
+ import { createHash } from "node:crypto";
9
9
  import { readFileSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
+ import yaml from "js-yaml";
11
12
  import { afterAll, describe, expect, test } from "vitest";
12
13
  import { cleanScratch, commitAll, contract, REPO } from "./__fixtures__/contract-repo";
13
14
  import { reviewed, sessionText, SESSIONS_KIND as SESSIONS, sessionsRepo as fixture } from "./__fixtures__/sessions";
@@ -34,14 +35,30 @@ describe("the session kind (#2673)", () => {
34
35
  expect(doc.records[0].citedBy).toEqual([]);
35
36
  });
36
37
 
37
- test("the seal is the awk recipe the docs give", () => {
38
+ test("the seal is the whole-file JCS rule the docs give (#2546)", () => {
38
39
  const file = join(REPO, "reference-workspace", "design", "sessions", "S-0001-first-walk-of-the-reference-decisions.md");
39
- const awk = execFileSync("sh", ["-c", `awk 'NR==1&&/^---$/{f=1;print;next} f&&/^---$/{f=0} f&&/^closed_digest:/{next} {print}' "$1" | shasum -a 256`, "sh", file], { encoding: "utf-8" });
40
40
  const text = readFileSync(file, "utf-8");
41
- expect(awk.split(" ")[0]).toBe(sessionSeal(text, "closed_digest"));
42
- expect(text).toContain(`closed_digest: "${sessionSeal(text, "closed_digest")}"`);
41
+ // The rule worked by hand: the front matter as JSON without closed_digest, the text below it, keys sorted, no whitespace.
42
+ const [, front, body] = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text)!;
43
+ const { closed_digest: _seal, ...core } = yaml.load(front, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>;
44
+ const sorted = (v: unknown): unknown =>
45
+ v === null || typeof v !== "object" ? v : Array.isArray(v) ? v.map(sorted) : Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted((v as Record<string, unknown>)[k])]));
46
+ const byHand = `sha256:${createHash("sha256").update(JSON.stringify(sorted({ body, core })), "utf8").digest("hex")}`;
47
+ expect(sessionSeal(text, "closed_digest")).toBe(byHand);
48
+ expect(text).toContain(`closed_digest: "${byHand}"`);
43
49
  // CRLF line endings hash as LF.
44
- expect(sessionSeal(text.replace(/\n/g, "\r\n"), "closed_digest")).toBe(sessionSeal(text, "closed_digest"));
50
+ expect(sessionSeal(text.replace(/\n/g, "\r\n"), "closed_digest")).toBe(byHand);
51
+ });
52
+
53
+ test("a session sealed by the text rule before #2546 reads as session-seal-mismatch, naming the old rule", async () => {
54
+ const root = fixture();
55
+ const file = join(root, "design", "sessions", "S-0001-first-walk-of-the-reference-decisions.md");
56
+ const text = readFileSync(file, "utf-8");
57
+ const old = createHash("sha256").update(text.replace(/^closed_digest: .*\n/m, ""), "utf8").digest("hex");
58
+ writeFileSync(file, text.replace(/^closed_digest: .*$/m, `closed_digest: "${old}"`));
59
+ const s1 = (await read(root)).records[0];
60
+ expect(s1.reasons.map((r) => r.code)).toContain("session-seal-mismatch");
61
+ expect(s1.reasons.find((r) => r.code === "session-seal-mismatch")!.message).toMatch(/the text rule chant used before #2546/);
45
62
  });
46
63
 
47
64
  test("a closed session edited after it closed is session-seal-mismatch", async () => {
@@ -10,7 +10,7 @@ import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
13
- import { parseFrontMatter, recordTextDigest as textDigest } from "./records";
13
+ import { parseFrontMatter, recordSeal, recordTextDigest as textDigest } from "./records";
14
14
  import { queryRecords } from "./records-cli";
15
15
  import { allocateId, amendRecord, newRecord, renderRecord, reviewRecord, toYaml } from "./records-write";
16
16
 
@@ -21,7 +21,7 @@ const KIND = "decisions/decision.kind.mjs";
21
21
  type Data = Record<string, unknown>;
22
22
 
23
23
  /** The digest of a decision: the kind names a ratified state, so the state leaves the digest with the reviews and seal (#2873). */
24
- const recordTextDigest = (text: string) => textDigest(text, ["reviews", "seal", "state"]);
24
+ const recordTextDigest = (text: string) => textDigest(text, ["reviews", "seal", "state", "closed_digest"]);
25
25
 
26
26
  const SAMPLE = (() => {
27
27
  const fm = parseFrontMatter(readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8"));
@@ -287,7 +287,7 @@ describe("records amend", () => {
287
287
  const evidence = [{ title: "A spec", url: "https://example.com/spec" }];
288
288
  expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ evidence }), cwd: dir })).toMatchObject({ changed: ["evidence"] });
289
289
  for (const by of ["alice", "bob"]) await reviewRecord({ kind: KIND, id: "ws-001", verdict: "agree", by, cwd: dir });
290
- expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified" }), cwd: dir })).toMatchObject({ changed: ["state"] });
290
+ expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified" }), cwd: dir })).toMatchObject({ changed: ["state", "closed_digest"] });
291
291
  put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
292
292
  expect(code(await amendRecord({ kind: KIND, id: "ws-006", fields: fields({ state: "withdrawn" }), cwd: dir }))).toBe("amend-supersede-instead");
293
293
  });
@@ -353,12 +353,43 @@ describe("ratifying against the quorum (#2873)", () => {
353
353
  await agree("alice");
354
354
  await agree("bob");
355
355
  const judged = recordTextDigest(text("ws-001-one.md"));
356
- expect(await ratify()).toMatchObject({ changed: ["state"] });
356
+ expect(await ratify()).toMatchObject({ changed: ["state", "closed_digest"] });
357
357
  expect(data("ws-001-one.md").state).toBe("ratified");
358
358
  expect(recordTextDigest(text("ws-001-one.md"))).toBe(judged);
359
359
  expect(await quorumOf("ws-001")).toMatchObject({ need: 2, agreed: 2, met: true, notCounted: [] });
360
360
  });
361
361
 
362
+ test("ratifying writes the whole-file seal, which covers the reviews and the state the digest leaves out (#2546)", async () => {
363
+ await agree("alice");
364
+ await agree("bob");
365
+ const judged = textDigest(text("ws-001-one.md"), ["reviews", "seal", "state", "closed_digest"]);
366
+ await ratify();
367
+ const sealed = text("ws-001-one.md");
368
+ expect(data("ws-001-one.md").closed_digest).toMatch(/^sha256:[0-9a-f]{64}$/);
369
+ expect(data("ws-001-one.md").closed_digest).toBe(recordSeal(sealed, "closed_digest"));
370
+ // The closed_digest line goes last in the front matter, and the digest leaves it out.
371
+ expect(sealed).toMatch(/\nclosed_digest: "sha256:[0-9a-f]{64}"\n---\n/);
372
+ expect(textDigest(sealed, ["reviews", "seal", "state", "closed_digest"])).toBe(judged);
373
+ const read = async () => (await queryRecords({ kind: KIND, cwd: dir })) as { records: { id: string; valid: boolean; reasons: { code: string; message: string }[] }[] };
374
+ expect((await read()).records.find((r) => r.id === "ws-001")).toMatchObject({ valid: true, reasons: [] });
375
+ // A verdict, a state or a byte of the body edited by hand after the close breaks the seal.
376
+ for (const edit of [
377
+ (t: string) => t.replace('verdict: "agree"', 'verdict: "dissent"\n note: "no"'),
378
+ (t: string) => t.replace('state: "ratified"', 'state: "superseded"'),
379
+ (t: string) => `${t}\nAn afterthought.\n`,
380
+ ]) {
381
+ writeFileSync(join(dir, "decisions", "ws-001-one.md"), edit(sealed));
382
+ const r = (await read()).records.find((x) => x.id === "ws-001")!;
383
+ expect(r.reasons.map((x) => x.code)).toContain("record-seal-mismatch");
384
+ }
385
+ // CRLF line endings, or a value quoted another way, leave the seal as it was.
386
+ writeFileSync(join(dir, "decisions", "ws-001-one.md"), sealed.replace('area: "D4"', "area: 'D4'").replace(/\n/g, "\r\n"));
387
+ expect((await read()).records.find((r) => r.id === "ws-001")).toMatchObject({ valid: true });
388
+ // And a closed record is never written again, so the seal can't be set by hand either.
389
+ writeFileSync(join(dir, "decisions", "ws-001-one.md"), sealed);
390
+ expect(code(await amendRecord({ kind: KIND, id: "ws-002", fields: fields({ closed_digest: `sha256:${"0".repeat(64)}` }), cwd: dir }))).toBe("write-input-invalid");
391
+ });
392
+
362
393
  test("a met quorum with an open concern ratifies, and the concern stays listed", async () => {
363
394
  await agree("alice");
364
395
  await agree("bob");
@@ -379,7 +410,7 @@ describe("ratifying against the quorum (#2873)", () => {
379
410
  await agree("alice");
380
411
  const digest = recordTextDigest(text("ws-001-one.md"));
381
412
  const reviews = [...(data("ws-001-one.md").reviews as Data[]), { reviewer: "bob", verdict: "agree", on: "2026-09-26", digest }];
382
- expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified", reviews }), cwd: dir })).toMatchObject({ changed: ["state", "reviews"] });
413
+ expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified", reviews }), cwd: dir })).toMatchObject({ changed: ["state", "reviews", "closed_digest"] });
383
414
  put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
384
415
  await agree("alice", "ws-006");
385
416
  await agree("bob", "ws-006");
@@ -403,11 +434,11 @@ describe("ratifying against the quorum (#2873)", () => {
403
434
  expect(withRatified).toContain('reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },');
404
435
  writeFileSync(kindFile, withRatified.replace(', ratified: "ratified" }', " }"));
405
436
  const judged = textDigest(text("ws-001-one.md"), ["reviews", "seal"]);
406
- expect(await ratify()).toMatchObject({ changed: ["state"] });
407
- expect(textDigest(text("ws-001-one.md"), ["reviews", "seal"])).not.toBe(judged);
437
+ expect(await ratify()).toMatchObject({ changed: ["state", "closed_digest"] });
438
+ expect(textDigest(text("ws-001-one.md"), ["reviews", "seal", "closed_digest"])).not.toBe(judged);
408
439
  writeFileSync(kindFile, withRatified.replace(' reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },\n', ""));
409
440
  put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
410
- expect(await ratify("ws-006")).toMatchObject({ changed: ["state"] });
441
+ expect(await ratify("ws-006")).toMatchObject({ changed: ["state", "closed_digest"] });
411
442
  });
412
443
 
413
444
  test("a kind whose reviews.ratified is not one of its states is refused", async () => {
@@ -417,6 +448,32 @@ describe("ratifying against the quorum (#2873)", () => {
417
448
  expect(code(doc)).toBe("kind-invalid");
418
449
  expect("error" in doc && doc.error.message).toMatch(/reviews\.ratified must be one of states/);
419
450
  });
451
+
452
+ test("on a copy of the chant repo's own decisions, verdicts count through ratifying and sealing (#2555, ws-063)", async () => {
453
+ for (const f of readdirSync(join(dir, "decisions"))) if (f.endsWith(".md")) rmSync(join(dir, "decisions", f));
454
+ for (const f of readdirSync(DECISIONS)) if (/^ws-\d{3}-.+\.md$/.test(f)) cpSync(join(DECISIONS, f), join(dir, "decisions", f));
455
+ const read = async () => {
456
+ const doc = await queryRecords({ kind: KIND, cwd: dir });
457
+ if ("error" in doc) throw new Error(doc.error.message);
458
+ return doc.records;
459
+ };
460
+ const decided = (await read()).filter((r) => r.state === "decided");
461
+ expect(decided.length).toBeGreaterThan(50);
462
+ // Every sixth one, and the last, keeps the test inside the unit-test budget (#2817).
463
+ const sample = decided.filter((_, i) => i % 6 === 0 || i === decided.length - 1);
464
+ const judged = new Map(sample.map((r) => [r.id!, r.digest]));
465
+ for (const id of judged.keys()) {
466
+ await agree("alice", id);
467
+ await agree("Bob ", id);
468
+ expect(await ratify(id)).toMatchObject({ changed: ["state", "closed_digest"] });
469
+ }
470
+ for (const r of (await read()).filter((x) => judged.has(x.id!))) {
471
+ expect(r, r.id!).toMatchObject({ state: "ratified", valid: true, reasons: [], digest: judged.get(r.id!) });
472
+ expect(r.quorum, r.id!).toMatchObject({ agreed: 2, met: true, notCounted: [] });
473
+ expect(r.quorum!.counted.map((c) => c.principal), r.id!).toEqual(["alice", "bob"]);
474
+ expect(r.data?.closed_digest, r.id!).toBe(recordSeal(readFileSync(join(dir, r.path), "utf-8"), "closed_digest"));
475
+ }
476
+ });
420
477
  });
421
478
 
422
479
  describe("records review", () => {