@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
@@ -20,7 +20,9 @@
20
20
  * `records close` is in `records-close.ts`.
21
21
  *
22
22
  * `new --sign` and `amend --sign` seal the record's author (#2688), and
23
- * `review --sign` seals a verdict (#2687): see `trust/seal.ts`.
23
+ * `review --sign` seals a verdict (#2687): see `trust/seal.ts`. A record of
24
+ * a kind that declares `seal` gets its whole-file seal when `new` or `amend`
25
+ * writes it into a closed state (#2546, ws-063).
24
26
  */
25
27
 
26
28
  import { readFileSync, statSync, writeFileSync } from "node:fs";
@@ -37,6 +39,7 @@ import {
37
39
  RecordReadError,
38
40
  RECORD_SEAL_FIELD,
39
41
  digestFields,
42
+ recordSeal,
40
43
  recordTextDigest,
41
44
  type LoadedRecordKind,
42
45
  type RecordEntry,
@@ -47,6 +50,8 @@ import { policyAtBase, resolveBase } from "./trust/provenance";
47
50
  import { WorkspaceReadError } from "./declaration";
48
51
  import { findSessionKinds, headCommit, sessionKindsFor } from "./session-kinds";
49
52
  import { workingTree } from "./tree";
53
+ import { AGENT_ENV, refuseRecordWrite, WRITE_SCOPE_CODES, WriteScopeError } from "./write-scope";
54
+ import type { WriteVerb } from "./declaration";
50
55
 
51
56
  // ── Contract ─────────────────────────────────────────────────────────────────
52
57
 
@@ -72,6 +77,7 @@ export const NEW_ERROR_CODES = [
72
77
  "source-harvest-not-proposed",
73
78
  "record-state-not-initial",
74
79
  "ratify-quorum-not-met",
80
+ ...WRITE_SCOPE_CODES,
75
81
  ...RECORD_REASON_CODES,
76
82
  ] as const satisfies readonly ReasonCode[];
77
83
 
@@ -86,6 +92,7 @@ export const AMEND_ERROR_CODES = [
86
92
  "amend-supersede-instead",
87
93
  "record-sign-failed",
88
94
  "ratify-quorum-not-met",
95
+ ...WRITE_SCOPE_CODES,
89
96
  ...RECORD_REASON_CODES,
90
97
  ] as const satisfies readonly ReasonCode[];
91
98
 
@@ -100,6 +107,7 @@ export const REVIEW_ERROR_CODES = [
100
107
  "review-sign-failed",
101
108
  "session-unknown",
102
109
  "session-not-open",
110
+ ...WRITE_SCOPE_CODES,
103
111
  ...RECORD_REASON_CODES,
104
112
  ] as const satisfies readonly ReasonCode[];
105
113
 
@@ -348,6 +356,15 @@ export async function open(kind: string, cwd: string): Promise<Opened> {
348
356
  };
349
357
  }
350
358
 
359
+ /**
360
+ * Refuse a write outside the writer's scope (#2548): the kind `o` opened,
361
+ * written with `verb`, by the session `agent` names (`CHANT_AGENT`) or the
362
+ * principal `by` names. Throws a {@link WriteScopeError}.
363
+ */
364
+ export function refuseOutOfScope(o: Opened, verb: WriteVerb, cwd: string, writer: { agent?: string; by?: string }): void {
365
+ refuseRecordWrite(cwd, { kindName: o.loaded.kind.name, kindFile: o.loaded.file, recordsDir: o.loaded.dir, verb, agent: writer.agent, principal: writer.by });
366
+ }
367
+
351
368
  /**
352
369
  * The kind's records as `records` reads them from `source`. A session kind's
353
370
  * subject records are read from the same source (#2693), so a write is
@@ -460,6 +477,33 @@ function refuseSealField(o: Opened, fields: Record<string, unknown>, flag: strin
460
477
  if (o.loaded.kind.reviews && RECORD_SEAL_FIELD in fields) {
461
478
  throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} set ${RECORD_SEAL_FIELD}, and only --sign writes a record's seal`);
462
479
  }
480
+ const closed = o.loaded.kind.seal?.field;
481
+ if (closed !== undefined && !o.loaded.kind.session && closed in fields) {
482
+ throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} set ${closed}, and chant writes it when the record closes`);
483
+ }
484
+ }
485
+
486
+ /**
487
+ * `text`, the record `data` holds, with its whole-file seal written into the
488
+ * kind's `seal.field` when the record is in a closed state (#2546, ws-063).
489
+ * The seal goes last, after the author seal, so it covers every other value
490
+ * and the body. `text` is returned as it is for a kind without `seal`, a
491
+ * session kind (whose seal `records close` writes), or an open record.
492
+ */
493
+ function sealClosed(o: Opened, text: string, data: Record<string, unknown>, id: string): { text: string; data: Record<string, unknown> } {
494
+ const { kind } = o.loaded;
495
+ if (!kind.seal || kind.session || kind.stateField === undefined) return { text, data };
496
+ const state = data[kind.stateField];
497
+ if (typeof state !== "string" || !(kind.closedStates ?? []).includes(state)) return { text, data };
498
+ const field = kind.seal.field;
499
+ const seal = recordSeal(text, field, kind.format);
500
+ if (seal === null) throw new RecordWriteError("record-unparseable", `${id} can't be read, so it can't be sealed`);
501
+ const sealedData = { ...data, [field]: seal };
502
+ const sealed = replaceFields(text, { [field]: seal }, sealedData);
503
+ if (sealed === undefined || recordSeal(sealed, field, kind.format) !== seal) {
504
+ throw new RecordWriteError("record-unparseable", `the ${field} field can't be written into ${id} without changing the text it seals`);
505
+ }
506
+ return { text: sealed, data: sealedData };
463
507
  }
464
508
 
465
509
  /**
@@ -584,7 +628,7 @@ function today(): string {
584
628
  }
585
629
 
586
630
  export function failure<C>(schema: string, err: unknown): WriteFailure<C> {
587
- if (err instanceof RecordWriteError || err instanceof RecordReadError) {
631
+ if (err instanceof RecordWriteError || err instanceof RecordReadError || err instanceof WriteScopeError) {
588
632
  return { $schema: schema, contract: RECORDS_WRITE_CONTRACT_VERSION, error: { code: err.code as C, message: err.message } };
589
633
  }
590
634
  throw err;
@@ -644,6 +688,8 @@ export interface NewRecordOptions {
644
688
  * `reviews.decider` field, which the fields must set.
645
689
  */
646
690
  sign?: string | true;
691
+ /** The agent session the write is made in (`CHANT_AGENT`, #2548), which its write scope binds. */
692
+ agent?: string;
647
693
  }
648
694
 
649
695
  /**
@@ -727,6 +773,7 @@ export async function newRecord(opts: NewRecordOptions & ChannelOptions): Promis
727
773
  throw new RecordWriteError("write-usage-invalid", `--prefix takes letters and digits, starting with a letter, not ${JSON.stringify(opts.prefix)}`);
728
774
  }
729
775
  const o = await open(opts.kind, opts.cwd);
776
+ refuseOutOfScope(o, "new", opts.cwd, opts);
730
777
  const fields = applyChannel(o, parseFields(opts.fields, "--from"), opts, true, "--from");
731
778
  const { kind, schema } = o.loaded;
732
779
  refuseSealField(o, fields, "--from");
@@ -758,6 +805,7 @@ export async function newRecord(opts: NewRecordOptions & ChannelOptions): Promis
758
805
  let text = renderRecord(data, title ? `\n# ${title}\n` : "");
759
806
  let seal: AuthorSeal | undefined;
760
807
  if (opts.sign !== undefined) ({ text, seal } = await sealAuthor(o, text, data, id, opts.sign, opts.cwd));
808
+ ({ text } = sealClosed(o, text, seal ? { ...data, [RECORD_SEAL_FIELD]: seal } : data, id));
761
809
  const written = await validatedEntry(o, before, path, text);
762
810
  await refuseRatifyBelowQuorum(o, written, id);
763
811
  const warnings = written.warnings;
@@ -789,6 +837,8 @@ export interface AmendRecordOptions {
789
837
  cwd: string;
790
838
  /** Seal the amended record's author, as `records new` does (#2688). */
791
839
  sign?: string | true;
840
+ /** The agent session the write is made in (#2548). */
841
+ agent?: string;
792
842
  }
793
843
 
794
844
  /**
@@ -808,6 +858,7 @@ export interface AmendRecordOptions {
808
858
  export async function amendRecord(opts: AmendRecordOptions & ChannelOptions): Promise<AmendDocument> {
809
859
  try {
810
860
  const o = await open(opts.kind, opts.cwd);
861
+ refuseOutOfScope(o, "amend", opts.cwd, opts);
811
862
  const given = applyChannel(o, parseFields(opts.fields, "--set"), opts, false, "--set");
812
863
  const { kind } = o.loaded;
813
864
  refuseSealField(o, given, "--set");
@@ -871,6 +922,12 @@ export async function amendRecord(opts: AmendRecordOptions & ChannelOptions): Pr
871
922
  sealDropped = `${opts.id} was sealed${typeof signer === "string" ? ` by ${signer}` : ""}, and the amendment moves its digest or its state, so the seal was removed: seal it again with records amend ${opts.id} --sign`;
872
923
  }
873
924
  if (stableJson(old[RECORD_SEAL_FIELD]) !== stableJson(merged[RECORD_SEAL_FIELD])) changed.push(RECORD_SEAL_FIELD);
925
+ // Entering a closed state seals the whole record, last (#2546, ws-063).
926
+ if (changed.length > 0 && kind.seal && !kind.session) {
927
+ const closedSeal = sealClosed(o, text, merged, opts.id);
928
+ text = closedSeal.text;
929
+ if (stableJson(old[kind.seal.field]) !== stableJson(closedSeal.data[kind.seal.field])) changed.push(kind.seal.field);
930
+ }
874
931
  let warnings = target.warnings;
875
932
  if (changed.length > 0) {
876
933
  const written = await validatedEntry(o, before, target.path, text);
@@ -916,6 +973,8 @@ export interface ReviewRecordOptions {
916
973
  * git's `user.signingkey`. Without it the verdict is written unsealed.
917
974
  */
918
975
  sign?: string | true;
976
+ /** The agent session the write is made in (#2548). */
977
+ agent?: string;
919
978
  }
