@intentius/chant 0.87.0 → 0.89.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 (79) hide show
  1. package/dist/cli/handlers/misc.d.ts.map +1 -1
  2. package/dist/cli/handlers/serve.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +10 -1
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/workspace-plugins.d.ts +40 -0
  7. package/dist/cli/mcp/workspace-plugins.d.ts.map +1 -0
  8. package/dist/cli/registry.d.ts +6 -1
  9. package/dist/cli/registry.d.ts.map +1 -1
  10. package/dist/cli/version.d.ts +8 -0
  11. package/dist/cli/version.d.ts.map +1 -0
  12. package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
  13. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  14. package/dist/workspace/composites.d.ts +13 -0
  15. package/dist/workspace/composites.d.ts.map +1 -1
  16. package/dist/workspace/environments.d.ts +80 -0
  17. package/dist/workspace/environments.d.ts.map +1 -0
  18. package/dist/workspace/reason-codes.d.ts +14 -5
  19. package/dist/workspace/reason-codes.d.ts.map +1 -1
  20. package/dist/workspace/records-cli.d.ts +6 -2
  21. package/dist/workspace/records-cli.d.ts.map +1 -1
  22. package/dist/workspace/records-close.d.ts +41 -0
  23. package/dist/workspace/records-close.d.ts.map +1 -0
  24. package/dist/workspace/records-since.d.ts +40 -1
  25. package/dist/workspace/records-since.d.ts.map +1 -1
  26. package/dist/workspace/records-write.d.ts +109 -11
  27. package/dist/workspace/records-write.d.ts.map +1 -1
  28. package/dist/workspace/records.d.ts +40 -12
  29. package/dist/workspace/records.d.ts.map +1 -1
  30. package/dist/workspace/runtimes.d.ts +6 -0
  31. package/dist/workspace/runtimes.d.ts.map +1 -1
  32. package/dist/workspace/session-kinds.d.ts +28 -0
  33. package/dist/workspace/session-kinds.d.ts.map +1 -0
  34. package/dist/workspace/status.d.ts +2 -0
  35. package/dist/workspace/status.d.ts.map +1 -1
  36. package/dist/workspace/trust/seal.d.ts +42 -0
  37. package/dist/workspace/trust/seal.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/handlers/misc.ts +1 -9
  40. package/src/cli/handlers/serve.ts +10 -1
  41. package/src/cli/main.test.ts +7 -0
  42. package/src/cli/main.ts +60 -9
  43. package/src/cli/mcp/server.test.ts +14 -1
  44. package/src/cli/mcp/server.ts +13 -2
  45. package/src/cli/mcp/workspace-plugins.ts +123 -0
  46. package/src/cli/registry.ts +6 -1
  47. package/src/cli/serve-mcp-workspace.test.ts +142 -0
  48. package/src/cli/version.ts +15 -0
  49. package/src/workspace/__fixtures__/sessions.ts +41 -0
  50. package/src/workspace/composites.schema.json +68 -3
  51. package/src/workspace/composites.test.ts +119 -5
  52. package/src/workspace/composites.ts +26 -7
  53. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +11 -0
  54. package/src/workspace/environments.ts +165 -0
  55. package/src/workspace/read-contract.test.ts +3 -0
  56. package/src/workspace/reason-codes.test.ts +8 -3
  57. package/src/workspace/reason-codes.ts +18 -6
  58. package/src/workspace/record-assets.test.ts +4 -3
  59. package/src/workspace/records-amend.schema.json +30 -1
  60. package/src/workspace/records-cli.ts +44 -7
  61. package/src/workspace/records-close.schema.json +192 -0
  62. package/src/workspace/records-close.ts +129 -0
  63. package/src/workspace/records-contract.test.ts +4 -3
  64. package/src/workspace/records-new.schema.json +25 -0
  65. package/src/workspace/records-review.schema.json +55 -2
  66. package/src/workspace/records-sessions-write.test.ts +274 -0
  67. package/src/workspace/records-since.schema.json +27 -2
  68. package/src/workspace/records-since.ts +120 -6
  69. package/src/workspace/records-write-contract.test.ts +5 -1
  70. package/src/workspace/records-write.test.ts +4 -2
  71. package/src/workspace/records-write.ts +307 -43
  72. package/src/workspace/records.schema.json +22 -3
  73. package/src/workspace/records.ts +73 -22
  74. package/src/workspace/runtimes.ts +12 -3
  75. package/src/workspace/session-kinds.ts +79 -0
  76. package/src/workspace/status.ts +1 -1
  77. package/src/workspace/trust/record-seal.test.ts +315 -0
  78. package/src/workspace/trust/seal.test.ts +4 -14
  79. package/src/workspace/trust/seal.ts +119 -25
