@intentius/chant 0.86.0 → 0.88.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 (83) hide show
  1. package/dist/cli/handlers/misc.d.ts.map +1 -1
  2. package/dist/cli/main.d.ts.map +1 -1
  3. package/dist/cli/mcp/server.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +5 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/cli/version.d.ts +8 -0
  7. package/dist/cli/version.d.ts.map +1 -0
  8. package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
  9. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  10. package/dist/workspace/composites.d.ts +13 -0
  11. package/dist/workspace/composites.d.ts.map +1 -1
  12. package/dist/workspace/environments.d.ts +80 -0
  13. package/dist/workspace/environments.d.ts.map +1 -0
  14. package/dist/workspace/intent.d.ts +6 -1
  15. package/dist/workspace/intent.d.ts.map +1 -1
  16. package/dist/workspace/reason-codes.d.ts +20 -4
  17. package/dist/workspace/reason-codes.d.ts.map +1 -1
  18. package/dist/workspace/records-cli.d.ts +12 -2
  19. package/dist/workspace/records-cli.d.ts.map +1 -1
  20. package/dist/workspace/records-close.d.ts +41 -0
  21. package/dist/workspace/records-close.d.ts.map +1 -0
  22. package/dist/workspace/records-since.d.ts +40 -1
  23. package/dist/workspace/records-since.d.ts.map +1 -1
  24. package/dist/workspace/records-write.d.ts +117 -12
  25. package/dist/workspace/records-write.d.ts.map +1 -1
  26. package/dist/workspace/records.d.ts +84 -14
  27. package/dist/workspace/records.d.ts.map +1 -1
  28. package/dist/workspace/runtimes.d.ts +6 -0
  29. package/dist/workspace/runtimes.d.ts.map +1 -1
  30. package/dist/workspace/session-kinds.d.ts +28 -0
  31. package/dist/workspace/session-kinds.d.ts.map +1 -0
  32. package/dist/workspace/status.d.ts +2 -0
  33. package/dist/workspace/status.d.ts.map +1 -1
  34. package/dist/workspace/trust/seal.d.ts +127 -0
  35. package/dist/workspace/trust/seal.d.ts.map +1 -0
  36. package/dist/workspace/trust/ssh-commit.d.ts +7 -0
  37. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  38. package/dist/workspace/work.d.ts +3 -3
  39. package/package.json +1 -1
  40. package/src/cli/handlers/misc.ts +1 -9
  41. package/src/cli/main.ts +29 -10
  42. package/src/cli/mcp/server.test.ts +14 -1
  43. package/src/cli/mcp/server.ts +3 -1
  44. package/src/cli/registry.ts +5 -0
  45. package/src/cli/version.ts +15 -0
  46. package/src/workspace/__fixtures__/sessions.ts +41 -0
  47. package/src/workspace/composites.schema.json +68 -3
  48. package/src/workspace/composites.test.ts +119 -5
  49. package/src/workspace/composites.ts +26 -7
  50. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +23 -0
  51. package/src/workspace/environments.ts +165 -0
  52. package/src/workspace/intent-gaps.test.ts +217 -0
  53. package/src/workspace/intent.schema.json +23 -1
  54. package/src/workspace/intent.test.ts +2 -0
  55. package/src/workspace/intent.ts +35 -3
  56. package/src/workspace/read-contract.test.ts +3 -0
  57. package/src/workspace/reason-codes.test.ts +9 -3
  58. package/src/workspace/reason-codes.ts +24 -4
  59. package/src/workspace/record-assets.test.ts +4 -3
  60. package/src/workspace/records-amend.schema.json +30 -1
  61. package/src/workspace/records-cli.ts +113 -14
  62. package/src/workspace/records-close.schema.json +192 -0
  63. package/src/workspace/records-close.ts +129 -0
  64. package/src/workspace/records-contract.test.ts +4 -3
  65. package/src/workspace/records-new.schema.json +25 -0
  66. package/src/workspace/records-review.schema.json +81 -3
  67. package/src/workspace/records-sessions-write.test.ts +274 -0
  68. package/src/workspace/records-since.schema.json +27 -2
  69. package/src/workspace/records-since.ts +120 -6
  70. package/src/workspace/records-write-contract.test.ts +5 -1
  71. package/src/workspace/records-write.test.ts +4 -2
  72. package/src/workspace/records-write.ts +336 -43
  73. package/src/workspace/records.schema.json +37 -3
  74. package/src/workspace/records.ts +145 -25
  75. package/src/workspace/runtimes.ts +12 -3
  76. package/src/workspace/session-kinds.ts +79 -0
  77. package/src/workspace/status.ts +1 -1
  78. package/src/workspace/trust/record-seal.test.ts +315 -0
  79. package/src/workspace/trust/seal.test.ts +222 -0
  80. package/src/workspace/trust/seal.ts +289 -0
  81. package/src/workspace/trust/ssh-commit.ts +2 -2
  82. package/src/workspace/work.test.ts +3 -1
  83. package/src/workspace/work.ts +4 -4