920
979
 
921
980
  /**
@@ -932,6 +991,7 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
932
991
  if (opts.by.trim() === "") throw new RecordWriteError("write-usage-invalid", "--by needs the reviewer's name");
933
992
  if (opts.session !== undefined && opts.session === "") throw new RecordWriteError("write-usage-invalid", "--session needs a session id");
934
993
  const o = await open(opts.kind, opts.cwd);
994
+ refuseOutOfScope(o, "review", opts.cwd, opts);
935
995
  const { kind } = o.loaded;
936
996
  if (!kind.reviews) {
937
997
  throw new RecordWriteError("review-unsupported", `the ${kind.name} kind declares no reviews field, so its records take no review`);
@@ -946,6 +1006,8 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
946
1006
  throw new RecordWriteError("review-note-required", `a dissent needs a reason: pass --note <text> with the concern`);
947
1007
  }
948
1008
  const inSession = opts.session !== undefined ? await openSession(o, opts.session, opts.cwd) : undefined;
1009
+ // The verdict is appended to the session too, so the session's kind must be in scope as well.
1010
+ if (inSession) refuseOutOfScope(inSession.so, "review", opts.cwd, opts);
949
1011
  const reviews = target.data[field] ?? [];
950
1012
  if (!Array.isArray(reviews)) throw new RecordWriteError("record-schema-invalid", `${target.path}: ${field} is not a list`);
951
1013
  const current = o.source.read(target.path);
@@ -1129,6 +1191,8 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
1129
1191
  const { args } = ctx;
1130
1192
  const cwd = process.cwd();
1131
1193
  const verb = args.extraPositional;
1194
+ // The agent session the write is made in (#2548): set by the harness that runs the agent.
1195
+ const agent = process.env[AGENT_ENV] || undefined;
1132
1196
  const print = (doc: object): number => {
1133
1197
  console.log(JSON.stringify(doc, null, 2));
1134
1198
  return "error" in doc ? 1 : 0;
@@ -1140,7 +1204,7 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
1140
1204
  if (!id) return print(usage(RECORDS_CLOSE_SCHEMA_ID, "close needs the session's id"));
1141
1205
  const kind = args.kind !== undefined ? resolveWriteKind(args.kind, cwd) : await declaredSessionKind(RECORDS_CLOSE_SCHEMA_ID, cwd);
1142
1206
  if (typeof kind !== "string") return print(kind);
1143
- return print(await closeRecord({ kind, id, dryRun: args.dryRun, cwd }));
1207
+ return print(await closeRecord({ kind, id, dryRun: args.dryRun, cwd, agent }));
1144
1208
  }
1145
1209
  if (verb === "new") {
1146
1210
  const named = args.extraPositional2 ?? args.kind;
@@ -1148,7 +1212,7 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
1148
1212
  if (typeof kind !== "string") return print(kind);
1149
1213
  const input = readInput(RECORDS_NEW_SCHEMA_ID, "--from", args.migrateFrom, cwd);
1150
1214
  if (typeof input !== "string") return print(input);
1151
- return print(await newRecord({ kind, fields: input, prefix: args.prefix, by: args.by, sign: args.sign, dryRun: args.dryRun, cwd }));
1215
+ return print(await newRecord({ kind, fields: input, prefix: args.prefix, by: args.by, sign: args.sign, dryRun: args.dryRun, cwd, agent }));
1152
1216
  }
1153
1217
  const schema = verb === "amend" ? RECORDS_AMEND_SCHEMA_ID : RECORDS_REVIEW_SCHEMA_ID;
1154
1218
  const id = args.extraPositional2;
@@ -1159,11 +1223,11 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
1159
1223
  if (args.by !== undefined) return print(usage(schema, "--by is not taken by amend: set decided_by, or a kind's proposedBy field, with --set"));
1160
1224
  const input = readInput(schema, "--set", args.set, cwd);
1161
1225
  if (typeof input !== "string") return print(input);
1162
- return print(await amendRecord({ kind, id, fields: input, sign: args.sign, dryRun: args.dryRun, cwd }));
1226
+ return print(await amendRecord({ kind, id, fields: input, sign: args.sign, dryRun: args.dryRun, cwd, agent }));
1163
1227
  }
1164
1228
  if (args.verdict === undefined) return print(usage(schema, "--verdict agree|dissent|abstain is required"));
1165
1229
  if (args.by === undefined) return print(usage(schema, "--by <principal> is required"));
1166
1230
  return print(
1167
- await reviewRecord({ kind, id, verdict: args.verdict, by: args.by, note: args.note, session: args.session, sign: args.sign, dryRun: args.dryRun, cwd }),
1231
+ await reviewRecord({ kind, id, verdict: args.verdict, by: args.by, note: args.note, session: args.session, sign: args.sign, dryRun: args.dryRun, cwd, agent }),
1168
1232
  );
1169
1233
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json",
4
4
  "title": "chant workspace records output",
5
- "description": "What `chant workspace records --json` prints, version 1 of the read contract for records (#2536). This is the first-test slice (#2546): records are unsealed and there is no workspace declaration yet. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists: a new code is a new contract version. Contract version 1 is written by chant 0.81.0 and newer; a reader that needs it refuses output whose `contract` it doesn't know. Every code is in the one closed list of `reason-codes.ts`.",
5
+ "description": "What `chant workspace records --json` prints, version 1 of the read contract for records (#2536). A closed record of a kind that declares seal carries a whole-file seal, checked on every read (#2546). Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists: a new code is a new contract version. Contract version 1 is written by chant 0.81.0 and newer; a reader that needs it refuses output whose `contract` it doesn't know. Every code is in the one closed list of `reason-codes.ts`.",
6
6
  "oneOf": [{ "$ref": "#/$defs/result" }, { "$ref": "#/$defs/failure" }, { "$ref": "#/$defs/set" }],
7
7
  "$defs": {
8
8
  "decidedIn": {
@@ -47,6 +47,43 @@
47
47
  ]
48
48
  }
49
49
  },
50
+ "spec": {
51
+ "description": "Added in contract 1 by #2546 (#2524 D20). With --current only: the workspace's spec, the current records of each declared kind marked spec: true, each with the workspace files it pins. A kind whose read failed is left out, and is listed under kinds with its error.",
52
+ "type": "object",
53
+ "required": ["kinds", "records"],
54
+ "properties": {
55
+ "kinds": {
56
+ "type": "array",
57
+ "description": "The spec kinds, as the declaration names them, in its order.",
58
+ "items": {
59
+ "type": "object",
60
+ "required": ["member", "path", "name"],
61
+ "properties": {
62
+ "member": { "type": ["string", "null"] },
63
+ "path": { "type": "string" },
64
+ "name": { "type": ["string", "null"] }
65
+ }
66
+ }
67
+ },
68
+ "records": {
69
+ "type": "array",
70
+ "description": "Every current record of the spec kinds, in the order of kinds and then path. Invalid records are listed with valid false, as under kinds.",
71
+ "items": {
72
+ "type": "object",
73
+ "required": ["kind", "id", "path", "state", "valid", "digest", "assets"],
74
+ "properties": {
75
+ "kind": { "type": "string", "description": "The kind's name, such as decision." },
76
+ "id": { "type": ["string", "null"] },
77
+ "path": { "type": "string" },
78
+ "state": { "type": ["string", "null"] },
79
+ "valid": { "type": "boolean" },
80
+ "digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
81
+ "assets": { "type": "array", "items": { "$ref": "#/$defs/asset" } }
82
+ }
83
+ }
84
+ }
85
+ }
86
+ },
50
87
  "kind": false,
51
88
  "records": false,
52
89
  "error": false
@@ -69,6 +106,10 @@
69
106
  "format": {
70
107
  "enum": ["markdown-front-matter", "json"],
71
108
  "description": "Added in contract 1 by #2664 (ws-053). How each record file holds its structured core: the YAML front matter of a Markdown file, or the whole file as one JSON object, read as I-JSON (no repeated member name)."
109
+ },
110
+ "spec": {
111
+ "type": "boolean",
112
+ "description": "Added in contract 1 by #2546. true when the kind file marks the kind spec: true, so its current records and the files they pin are part of the workspace's spec (#2524 D20)."
72
113
  }
73
114
  }
74
115
  },
@@ -163,7 +204,7 @@
163
204
  "items": { "$ref": "#/$defs/warning" }
164
205
  },
165
206
  "digest": {
166
- "description": "Added in contract 1 by #2672. The lowercase hex SHA-256 of the record file's text, with LF line endings and without the kind's reviews block and its top-level seal block (#2688) in the front matter, or for a kind of format json without the top-level reviews and seal members, each with one comma beside it (#2664). A record with no seal hashes as it did before #2688. A review verdict names it in its digest field, and stops counting once the record's digest moves on; a record's author seal signs it. For a kind with no reviews list, the digest of the whole text.",
207
+ "description": "Added in contract 1 by #2672. The lowercase hex SHA-256 of the record file's text, with LF line endings and without the kind's reviews block and its top-level seal block (#2688) in the front matter, or for a kind of format json without the top-level reviews and seal members, each with one comma beside it (#2664). A record with no seal hashes as it did before #2688. A review verdict names it in its digest field, and stops counting once the record's digest moves on; a record's author seal signs it. A kind that declares seal (#2546) also leaves out the field holding the whole-file seal, and a kind with reviews.ratified its state line (#2873). For a kind with no reviews list, the digest of the whole text. It is not the record's seal: the seal covers the whole file (#2546, ws-063).",
167
208
  "type": "string",
168
209
  "pattern": "^[0-9a-f]{64}$"
169
210
  },
@@ -405,7 +446,17 @@
405
446
  "attestor": { "type": "string", "description": "The attestor that answered, such as ssh-commit." },
406
447
  "principal": { "type": "string", "description": "The signer, when attested." },
407
448
  "key": { "type": "string", "description": "The signing key's fingerprint, when known." },
408
- "reason": { "type": "string" }
449
+ "reason": { "type": "string" },
450
+ "returned": {
451
+ "description": "Added in contract 1 by #2552. Set when the record's bytes came back in a return that carries the commit they were made in: level, principal and key then judge that commit, with the signers at base plus those admitted for the return, and commit is the commit here that holds the bytes. A signer neither lists reads as attested-unverifiable-here until an admin admits it.",
452
+ "type": "object",
453
+ "required": ["id", "importedIn"],
454
+ "properties": {
455
+ "id": { "type": "string", "pattern": "^ret-[0-9a-f]{12}$", "description": "The return, .chant/returns/<id>.json at the workspace root." },
456
+ "commit": { "type": "string", "pattern": "^[0-9a-f]{40,64}$", "description": "The commit in the returned copy that last changed the file." },
457
+ "importedIn": { "type": ["string", "null"], "pattern": "^[0-9a-f]{40,64}$", "description": "The commit here that holds the returned bytes." }
458
+ }
459
+ }
409
460
  }
410
461
  },
411
462
  "reason": {
@@ -421,6 +472,7 @@
421
472
  "record-supersedes-conflict",
422
473
  "record-remediates-unknown",
423
474
  "record-remediates-not-closed",
475
+ "record-seal-mismatch",
424
476
  "session-seal-mismatch",
425
477
  "session-verdict-unknown-record"
426
478
  ]
@@ -13,15 +13,18 @@
13
13
  *
14
14
  * A record that fails any of that is still returned, with reason codes, and the
15
15
  * read succeeds. Only a failure to read the kind, its schema or the revision is
16
- * an error. Seals, attestation and the workspace declaration come later (#2546,
17
- * #2534); nothing here needs a `chant.workspace.json`.
16
+ * an error. A closed record of a kind that declares `seal` is checked against
17
+ * its whole-file seal ({@link recordSeal}, #2546); author and verdict seals
18
+ * are checked by `records-cli.ts` against the policy at base. Nothing here
19
+ * needs a `chant.workspace.json`.
18
20
  *
19
21
  * Everything under `workspace/` loads only when a `chant workspace` command
20
22
  * runs. The level-0 goldens (#2526) fail if a level-0 command loads it.
21
23
  */