@@ -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
+ });
@@ -16,7 +16,7 @@ import { parseArgs } from "../../cli/main";
16
16
  import recordsSchema from "../records.schema.json";
17
17
  import reviewSchema from "../records-review.schema.json";
18
18
  import { queryRecords, type RecordView } from "../records-cli";
19
- import { amendRecord, reviewRecord, runRecordsWrite, type ReviewDocument } from "../records-write";
19
+ import { amendRecord, reviewRecord, type ReviewDocument } from "../records-write";
20
20
  import type { QuorumVerdict } from "../records";
21
21
  import { REVIEW_SEAL_NAMESPACE, reviewSealPayload } from "./seal";
22
22
  import { hasSshKeygen, TestRepo, type Key } from "./test-repo";
@@ -215,18 +215,8 @@ describe("--sign on the command line", () => {
215
215
  expect(parseArgs(["workspace", "records", "review", "ws-003", "--by", "a"]).sign).toBeUndefined();
216
216
  });
217
217
 
218
- test("records new and amend refuse it: author seals are #2688", async () => {
219
- const lines: string[] = [];
220
- const log = console.log;
221
- console.log = (s: string) => lines.push(s);
222
- try {
223
- for (const verb of ["new", "amend"]) {
224
- const code = await runRecordsWrite({ args: { ...parseArgs(["workspace", "records", verb, "x", "--sign"]), extraPositional: verb, extraPositional2: "x" } } as never);
225
- expect(code).toBe(1);
226
- }
227
- } finally {
228
- console.log = log;
229
- }
230
- for (const l of lines) expect(JSON.parse(l).error).toMatchObject({ code: "write-usage-invalid", message: expect.stringMatching(/#2688/) });
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");
231
221
  });
232
222
  });
@@ -14,6 +14,12 @@
14
14
  * digest is the record's text digest (`recordTextDigest`), so the seal binds
15
15
  * the verdict to the text it judged. The seal lives inside the reviews block,
16
16
  * which the digest leaves out, so sealing a verdict never moves the digest.
17
+ *
18
+ * A record's author seal (#2688) works the same way, in its own namespace,
19
+ * `chant-record`. It sits in the record's top-level `seal` field, which the
20
+ * digest also leaves out, and signs the record id, the digest, the author
21
+ * (the kind's `reviews.decider` field, `decided_by` for decisions) and the
22
+ * state. An amendment moves the digest, so it has to be signed again.
17
23
  */
18
24
 
19
25
  import { execFileSync } from "node:child_process";
@@ -27,6 +33,9 @@ import { sshKeygen, verifySshSignature } from "./ssh-commit";
27
33
  /** The ssh signature namespace of a verdict seal. */
28
34
  export const REVIEW_SEAL_NAMESPACE = "chant-review";
29
35
 
36
+ /** The ssh signature namespace of a record's author seal (#2688). */
37
+ export const RECORD_SEAL_NAMESPACE = "chant-record";
38
+
30
39
  /** A seal as a review entry holds it. */
31
40
  export interface VerdictSeal {
32
41
  /** The principal who signed: the reviewer, as the signers file names them. */
@@ -63,10 +72,45 @@ export function reviewSealPayload(record: string, digest: string, verdict: strin
63
72
  return Buffer.from(`${record}\n${digest}\n${verdict}\n${reviewer}\n${on}`, "utf-8");
64
73
  }
65
74
 
75
+ /**
76
+ * The bytes a record's author seal signs (#2688): the id, the digest, the
77
+ * author and the state, joined by LF with no final newline. A kind without
78
+ * states signs an empty last line.
79
+ */
80
+ export function recordSealPayload(record: string, digest: string, author: string, state: string | null): Buffer {
81
+ return Buffer.from(`${record}\n${digest}\n${author}\n${state ?? ""}`, "utf-8");
82
+ }
83
+
84
+ /** The record an author seal covers (#2688). */
85
+ export interface SealedRecord {
86
+ record: string | null;
87
+ digest: string;
88
+ /** The author as the record names them, or null when it names none. */
89
+ author: string | null;
90
+ /** The kind's field that names the author, for messages. */
91
+ authorField: string;
92
+ state: string | null;
93
+ seal: unknown;
94
+ }
95
+
66
96
  const normalise = (name: string): string => name.normalize("NFKC").trim().toLowerCase();
67
97
 
68
98
  const FINGERPRINT = /key (SHA256:[A-Za-z0-9+/=]+)/;
69
99
 
100
+ /** What {@link checkSeal} needs to know of the thing sealed. */
101
+ interface SealSubject {
102
+ /** Who must have signed, as the thing names them. */
103
+ principal: string;
104
+ /** "the verdict by alice", "ws-003": what messages call it. */
105
+ what: string;
106
+ /** "reviewer", "decided_by": what messages call the principal's role. */
107
+ role: string;
108
+ namespace: string;
109
+ seal: unknown;
110
+ /** The signed bytes, or a message saying which covered value is missing. */
111
+ payload: Buffer | string;
112
+ }
113
+
70
114
  /**
71
115
  * Check a verdict's seal. With a signers file active at base, it verifies
72
116
  * against the keys listed there for the reviewer. With none, a seal present
@@ -74,44 +118,83 @@ const FINGERPRINT = /key (SHA256:[A-Za-z0-9+/=]+)/;
74
118
  * says whose key it is.
75
119
  */
76
120
  export function checkVerdictSeal(policy: TrustPolicy, v: SealedVerdict): SealCheck {
121
+ const covered = v.record === null || v.digest === null || typeof v.on !== "string";
122
+ return checkSeal(policy, {
123
+ principal: v.reviewer,
124
+ what: `the verdict by ${v.reviewer}`,
125
+ role: "reviewer",
126
+ namespace: REVIEW_SEAL_NAMESPACE,
127
+ seal: v.seal,
128
+ payload: covered
129
+ ? `a seal covers the record id, the verdict's digest and on, and the verdict by ${v.reviewer} lacks one`
130
+ : reviewSealPayload(v.record!, v.digest!, v.verdict, v.reviewer, v.on as string),
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Check a record's author seal (#2688), as a verdict's is checked, over
136
+ * {@link recordSealPayload} in the `chant-record` namespace. A record that
137
+ * names no author and carries no seal is `seal-missing` with `attested`
138
+ * null even under an active policy: it claims no author to attest.
139
+ */
140
+ export function checkRecordSeal(policy: TrustPolicy, r: SealedRecord): SealCheck {
141
+ const what = r.record ?? "the record";
142
+ if (r.author === null && (r.seal === undefined || r.seal === null)) {
143
+ return { attested: null, code: "seal-missing", message: `${what} names no ${r.authorField}, so it claims no author to seal` };
144
+ }
145
+ return checkSeal(policy, {
146
+ principal: r.author ?? "",
147
+ what,
148
+ role: r.authorField,
149
+ namespace: RECORD_SEAL_NAMESPACE,
150
+ seal: r.seal,
151
+ payload:
152
+ r.record === null || r.author === null
153
+ ? `a record's seal covers its id and its ${r.authorField}, and ${what} lacks one`
154
+ : recordSealPayload(r.record, r.digest, r.author, r.state),
155
+ });
156
+ }
157
+
158
+ function checkSeal(policy: TrustPolicy, s: SealSubject): SealCheck {
77
159
  const where = `${policy.signersPath} at base`;
78
- if (v.seal === undefined || v.seal === null) {
160
+ if (s.seal === undefined || s.seal === null) {
79
161
  return policy.active
80
- ? { attested: false, code: "seal-missing", message: `the verdict by ${v.reviewer} carries no seal` }
81
- : { attested: null, code: "seal-missing", message: `the verdict by ${v.reviewer} carries no seal; there is no signers file at base, so none is needed` };
162
+ ? { attested: false, code: "seal-missing", message: `${s.what} carries no seal` }
163
+ : { attested: null, code: "seal-missing", message: `${s.what} carries no seal; there is no signers file at base, so none is needed` };
82
164
  }
83
- const seal = v.seal as Partial<Record<keyof VerdictSeal, unknown>>;
165
+ const seal = s.seal as Partial<Record<keyof VerdictSeal, unknown>>;
84
166
  if (typeof seal !== "object" || Array.isArray(seal) || typeof seal.signer !== "string" || typeof seal.signature !== "string") {
85
- return { attested: false, code: "seal-signature-invalid", message: `the seal on the verdict by ${v.reviewer} is malformed: it needs signer and signature` };
86
- }
87
- if (normalise(seal.signer) !== normalise(v.reviewer)) {
88
- return { attested: false, code: "seal-signature-invalid", message: `the seal is by ${seal.signer}, and the verdict is ${v.reviewer}'s` };
167
+ return { attested: false, code: "seal-signature-invalid", message: `the seal on ${s.what} is malformed: it needs signer and signature` };
89
168
  }
90
- if (v.record === null || v.digest === null || typeof v.on !== "string") {
91
- return { attested: false, code: "seal-signature-invalid", message: `a seal covers the record id, the verdict's digest and on, and the verdict by ${v.reviewer} lacks one` };
169
+ if (normalise(seal.signer) !== normalise(s.principal)) {
170
+ return {
171
+ attested: false,
172
+ code: "seal-signature-invalid",
173
+ message: s.role === "reviewer" ? `the seal is by ${seal.signer}, and the verdict is ${s.principal}'s` : `the seal on ${s.what} is by ${seal.signer}, and its ${s.role} is ${s.principal || "empty"}`,
174
+ };
92
175
  }
93
- const payload = reviewSealPayload(v.record, v.digest, v.verdict, v.reviewer, v.on);
94
- if (!policy.active) return checkIntegrity(v.reviewer, payload, seal.signature);
95
- const listed = policy.signers.filter((s) => normalise(s.principal) === normalise(v.reviewer));
176
+ if (typeof s.payload === "string") return { attested: false, code: "seal-signature-invalid", message: s.payload };
177
+ if (!policy.active) return checkIntegrity(s.principal, s.payload, seal.signature, s.namespace);
178
+ const listed = policy.signers.filter((x) => normalise(x.principal) === normalise(s.principal));
96
179
  if (listed.length === 0) {
97
- return { attested: false, code: "seal-signer-unlisted", message: `${v.reviewer} has no key in ${where}, so the seal can't count` };
180
+ return { attested: false, code: "seal-signer-unlisted", message: `${s.principal} has no key in ${where}, so the seal can't count` };
98
181
  }
99
- const r = verifySshSignature(listed, payload, seal.signature, REVIEW_SEAL_NAMESPACE);
182
+ const r = verifySshSignature(listed, s.payload, seal.signature, s.namespace);
100
183
  if (r.ok) return { attested: true, message: `sealed by ${r.principal}${r.key ? ` with ${r.key}` : ""}, a signer ${where} lists`, ...(r.key ? { key: r.key } : {}) };
101
- if (r.missing) return { attested: null, code: "seal-unverifiable", message: `the seal by ${v.reviewer} can't be checked here: ${r.reason}` };
102
- return { attested: false, code: "seal-signature-invalid", message: `the seal does not verify for ${v.reviewer} against ${where}: ${r.reason}` };
184
+ if (r.missing) return { attested: null, code: "seal-unverifiable", message: `the seal by ${s.principal} can't be checked here: ${r.reason}` };
185
+ return { attested: false, code: "seal-signature-invalid", message: `the seal does not verify for ${s.principal} against ${where}: ${r.reason}` };
103
186
  }
104
187
 
105
188
  /** A seal checked with no signers file: is the signature over these bytes intact? */
106
- function checkIntegrity(reviewer: string, payload: Buffer, signature: string): SealCheck {
189
+ function checkIntegrity(principal: string, payload: Buffer, signature: string, namespace: string): SealCheck {
107
190
  const dir = mkdtempSync(join(tmpdir(), "chant-seal-"));
108
191
  try {
109
192
  const sigFile = join(dir, "signature");
110
193
  writeFileSync(sigFile, signature);
111
- const r = sshKeygen(["-Y", "check-novalidate", "-n", REVIEW_SEAL_NAMESPACE, "-s", sigFile], payload);
112
- if (r.missing) return { attested: null, code: "seal-unverifiable", message: `the seal by ${reviewer} can't be checked here: ssh-keygen is not installed` };
194
+ const r = sshKeygen(["-Y", "check-novalidate", "-n", namespace, "-s", sigFile], payload);
195
+ if (r.missing) return { attested: null, code: "seal-unverifiable", message: `the seal by ${principal} can't be checked here: ssh-keygen is not installed` };
113
196
  if (r.status !== 0) {
114
- return { attested: false, code: "seal-signature-invalid", message: `the seal by ${reviewer} does not verify over this verdict, even without a signers file` };
197
+ return { attested: false, code: "seal-signature-invalid", message: `the seal by ${principal} does not verify over ${namespace === RECORD_SEAL_NAMESPACE ? "this record" : "this verdict"}, even without a signers file` };
115
198
  }
116
199
  const key = FINGERPRINT.exec(r.stdout + r.stderr)?.[1];
117
200
  return {
@@ -174,8 +257,19 @@ function expandHome(path: string): string {
174
257
  * whose private half the ssh agent holds). Throws a {@link SealError}.
175
258
  */
176
259
  export function sealVerdict(keyFile: string, v: { record: string; digest: string; verdict: string; reviewer: string; on: string }): VerdictSeal {
177
- const payload = reviewSealPayload(v.record, v.digest, v.verdict, v.reviewer, v.on);
178
- const signed = sshKeygen(["-q", "-Y", "sign", "-n", REVIEW_SEAL_NAMESPACE, "-f", keyFile], payload);
260
+ return sign(keyFile, reviewSealPayload(v.record, v.digest, v.verdict, v.reviewer, v.on), REVIEW_SEAL_NAMESPACE, v.reviewer);
261
+ }
262
+
263
+ /**
264
+ * Seal a record's author (#2688) with the key in `keyFile`, over
265
+ * {@link recordSealPayload}. Throws a {@link SealError}.
266
+ */
267
+ export function sealRecord(keyFile: string, r: { record: string; digest: string; author: string; state: string | null }): VerdictSeal {
268
+ return sign(keyFile, recordSealPayload(r.record, r.digest, r.author, r.state), RECORD_SEAL_NAMESPACE, r.author);
269
+ }
270
+
271
+ function sign(keyFile: string, payload: Buffer, namespace: string, signer: string): VerdictSeal {
272
+ const signed = sshKeygen(["-q", "-Y", "sign", "-n", namespace, "-f", keyFile], payload);
179
273
  if (signed.missing) throw new SealError("--sign needs ssh-keygen, and it is not installed here");
180
274
  if (signed.status !== 0 || !signed.stdout.startsWith("-----BEGIN SSH SIGNATURE-----")) {
181
275
  throw new SealError(`ssh-keygen could not sign with ${keyFile}: ${signed.stderr.trim() || `exit ${signed.status}`}`);
@@ -185,10 +279,10 @@ export function sealVerdict(keyFile: string, v: { record: string; digest: string
185
279
  try {
186
280
  const sigFile = join(dir, "signature");
187
281
  writeFileSync(sigFile, signed.stdout);
188
- const check = sshKeygen(["-Y", "check-novalidate", "-n", REVIEW_SEAL_NAMESPACE, "-s", sigFile], payload);
282
+ const check = sshKeygen(["-Y", "check-novalidate", "-n", namespace, "-s", sigFile], payload);
189
283
  const key = FINGERPRINT.exec(check.stdout + check.stderr)?.[1];
190
284
  if (check.status !== 0 || !key) throw new SealError(`the signature ssh-keygen made with ${keyFile} does not check: ${check.stderr.trim()}`);
191
- return { signer: v.reviewer, key, signature: signed.stdout };
285
+ return { signer, key, signature: signed.stdout };
192
286
  } finally {
193
287
  rmSync(dir, { recursive: true, force: true });
194
288
  }