@@ -0,0 +1,315 @@
1
+ /**
2
+ * #2688: author seals. `records new --sign` and `records amend --sign` seal a
3
+ * record's author (decided_by for decisions), and `records` reports whether
4
+ * the seal verifies against the signers file at base.
5
+ *
6
+ * Each case writes through the real write commands and reads back through
7
+ * the real `records` query, in a git repository whose base (`main`) holds
8
+ * the signers file, or doesn't. Every document is checked against its
9
+ * published schema.
10
+ */
11
+
12
+ import { execFileSync } from "node:child_process";
13
+ import { readFileSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import Ajv2020 from "ajv/dist/2020";
16
+ import { afterEach, describe, expect, test } from "vitest";
17
+ import recordsSchema from "../records.schema.json";
18
+ import newSchema from "../records-new.schema.json";
19
+ import amendSchema from "../records-amend.schema.json";
20
+ import { queryRecords, type RecordView } from "../records-cli";
21
+ import { amendRecord, newRecord, reviewRecord, type AmendDocument, type NewDocument } from "../records-write";
22
+ import { parseFrontMatter, recordTextDigest } from "../records";
23
+ import { RECORD_SEAL_NAMESPACE, recordSealPayload } from "./seal";
24
+ import { hasSshKeygen, TestRepo, type Key } from "./test-repo";
25
+
26
+ const REPO = join(import.meta.dirname, "..", "..", "..", "..", "..");
27
+ const DECISIONS = join(REPO, "docs", "design", "decisions");
28
+ const KIND = "decisions/decision.kind.mjs";
29
+ const FILE = "decisions/ws-003-seal-scope.md";
30
+
31
+ const ajv = new Ajv2020({ strict: true, allErrors: true });
32
+ const validateRecords = ajv.compile(recordsSchema);
33
+ const validateNew = ajv.compile(newSchema);
34
+ const validateAmend = ajv.compile(amendSchema);
35
+
36
+ const repos: TestRepo[] = [];
37
+ afterEach(() => {
38
+ while (repos.length) repos.pop()!.cleanup();
39
+ });
40
+
41
+ /**
42
+ * A repository whose main holds ws-003 (decided by lex00) and, unless
43
+ * `signers` is false, a signers file listing lex00 and alice. The work
44
+ * happens on a branch, so main stays the base.
45
+ */
46
+ function workspace(label: string, opts: { signers?: boolean } = {}) {
47
+ const r = new TestRepo(`record-seal-${label}`);
48
+ repos.push(r);
49
+ const lex = r.key("lex00");
50
+ const alice = r.key("alice");
51
+ const mallory = r.key("mallory");
52
+ r.write(KIND, readFileSync(join(DECISIONS, "decision.kind.mjs"), "utf-8"));
53
+ r.write("decisions/decision.schema.json", readFileSync(join(DECISIONS, "decision.schema.json"), "utf-8"));
54
+ r.write(FILE, readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8"));
55
+ if (opts.signers !== false) r.write(".chant/allowed_signers", `lex00 ${lex.pub}\nalice@example.test ${alice.pub}\n`);
56
+ r.commit("base", lex);
57
+ r.git(["checkout", "-q", "-b", "change"]);
58
+ return { r, lex, alice, mallory };
59
+ }
60
+
61
+ /** ws-003's fields with `over` laid on top and no id, for `records new`. */
62
+ function fields(over: Record<string, unknown> = {}): string {
63
+ const fm = parseFrontMatter(readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8"));
64
+ if (!fm.ok) throw new Error(fm.message);
65
+ const data: Record<string, unknown> = { ...fm.value, title: "Sealed", ...over };
66
+ delete data.id;
67
+ return JSON.stringify(data);
68
+ }
69
+
70
+ async function create(r: TestRepo, key?: Key | string | true, over: Record<string, unknown> = {}): Promise<NewDocument> {
71
+ const doc = await newRecord({ kind: KIND, fields: fields(over), cwd: r.dir, ...(key !== undefined ? { sign: typeof key === "object" ? key.file : key } : {}) });
72
+ expect(validateNew(doc), JSON.stringify(validateNew.errors)).toBe(true);
73
+ return doc;
74
+ }
75
+
76
+ async function amend(r: TestRepo, set: Record<string, unknown>, key?: Key, id = "ws-003"): Promise<AmendDocument> {
77
+ const doc = await amendRecord({ kind: KIND, id, fields: JSON.stringify(set), cwd: r.dir, ...(key ? { sign: key.file } : {}) });
78
+ expect(validateAmend(doc), JSON.stringify(validateAmend.errors)).toBe(true);
79
+ return doc;
80
+ }
81
+
82
+ async function record(r: TestRepo, id = "ws-003"): Promise<RecordView> {
83
+ const doc = await queryRecords({ kind: KIND, cwd: r.dir, base: "main" });
84
+ expect(validateRecords(doc), JSON.stringify(validateRecords.errors)).toBe(true);
85
+ if ("error" in doc) throw new Error(doc.error.message);
86
+ const rec = doc.records.find((x) => x.id === id)!;
87
+ expect(rec.reasons).toEqual([]);
88
+ return rec;
89
+ }
90
+
91
+ const warningCodes = (rec: RecordView) => rec.warnings.map((w) => w.code);
92
+
93
+ describe("the digest leaves a top-level seal out (#2688)", () => {
94
+ const base = readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8");
95
+ const digest = recordTextDigest(base, ["reviews", "seal"]);
96
+
97
+ test("a record with no seal hashes as it did before author seals, and adding one anywhere in the front matter leaves it", () => {
98
+ expect(digest).toBe(recordTextDigest(base, "reviews"));
99
+ const seal = 'seal:\n signer: "lex00"\n key: "SHA256:abc"\n signature: "-----BEGIN SSH SIGNATURE-----\\nx\\n-----END SSH SIGNATURE-----\\n"';
100
+ const atEnd = base.replace("\n---\n\n# Seal scope", `\n${seal}\n---\n\n# Seal scope`);
101
+ const atStart = base.replace("---\nschema: 1\n", `---\n${seal}\nschema: 1\n`);
102
+ const beforeReviews = base.replace("\nreviews: []\n", `\n${seal}\nreviews: []\n`);
103
+ for (const t of [atEnd, atStart, beforeReviews]) {
104
+ expect(t).not.toBe(base);
105
+ expect(recordTextDigest(t, ["reviews", "seal"])).toBe(digest);
106
+ expect(recordTextDigest(t, ["seal", "reviews"])).toBe(digest);
107
+ }
108
+ // Only a top-level key counts: a seal nested in a verdict is the reviews block's.
109
+ expect(recordTextDigest(base.replace("title: ", "sealed: true\ntitle: "), ["reviews", "seal"])).not.toBe(digest);
110
+ });
111
+
112
+ test("for a JSON record, the member rule removes reviews, then seal", () => {
113
+ const json = (o: object) => `${JSON.stringify(o, null, 2)}\n`;
114
+ const bare = json({ id: "x-001", decided_by: "lex00", state: "decided" });
115
+ const d = recordTextDigest(bare, ["reviews", "seal"], "json");
116
+ expect(d).toBe(recordTextDigest(bare, "reviews", "json"));
117
+ for (const o of [
118
+ { id: "x-001", decided_by: "lex00", state: "decided", reviews: [], seal: { signer: "lex00" } },
119
+ { id: "x-001", seal: { signer: "lex00" }, decided_by: "lex00", reviews: [], state: "decided" },
120
+ { seal: { signer: "lex00" }, id: "x-001", decided_by: "lex00", state: "decided", reviews: [{ reviewer: "a" }] },
121
+ ]) {
122
+ expect(recordTextDigest(json(o), ["reviews", "seal"], "json")).toBe(d);
123
+ }
124
+ });
125
+ });
126
+
127
+ describe.skipIf(!hasSshKeygen)("author seals under a signers file at base", () => {
128
+ test("records new --sign writes a sealed record that reads as attested, and an unsealed one is read with record-unattested", async () => {
129
+ const { r, lex } = workspace("new");
130
+ const doc = await create(r, lex);
131
+ if ("error" in doc) throw new Error(doc.error.message);
132
+ expect(doc.seal).toMatchObject({ signer: "lex00", key: expect.stringMatching(/^SHA256:/) });
133
+ const text = readFileSync(join(r.dir, doc.path), "utf-8");
134
+ expect(text).toContain('\nseal:\n signer: "lex00"\n');
135
+
136
+ const sealed = await record(r, doc.id);
137
+ expect(sealed.attested).toBe(true);
138
+ expect(sealed.attestation).toMatchObject({ key: doc.seal!.key, message: expect.stringMatching(/sealed by lex00/) });
139
+ expect(sealed.attestation!.code).toBeUndefined();
140
+ expect(warningCodes(sealed)).not.toContain("record-unattested");
141
+
142
+ // ws-003 on main carries no seal: reported, and still read.
143
+ const plain = await record(r);
144
+ expect(plain).toMatchObject({ attested: false, valid: true, attestation: { code: "seal-missing" } });
145
+ expect(plain.warnings.find((w) => w.code === "record-unattested")!.message).toMatch(/ws-003 carries no seal/);
146
+ });
147
+
148
+ test("the seal signs id, digest, decided_by and state in chant-record, so a hand-editor can verify it with ssh-keygen", async () => {
149
+ const { r, lex } = workspace("hand");
150
+ const doc = await create(r, lex);
151
+ if ("error" in doc) throw new Error(doc.error.message);
152
+ const rec = await record(r, doc.id);
153
+ const payload = recordSealPayload(doc.id, rec.digest, "lex00", "decided");
154
+ expect(payload.toString()).toBe(`${doc.id}\n${rec.digest}\nlex00\ndecided`);
155
+ const sig = join(r.dir, "record.sig");
156
+ writeFileSync(sig, doc.seal!.signature);
157
+ // Throws when the signature does not verify.
158
+ execFileSync("ssh-keygen", ["-Y", "verify", "-f", join(r.dir, ".chant/allowed_signers"), "-I", "lex00", "-n", RECORD_SEAL_NAMESPACE, "-s", sig], { input: payload, stdio: ["pipe", "ignore", "ignore"] });
159
+ });
160
+
161
+ test("a review on a sealed record leaves the seal holding: the digest leaves both out", async () => {
162
+ const { r, lex, alice } = workspace("review");
163
+ await amend(r, {}, lex);
164
+ const before = await record(r);
165
+ expect(before.attested).toBe(true);
166
+ const review = await reviewRecord({ kind: KIND, id: "ws-003", verdict: "agree", by: "alice@example.test", cwd: r.dir, on: "2026-09-24", sign: alice.file });
167
+ expect("error" in review ? review.error : null).toBeNull();
168
+ const after = await record(r);
169
+ expect(after.digest).toBe(before.digest);
170
+ expect(after.attested).toBe(true);
171
+ expect(after.quorum!.counted.map((v) => v.reviewer)).toEqual(["alice@example.test"]);
172
+ });
173
+
174
+ test("amend --sign signs again over the new digest", async () => {
175
+ const { r, lex } = workspace("resign");
176
+ const first = await amend(r, {}, lex);
177
+ if ("error" in first) throw new Error(first.error.message);
178
+ expect(first.changed).toEqual(["seal"]);
179
+ const before = await record(r);
180
+
181
+ const second = await amend(r, { evidence: [] }, lex);
182
+ if ("error" in second) throw new Error(second.error.message);
183
+ expect(second.changed).toEqual(["evidence", "seal"]);
184
+ expect(second.sealDropped).toBeUndefined();
185
+ expect(second.seal!.signature).not.toBe(first.seal!.signature);
186
+ const after = await record(r);
187
+ expect(after.digest).not.toBe(before.digest);
188
+ expect(after.attested).toBe(true);
189
+ expect(warningCodes(after)).not.toContain("record-unattested");
190
+
191
+ // Nothing to change and the same key: the same seal, so nothing is written.
192
+ const again = await amend(r, {}, lex);
193
+ expect("error" in again ? again.error : again.changed).toEqual([]);
194
+ });
195
+
196
+ test("amend without --sign drops the seal and says so, so the record never carries one that fails", async () => {
197
+ const { r, lex } = workspace("drop");
198
+ await amend(r, {}, lex);
199
+ const doc = await amend(r, { evidence: [] });
200
+ if ("error" in doc) throw new Error(doc.error.message);
201
+ expect(doc.changed).toEqual(["evidence", "seal"]);
202
+ expect(doc.sealDropped).toMatch(/ws-003 was sealed by lex00, and the amendment moves its digest, so the seal was removed: seal it again with records amend ws-003 --sign/);
203
+ expect(readFileSync(join(r.dir, FILE), "utf-8")).not.toMatch(/^seal:/m);
204
+ // Changing only the reviews leaves the digest, so the seal stays.
205
+ await amend(r, {}, lex);
206
+ const reviewsOnly = await amend(r, { reviews: [{ reviewer: "alice@example.test", verdict: "abstain", on: "2026-09-24" }] });
207
+ if ("error" in reviewsOnly) throw new Error(reviewsOnly.error.message);
208
+ expect(reviewsOnly.changed).toEqual(["reviews"]);
209
+ expect(reviewsOnly.sealDropped).toBeUndefined();
210
+ expect((await record(r)).attested).toBe(true);
211
+ const moved = await amend(r, { evidence: [{ title: "a link", url: "https://example.test/a" }] });
212
+ expect("error" in moved ? moved.error : moved.sealDropped).toMatch(/the seal was removed/);
213
+ const rec = await record(r);
214
+ expect(rec).toMatchObject({ attested: false, attestation: { code: "seal-missing" } });
215
+ expect(warningCodes(rec)).toContain("record-unattested");
216
+ });
217
+
218
+ test("a stale seal left by hand fails, and an edited state fails", async () => {
219
+ const { r, lex } = workspace("stale");
220
+ await amend(r, {}, lex);
221
+ const path = join(r.dir, FILE);
222
+ // An evidence change made by hand, keeping the old seal.
223
+ writeFileSync(path, readFileSync(path, "utf-8").replace('as_of: null', 'as_of: "2026-09-24T00:00:00Z"'));
224
+ let rec = await record(r);
225
+ expect(rec).toMatchObject({ attested: false, attestation: { code: "seal-signature-invalid" } });
226
+ expect(warningCodes(rec)).toContain("record-unattested");
227
+
228
+ // The state is signed too: a decided record hand-promoted to ratified no longer verifies.
229
+ await amend(r, { evidence: JSON.parse(JSON.stringify(rec.data!.evidence)) }, lex);
230
+ expect((await record(r)).attested).toBe(true);
231
+ writeFileSync(path, readFileSync(path, "utf-8").replace('state: "decided"', 'state: "ratified"'));
232
+ rec = await record(r);
233
+ expect(rec.attestation!.code).toBe("seal-signature-invalid");
234
+ });
235
+
236
+ test("a signer not in the file: the seal verifies nowhere, and the record is read with record-unattested", async () => {
237
+ const { r, mallory } = workspace("unlisted");
238
+ // decided_by names mallory, whose key the signers file at base doesn't list.
239
+ const doc = await create(r, mallory, { decided_by: "mallory" });
240
+ if ("error" in doc) throw new Error(doc.error.message);
241
+ const rec = await record(r, doc.id);
242
+ expect(rec).toMatchObject({ attested: false, valid: true, attestation: { code: "seal-signer-unlisted", message: expect.stringMatching(/mallory has no key in \.chant\/allowed_signers at base/) } });
243
+ expect(warningCodes(rec)).toContain("record-unattested");
244
+
245
+ // Another listed signer's key, claiming to be lex00: signed with alice's key for decided_by lex00.
246
+ const { r: r2, alice } = workspace("wrongkey");
247
+ const forged = await create(r2, alice);
248
+ if ("error" in forged) throw new Error(forged.error.message);
249
+ expect((await record(r2, forged.id)).attestation!.code).toBe("seal-signature-invalid");
250
+ });
251
+
252
+ test("provenance and the author seal are reported side by side, each on its own terms", async () => {
253
+ const { r, lex } = workspace("provenance");
254
+ await amend(r, {}, lex);
255
+ // Uncommitted: the commit vouches for nothing, and the seal still verifies.
256
+ let rec = await record(r);
257
+ expect(rec.provenance.level).toBe("unattested");
258
+ expect(rec.attested).toBe(true);
259
+ // Committed with lex00's signature: both hold.
260
+ r.commit("seal ws-003", lex);
261
+ rec = await record(r);
262
+ expect(rec.provenance).toMatchObject({ level: "attested", principal: "lex00" });
263
+ expect(rec.attested).toBe(true);
264
+ // An unsigned commit of an unsealed amendment: neither holds.
265
+ await amend(r, { evidence: [] });
266
+ r.commit("unsigned");
267
+ rec = await record(r);
268
+ expect(rec.provenance.level).toBe("unattested");
269
+ expect(rec.attested).toBe(false);
270
+ });
271
+ });
272
+
273
+ describe.skipIf(!hasSshKeygen)("author seals with no signers file at base", () => {
274
+ test("an unsealed record carries no warning, and a seal is checked for integrity only", async () => {
275
+ const { r, lex } = workspace("inactive", { signers: false });
276
+ const plain = await record(r);
277
+ expect(plain).toMatchObject({ attested: null, attestation: { code: "seal-missing" } });
278
+ expect(warningCodes(plain)).not.toContain("record-unattested");
279
+ await amend(r, {}, lex);
280
+ const sealed = await record(r);
281
+ expect(sealed).toMatchObject({ attested: null, attestation: { code: "seal-unverifiable", key: expect.stringMatching(/^SHA256:/), message: expect.stringMatching(/intact/) } });
282
+ expect(warningCodes(sealed)).not.toContain("record-unattested");
283
+ });
284
+ });
285
+
286
+ describe.skipIf(!hasSshKeygen)("refusals", () => {
287
+ test("a record with no decided_by has no author to seal: record-sign-failed, and nothing is written", async () => {
288
+ const { r, lex } = workspace("noauthor");
289
+ const doc = await create(r, lex, { state: "proposed", choice: null, decided_by: null, decided_on: null });
290
+ expect("error" in doc && doc.error).toMatchObject({ code: "record-sign-failed", message: expect.stringMatching(/names no decided_by/) });
291
+ expect(r.git(["status", "--porcelain"]).trim()).toBe("");
292
+ // A proposal names no author, so it is not warned about under the signers file.
293
+ const unsigned = await create(r, undefined, { state: "proposed", choice: null, decided_by: null, decided_on: null });
294
+ if ("error" in unsigned) throw new Error(unsigned.error.message);
295
+ const rec = await record(r, unsigned.id);
296
+ expect(rec).toMatchObject({ attested: null, attestation: { code: "seal-missing" } });
297
+ expect(warningCodes(rec)).not.toContain("record-unattested");
298
+ });
299
+
300
+ test("a key that can't sign is record-sign-failed; seal in the fields is write-input-invalid; a closed record takes no seal", async () => {
301
+ const { r, lex } = workspace("refuse");
302
+ const before = readFileSync(join(r.dir, FILE), "utf-8");
303
+ const bad = await amend(r, {}, { name: "x", file: join(r.dir, "no-such-key"), pub: "" });
304
+ expect("error" in bad && bad.error.code).toBe("record-sign-failed");
305
+ const set = await amend(r, { seal: { signer: "lex00", key: "SHA256:x", signature: "x" } });
306
+ expect("error" in set && set.error.code).toBe("write-input-invalid");
307
+ const from = await create(r, undefined, { seal: { signer: "lex00" } });
308
+ expect("error" in from && from.error.code).toBe("write-input-invalid");
309
+ expect(readFileSync(join(r.dir, FILE), "utf-8")).toBe(before);
310
+
311
+ await amend(r, { state: "ratified" });
312
+ const closed = await amend(r, {}, lex);
313
+ expect("error" in closed && closed.error.code).toBe("record-closed");
314
+ });
315
+ });
@@ -0,0 +1,222 @@
1
+ /**
2
+ * #2687: sealed verdicts. A review counts under a signers file at base only
3
+ * when its reviewer signed it.
4
+ *
5
+ * Each case writes verdicts through `records review` and reads them back
6
+ * through the real `records` query, in a git repository whose base (`main`)
7
+ * holds the signers file, or doesn't. Every document is checked against its
8
+ * published schema.
9
+ */
10
+
11
+ import { readFileSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import Ajv2020 from "ajv/dist/2020";
14
+ import { afterEach, describe, expect, test } from "vitest";
15
+ import { parseArgs } from "../../cli/main";
16
+ import recordsSchema from "../records.schema.json";
17
+ import reviewSchema from "../records-review.schema.json";
18
+ import { queryRecords, type RecordView } from "../records-cli";
19
+ import { amendRecord, reviewRecord, type ReviewDocument } from "../records-write";
20
+ import type { QuorumVerdict } from "../records";
21
+ import { REVIEW_SEAL_NAMESPACE, reviewSealPayload } from "./seal";
22
+ import { hasSshKeygen, TestRepo, type Key } from "./test-repo";
23
+
24
+ const REPO = join(import.meta.dirname, "..", "..", "..", "..", "..");
25
+ const DECISIONS = join(REPO, "docs", "design", "decisions");
26
+ const KIND = "decisions/decision.kind.mjs";
27
+ const FILE = "decisions/ws-003-seal-scope.md";
28
+
29
+ const ajv = new Ajv2020({ strict: true, allErrors: true });
30
+ const validateRecords = ajv.compile(recordsSchema);
31
+ const validateReview = ajv.compile(reviewSchema);
32
+
33
+ const repos: TestRepo[] = [];
34
+ afterEach(() => {
35
+ while (repos.length) repos.pop()!.cleanup();
36
+ });
37
+
38
+ /**
39
+ * A repository whose main holds ws-003 (decided by lex00) and, unless
40
+ * `signers` is false, a signers file listing alice and bob. The work happens
41
+ * on a branch, so main stays the base.
42
+ */
43
+ function workspace(label: string, opts: { signers?: boolean } = {}) {
44
+ const r = new TestRepo(`seal-${label}`);
45
+ repos.push(r);
46
+ const alice = r.key("alice");
47
+ const bob = r.key("bob");
48
+ const mallory = r.key("mallory");
49
+ r.write(KIND, readFileSync(join(DECISIONS, "decision.kind.mjs"), "utf-8"));
50
+ r.write("decisions/decision.schema.json", readFileSync(join(DECISIONS, "decision.schema.json"), "utf-8"));
51
+ r.write(FILE, readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8"));
52
+ if (opts.signers !== false) r.write(".chant/allowed_signers", `alice@example.test ${alice.pub}\nbob@example.test ${bob.pub}\n`);
53
+ r.commit("base", alice);
54
+ r.git(["checkout", "-q", "-b", "change"]);
55
+ return { r, alice, bob, mallory };
56
+ }
57
+
58
+ async function review(r: TestRepo, by: string, sign?: Key | string | true, verdict = "agree"): Promise<ReviewDocument> {
59
+ const doc = await reviewRecord({ kind: KIND, id: "ws-003", verdict, by, cwd: r.dir, on: "2026-09-24", ...(sign !== undefined ? { sign: typeof sign === "object" ? sign.file : sign } : {}) });
60
+ expect(validateReview(doc), JSON.stringify(validateReview.errors)).toBe(true);
61
+ return doc;
62
+ }
63
+
64
+ async function record(r: TestRepo): Promise<RecordView> {
65
+ const doc = await queryRecords({ kind: KIND, cwd: r.dir, base: "main" });
66
+ expect(validateRecords(doc), JSON.stringify(validateRecords.errors)).toBe(true);
67
+ if ("error" in doc) throw new Error(doc.error.message);
68
+ const rec = doc.records.find((x) => x.id === "ws-003")!;
69
+ expect(rec.reasons).toEqual([]);
70
+ return rec;
71
+ }
72
+
73
+ /** Every verdict, counted or not, in list order, as `reviewer:reason code:seal code`. */
74
+ function verdicts(rec: RecordView): string[] {
75
+ const q = rec.quorum!;
76
+ return [...q.counted, ...q.notCounted]
77
+ .sort((a, b) => a.index - b.index)
78
+ .map((v: QuorumVerdict) => `${v.reviewer}:${v.reason?.code ?? "counted"}:${v.attestation.code ?? "attested"}`);
79
+ }
80
+
81
+ function verdict(rec: RecordView, index: number): QuorumVerdict {
82
+ const q = rec.quorum!;
83
+ return [...q.counted, ...q.notCounted].find((v) => v.index === index)!;
84
+ }
85
+
86
+ describe.skipIf(!hasSshKeygen)("sealed verdicts under a signers file at base", () => {
87
+ test("a sealed agree counts; an unsealed one, one by an unlisted principal and one signed with another's key do not", async () => {
88
+ const { r, alice, bob, mallory } = workspace("active");
89
+ const sealed = await review(r, "alice@example.test", alice);
90
+ if ("error" in sealed) throw new Error(sealed.error.message);
91
+ expect(sealed.review.seal).toMatchObject({ signer: "alice@example.test", key: expect.stringMatching(/^SHA256:/) });
92
+ await review(r, "bob@example.test");
93
+ await review(r, "mallory@example.test", mallory);
94
+ // alice's key, claiming to be bob.
95
+ await review(r, "Bob@Example.test ", alice);
96
+
97
+ const rec = await record(r);
98
+ expect(verdicts(rec)).toEqual([
99
+ "alice@example.test:counted:attested",
100
+ "bob@example.test:review-unattested:seal-missing",
101
+ "mallory@example.test:review-unattested:seal-signer-unlisted",
102
+ "Bob@Example.test :review-unattested:seal-signature-invalid",
103
+ ]);
104
+ const a = verdict(rec, 0);
105
+ expect(a.attested).toBe(true);
106
+ expect(a.attestation.key).toBe((sealed.review.seal as { key: string }).key);
107
+ expect(verdict(rec, 1)).toMatchObject({ attested: false, reason: { message: expect.stringMatching(/carries no seal/) } });
108
+ expect(verdict(rec, 2).reason!.message).toMatch(/mallory@example\.test has no key in \.chant\/allowed_signers at base/);
109
+ expect(verdict(rec, 3).reason!.message).toMatch(/does not verify for Bob@Example\.test/);
110
+ expect(rec.quorum).toMatchObject({ agreed: 1, met: false });
111
+ expect(bob).toBeDefined();
112
+ });
113
+
114
+ test("two sealed agrees meet the quorum", async () => {
115
+ const { r, alice, bob } = workspace("met");
116
+ await review(r, "alice@example.test", alice);
117
+ await review(r, "bob@example.test", bob);
118
+ const rec = await record(r);
119
+ expect(rec.quorum).toMatchObject({ agreed: 2, met: true, notCounted: [] });
120
+ });
121
+
122
+ test("an amendment leaves a sealed verdict on the older digest, and rewriting its digest breaks the seal", async () => {
123
+ const { r, alice } = workspace("amend");
124
+ await review(r, "alice@example.test", alice);
125
+ const before = await record(r);
126
+ const amended = await amendRecord({ kind: KIND, id: "ws-003", fields: JSON.stringify({ evidence: [] }), cwd: r.dir });
127
+ expect("error" in amended ? amended.error : null).toBeNull();
128
+
129
+ const after = await record(r);
130
+ expect(after.digest).not.toBe(before.digest);
131
+ // The seal still verifies over the text it judged; the verdict stops counting because the text moved.
132
+ expect(verdicts(after)).toEqual(["alice@example.test:review-older-digest:attested"]);
133
+
134
+ // Bringing the verdict up to date by hand, without a new signature.
135
+ const path = join(r.dir, FILE);
136
+ writeFileSync(path, readFileSync(path, "utf-8").replace(before.digest, after.digest));
137
+ const forged = await record(r);
138
+ expect(forged.digest).toBe(after.digest);
139
+ expect(verdicts(forged)).toEqual(["alice@example.test:review-unattested:seal-signature-invalid"]);
140
+ expect(verdict(forged, 0).attested).toBe(false);
141
+ });
142
+
143
+ test("attack: a seal lifted from another verdict, or made in the commit namespace, does not verify", async () => {
144
+ const { r, alice } = workspace("lift");
145
+ // An abstain, turned into an agree by hand: the seal covers the verdict.
146
+ await review(r, "alice@example.test", alice, "abstain");
147
+ const path = join(r.dir, FILE);
148
+ writeFileSync(path, readFileSync(path, "utf-8").replace('verdict: "abstain"', 'verdict: "agree"'));
149
+ let rec = await record(r);
150
+ expect(verdicts(rec)).toEqual(["alice@example.test:review-unattested:seal-signature-invalid"]);
151
+
152
+ // A good signature by alice over the right bytes, in the git namespace.
153
+ const text = readFileSync(path, "utf-8");
154
+ const payload = reviewSealPayload("ws-003", rec.digest, "agree", "alice@example.test", "2026-09-24");
155
+ const gitSig = r.sshSign(alice, payload, "git");
156
+ const current = /signature: ("[^"]*")/.exec(text)![1];
157
+ writeFileSync(path, text.replace(current, JSON.stringify(gitSig)));
158
+ rec = await record(r);
159
+ expect(verdicts(rec)).toEqual(["alice@example.test:review-unattested:seal-signature-invalid"]);
160
+
161
+ // The same bytes in the review namespace do verify, which shows it was the namespace.
162
+ writeFileSync(path, text.replace(current, JSON.stringify(r.sshSign(alice, payload, REVIEW_SEAL_NAMESPACE))));
163
+ rec = await record(r);
164
+ expect(verdicts(rec)).toEqual(["alice@example.test:counted:attested"]);
165
+ });
166
+
167
+ test("--sign with no file uses git's ssh user.signingkey", async () => {
168
+ const { r, bob } = workspace("gitkey");
169
+ r.git(["config", "gpg.format", "ssh"]);
170
+ r.git(["config", "user.signingkey", bob.file]);
171
+ const doc = await review(r, "bob@example.test", true);
172
+ expect("error" in doc ? doc.error : null).toBeNull();
173
+ expect(verdicts(await record(r))).toEqual(["bob@example.test:counted:attested"]);
174
+ });
175
+
176
+ test("a key that can't sign is refused with review-sign-failed, and nothing is written", async () => {
177
+ const { r } = workspace("nokey");
178
+ const before = readFileSync(join(r.dir, FILE), "utf-8");
179
+ const doc = await review(r, "alice@example.test", join(r.dir, "no-such-key"));
180
+ expect("error" in doc && doc.error.code).toBe("review-sign-failed");
181
+ r.git(["config", "gpg.format", "openpgp"]);
182
+ const git = await review(r, "alice@example.test", true);
183
+ expect("error" in git && git.error.message).toMatch(/gpg\.format openpgp/);
184
+ expect(readFileSync(join(r.dir, FILE), "utf-8")).toBe(before);
185
+ });
186
+ });
187
+
188
+ describe.skipIf(!hasSshKeygen)("sealed verdicts with no signers file at base", () => {
189
+ test("an unsealed verdict counts, and a seal is checked for integrity and reported without gating", async () => {
190
+ const { r, alice, bob } = workspace("inactive", { signers: false });
191
+ await review(r, "carol");
192
+ await review(r, "alice@example.test", alice);
193
+ await review(r, "bob@example.test", bob, "abstain");
194
+ const path = join(r.dir, FILE);
195
+ // bob's abstain turned into an agree by hand: its seal no longer covers it.
196
+ writeFileSync(path, readFileSync(path, "utf-8").replace('verdict: "abstain"', 'verdict: "agree"'));
197
+ const rec = await record(r);
198
+ expect(verdicts(rec)).toEqual([
199
+ "carol:counted:seal-missing",
200
+ "alice@example.test:counted:seal-unverifiable",
201
+ "bob@example.test:counted:seal-signature-invalid",
202
+ ]);
203
+ expect(verdict(rec, 0).attested).toBeNull();
204
+ expect(verdict(rec, 1)).toMatchObject({ attested: null, attestation: { key: expect.stringMatching(/^SHA256:/), message: expect.stringMatching(/intact/) } });
205
+ expect(verdict(rec, 2).attested).toBe(false);
206
+ expect(rec.quorum).toMatchObject({ agreed: 3, met: true });
207
+ });
208
+ });
209
+
210
+ describe("--sign on the command line", () => {
211
+ test("takes a key file, or nothing for git's key", () => {
212
+ expect(parseArgs(["workspace", "records", "review", "ws-003", "--sign", "~/.ssh/id_ed25519", "--by", "a"]).sign).toBe("~/.ssh/id_ed25519");
213
+ expect(parseArgs(["workspace", "records", "review", "ws-003", "--sign", "--by", "a"]).sign).toBe(true);
214
+ expect(parseArgs(["workspace", "records", "review", "ws-003", "--by", "a", "--sign"]).sign).toBe(true);
215
+ expect(parseArgs(["workspace", "records", "review", "ws-003", "--by", "a"]).sign).toBeUndefined();
216
+ });
217
+
218
+ test("records new and amend take it too, for the author seal (#2688)", () => {
219
+ expect(parseArgs(["workspace", "records", "new", "decisions/decision.kind.mjs", "--from", "-", "--sign"]).sign).toBe(true);
220
+ expect(parseArgs(["workspace", "records", "amend", "ws-003", "--set", "-", "--sign", "k"]).sign).toBe("k");
221
+ });
222
+ });