22
24
 
23
25
  import { createHash } from "node:crypto";
24
- import { sha256Hex } from "../content-digest";
26
+ import { contentDigest, sha256Hex } from "../content-digest";
27
+ import { canonicalJson } from "../effect-receipt";
25
28
  import { readFileSync, statSync } from "node:fs";
26
29
  import { dirname, posix, relative, resolve, sep } from "node:path";
27
30
  import yaml from "js-yaml";
@@ -57,6 +60,8 @@ export const RECORD_REASON_CODES = [
57
60
  "record-remediates-unknown",
58
61
  /** A `remediates` link names a record that isn't closed: a record still open is amended instead (#2774). */
59
62
  "record-remediates-not-closed",
63
+ /** A closed record's seal is not the whole-file seal of its text now: it changed after it closed (#2546, ws-063). */
64
+ "record-seal-mismatch",
60
65
  /** A closed session's seal is not the digest of its text: it changed after it closed (#2673). */
61
66
  "session-seal-mismatch",
62
67
  /** A session's verdict names a record the kind's subject records do not have (#2673). */
@@ -171,13 +176,21 @@ export const RECORD_SEAL_FIELD = "seal";
171
176
  * moving a record to that state keeps the verdicts that ratified it
172
177
  * counting.
173
178
  */
174
- export function digestFields(kind: Pick<RecordKind, "reviews" | "stateField">): string[] | null {
179
+ export function digestFields(kind: Pick<RecordKind, "reviews" | "stateField" | "seal">): string[] | null {
175
180
  if (!kind.reviews) return null;
176
181
  const fields = [kind.reviews.field, RECORD_SEAL_FIELD];
177
182
  if (kind.reviews.ratified !== undefined && kind.stateField !== undefined) fields.push(kind.stateField);
183
+ // The whole-file seal is written when the record closes (#2546, ws-063), so
184
+ // the verdicts that ratified it keep counting on the sealed record.
185
+ if (kind.seal !== undefined) fields.push(kind.seal.field);
178
186
  return fields;
179
187
  }
180
188
 
189
+ /** The field holding a closed record's whole-file seal: the kind's `seal.field`, or a session kind's `session.seal` (#2546, #2673). */
190
+ export function sealFieldOf(kind: Pick<RecordKind, "seal" | "session">): string | undefined {
191
+ return kind.seal?.field ?? kind.session?.seal;
192
+ }
193
+
181
194
  /** What a verdict's seal establishes (#2687). See `trust/seal.ts`. */
182
195
  export interface VerdictAttestation {
183
196
  /** Why the verdict is not attested. Absent when its seal verified. */
@@ -430,8 +443,32 @@ export const recordKindSchema = z
430
443
  * Optional.
431
444
  */
432
445
  answers: z.object({ points: z.string().min(1) }).strict().optional(),
446
+ /**
447
+ * The top-level field that holds a closed record's seal (#2546, ws-003,
448
+ * ws-063): {@link recordSeal}, the whole-file JCS/SHA-256 of the record
449
+ * without that field. `records amend` and `records new` write it when a
450
+ * record enters one of `closedStates`, and `records` reports a closed
451
+ * record whose seal does not match as `record-seal-mismatch`. Requires
452
+ * states. A session kind's `session.seal` names the same field for a
453
+ * session, and when both are given they must agree. Optional.
454
+ */
455
+ seal: z.object({ field: z.string().min(1) }).strict().optional(),
456
+ /**
457
+ * The kind's current records, with the files they pin, are part of the
458
+ * workspace's spec (#2524 D20): `chant workspace records --current
459
+ * --json` lists them under `spec`. Optional, and false when left out.
460
+ */
461
+ spec: z.boolean().optional(),
433
462
  })
434
463
  .strict()
464
+ .refine((k) => k.states !== undefined || k.seal === undefined, {
465
+ message: "a kind without states cannot have seal: a record is sealed when it enters a closed state",
466
+ path: ["seal"],
467
+ })
468
+ .refine((k) => k.seal === undefined || k.session === undefined || k.seal.field === k.session.seal, {
469
+ message: "seal.field and session.seal name the field that holds a closed session's seal, and must be the same",
470
+ path: ["seal"],
471
+ })
435
472
  .refine((k) => (k.idField === undefined) !== (k.idFrom === undefined), {
436
473
  message: "a kind names its id with exactly one of idField and idFrom",
437
474
  path: ["idField"],
@@ -748,7 +785,9 @@ function nonJson(v: unknown, at: string, seen: Set<object>): string | undefined
748
785
  * record's author seal (#2688; {@link digestFields}), so sealing a record
749
786
  * leaves its digest where it was too. A record with no `seal` hashes exactly
750
787
  * as it did before author seals existed. A kind with `reviews.ratified` adds
751
- * its state field (#2873).
788
+ * its state field (#2873), and a kind with `seal` its seal field (#2546), so
789
+ * the whole-file seal written at the close moves no digest. The digest is not
790
+ * that seal: {@link recordSeal} covers the whole record.
752
791
  *
753
792
  * The rule, which a hand-editor can follow with a text editor and
754
793
  * `sha256sum`:
@@ -776,6 +815,50 @@ export function recordTextDigest(text: string, field: string | readonly string[]
776
815
  return createHash("sha256").update(kept, "utf8").digest("hex");
777
816
  }
778
817
 
818
+ // ── Record seal ──────────────────────────────────────────────────────────────
819
+
820
+ /**
821
+ * The seal of a closed record (#2546, ws-003, ws-063): `sha256:` and the
822
+ * lowercase hex SHA-256 of the RFC 8785 (JCS) form of the whole record, with
823
+ * only the field that holds the seal left out. It is a separate hash from
824
+ * {@link recordTextDigest}, which a review verdict names: the digest leaves
825
+ * out the reviews, the author seal and the state so verdicts keep counting,
826
+ * and the seal covers all of them.
827
+ *
828
+ * 1. Line endings become LF (CRLF and a lone CR each become one LF).
829
+ * 2. The structured core is parsed as the kind's format says: the front
830
+ * matter, as the JSON subset of YAML, or the whole JSON object. The
831
+ * top-level member `field` is removed from it.
832
+ * 3. For a Markdown record the value hashed is the object
833
+ * `{"body": <the text after the closing --- line>, "core": <the core>}`;
834
+ * for a JSON record it is the core itself.
835
+ * 4. That value is written in JCS (keys sorted by UTF-16 code units, no
836
+ * whitespace, ECMAScript number and string forms) and hashed as UTF-8.
837
+ *
838
+ * So every value in the record and every byte of its body is sealed, the
839
+ * reviews, the author seal and the state included, while the key order,
840
+ * quoting and indentation of the front matter are not: the same record
841
+ * written another way has the same seal. A YAML comment in the front matter
842
+ * is not part of the record, so it is not sealed either (#3066). Returns null when
843
+ * the core can't be parsed.
844
+ */
845
+ export function recordSeal(text: string, field: string, format: RecordFormat = "markdown-front-matter"): string | null {
846
+ const lf = text.replace(/\r\n?/g, "\n");
847
+ const parsed = parseRecord(format, lf);
848
+ if (!parsed.ok) return null;
849
+ const core = { ...parsed.value };
850
+ delete core[field];
851
+ const sealed = format === "json" ? core : { body: bodyText(lf), core };
852
+ return contentDigest(canonicalJson(sealed));
853
+ }
854
+
855
+ /** The text below a Markdown file's front matter, with LF line endings: everything after the closing `---` line. */
856
+ export function bodyText(text: string): string {
857
+ const normalised = text.replace(/\r\n?/g, "\n");
858
+ const m = normalised.match(/^---\n[\s\S]*?\n---(?:\n|$)/);
859
+ return m ? normalised.slice(m[0].length) : "";
860
+ }
861
+
779
862
  /**
780
863
  * `text` with the top-level member `field` removed from a JSON record, by the
781
864
  * rule a hand-editor follows for a JSON record (ws-053). With several fields
@@ -1282,6 +1365,14 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
1282
1365
  if (drift) entry.warnings.push({ code: "source-transcript-drift", message: drift });
1283
1366
  }
1284
1367
  }
1368
+ if (kind.seal && !kind.session && entry.state !== null && (kind.closedStates ?? []).includes(entry.state)) {
1369
+ // A closed record's whole-file seal (#2546, ws-063). A session's is checked with its verdicts (#2673).
1370
+ const seal = fm.value[kind.seal.field];
1371
+ const actual = typeof seal === "string" ? recordSeal(text, kind.seal.field, kind.format) : null;
1372
+ if (typeof seal === "string" && actual !== seal) {
1373
+ entry.reasons.push({ code: "record-seal-mismatch", message: `${kind.seal.field} is ${seal}, but the record seals to ${actual}: it changed after it closed` });
1374
+ }
1375
+ }
1285
1376
  if (kind.pins) {
1286
1377
  const cited = fm.value[kind.pins.field];
1287
1378
  if (Array.isArray(cited) && cited.length === 0) {