@intentius/chant 0.85.0 → 0.87.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 (129) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/registry.d.ts +13 -1
  3. package/dist/cli/registry.d.ts.map +1 -1
  4. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  5. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  6. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  7. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  8. package/dist/workspace/checks/records.d.ts +1 -0
  9. package/dist/workspace/checks/records.d.ts.map +1 -1
  10. package/dist/workspace/checks.d.ts +4 -0
  11. package/dist/workspace/checks.d.ts.map +1 -1
  12. package/dist/workspace/composites.d.ts +14 -1
  13. package/dist/workspace/composites.d.ts.map +1 -1
  14. package/dist/workspace/conformance/index.d.ts +211 -0
  15. package/dist/workspace/conformance/index.d.ts.map +1 -0
  16. package/dist/workspace/conformance/vitest.d.ts +11 -0
  17. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  18. package/dist/workspace/declaration.d.ts +28 -0
  19. package/dist/workspace/declaration.d.ts.map +1 -1
  20. package/dist/workspace/declaration.schema.json +40 -0
  21. package/dist/workspace/declared-kinds.d.ts +43 -0
  22. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  23. package/dist/workspace/graph-cli.d.ts +11 -0
  24. package/dist/workspace/graph-cli.d.ts.map +1 -1
  25. package/dist/workspace/intent-cli.d.ts +2 -1
  26. package/dist/workspace/intent-cli.d.ts.map +1 -1
  27. package/dist/workspace/intent-joins.d.ts +45 -8
  28. package/dist/workspace/intent-joins.d.ts.map +1 -1
  29. package/dist/workspace/intent.d.ts +71 -7
  30. package/dist/workspace/intent.d.ts.map +1 -1
  31. package/dist/workspace/ls.d.ts +31 -1
  32. package/dist/workspace/ls.d.ts.map +1 -1
  33. package/dist/workspace/reason-codes.d.ts +47 -4
  34. package/dist/workspace/reason-codes.d.ts.map +1 -1
  35. package/dist/workspace/record-sessions.d.ts +51 -0
  36. package/dist/workspace/record-sessions.d.ts.map +1 -0
  37. package/dist/workspace/record-source.d.ts +2 -0
  38. package/dist/workspace/record-source.d.ts.map +1 -1
  39. package/dist/workspace/records-cli.d.ts +71 -4
  40. package/dist/workspace/records-cli.d.ts.map +1 -1
  41. package/dist/workspace/records-since.d.ts +90 -0
  42. package/dist/workspace/records-since.d.ts.map +1 -0
  43. package/dist/workspace/records-write.d.ts +171 -0
  44. package/dist/workspace/records-write.d.ts.map +1 -0
  45. package/dist/workspace/records.d.ts +244 -15
  46. package/dist/workspace/records.d.ts.map +1 -1
  47. package/dist/workspace/runtimes.d.ts +60 -0
  48. package/dist/workspace/runtimes.d.ts.map +1 -0
  49. package/dist/workspace/status-gates.d.ts +90 -0
  50. package/dist/workspace/status-gates.d.ts.map +1 -0
  51. package/dist/workspace/status.d.ts +17 -0
  52. package/dist/workspace/status.d.ts.map +1 -1
  53. package/dist/workspace/trust/seal.d.ts +85 -0
  54. package/dist/workspace/trust/seal.d.ts.map +1 -0
  55. package/dist/workspace/trust/ssh-commit.d.ts +7 -0
  56. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  57. package/dist/workspace/work.d.ts +56 -0
  58. package/dist/workspace/work.d.ts.map +1 -0
  59. package/package.json +19 -1
  60. package/src/cli/main.ts +55 -3
  61. package/src/cli/registry.ts +13 -1
  62. package/src/lifecycle/gate-ledger.ts +14 -0
  63. package/src/workspace/__fixtures__/sessions.ts +66 -0
  64. package/src/workspace/checks/records.ts +19 -0
  65. package/src/workspace/checks.test.ts +2 -0
  66. package/src/workspace/checks.ts +7 -1
  67. package/src/workspace/composites.schema.json +65 -3
  68. package/src/workspace/composites.test.ts +95 -5
  69. package/src/workspace/composites.ts +28 -7
  70. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  71. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  72. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  73. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +376 -0
  74. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  75. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  76. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  77. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  78. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  79. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  80. package/src/workspace/conformance/conformance.test.ts +149 -0
  81. package/src/workspace/conformance/index.mjs +31 -0
  82. package/src/workspace/conformance/index.ts +453 -0
  83. package/src/workspace/conformance/vitest.ts +62 -0
  84. package/src/workspace/declaration.schema.json +40 -0
  85. package/src/workspace/declaration.ts +62 -0
  86. package/src/workspace/declared-kinds.test.ts +321 -0
  87. package/src/workspace/declared-kinds.ts +76 -0
  88. package/src/workspace/graph-cli.ts +8 -0
  89. package/src/workspace/intent-cli.ts +29 -6
  90. package/src/workspace/intent-gaps.test.ts +217 -0
  91. package/src/workspace/intent-joins.test.ts +60 -0
  92. package/src/workspace/intent-joins.ts +71 -19
  93. package/src/workspace/intent.schema.json +304 -7
  94. package/src/workspace/intent.test.ts +99 -0
  95. package/src/workspace/intent.ts +365 -46
  96. package/src/workspace/ls.schema.json +34 -0
  97. package/src/workspace/ls.ts +69 -4
  98. package/src/workspace/read-contract.test.ts +30 -9
  99. package/src/workspace/reason-codes.test.ts +16 -4
  100. package/src/workspace/reason-codes.ts +55 -4
  101. package/src/workspace/record-assets.test.ts +3 -1
  102. package/src/workspace/record-sessions.ts +105 -0
  103. package/src/workspace/record-source.ts +14 -5
  104. package/src/workspace/records-amend.schema.json +167 -0
  105. package/src/workspace/records-cli.ts +308 -19
  106. package/src/workspace/records-contract.test.ts +57 -2
  107. package/src/workspace/records-formats.test.ts +640 -0
  108. package/src/workspace/records-new.schema.json +158 -0
  109. package/src/workspace/records-quorum.test.ts +196 -0
  110. package/src/workspace/records-review.schema.json +227 -0
  111. package/src/workspace/records-sessions.test.ts +108 -0
  112. package/src/workspace/records-since.schema.json +193 -0
  113. package/src/workspace/records-since.test.ts +174 -0
  114. package/src/workspace/records-since.ts +259 -0
  115. package/src/workspace/records-write-contract.test.ts +125 -0
  116. package/src/workspace/records-write.test.ts +373 -0
  117. package/src/workspace/records-write.ts +765 -0
  118. package/src/workspace/records.schema.json +202 -9
  119. package/src/workspace/records.ts +700 -41
  120. package/src/workspace/runtimes.ts +107 -0
  121. package/src/workspace/status-contract.test.ts +163 -0
  122. package/src/workspace/status-gates.ts +215 -0
  123. package/src/workspace/status.schema.json +69 -3
  124. package/src/workspace/status.ts +35 -2
  125. package/src/workspace/trust/seal.test.ts +232 -0
  126. package/src/workspace/trust/seal.ts +195 -0
  127. package/src/workspace/trust/ssh-commit.ts +2 -2
  128. package/src/workspace/work.test.ts +390 -0
  129. package/src/workspace/work.ts +163 -0
@@ -20,6 +20,11 @@
20
20
  * declaration that can't be read, or an environment name that can't be one,
21
21
  * exits 1. The `--json` output is part of the read contract and is described
22
22
  * by `status.schema.json` beside this file.
23
+ *
24
+ * The JSON also lists each member's gates, read from its gate ledger on the
25
+ * same branch (#2674, `status-gates.ts`): the state of each, the approvals
26
+ * that count, and the `chant approve` line that answers it. The text view
27
+ * doesn't show them.
23
28
  */
24
29
 
25
30
  import { execFileSync } from "node:child_process";
@@ -27,11 +32,13 @@ import { existsSync, realpathSync } from "node:fs";
27
32
  import { relative, resolve } from "node:path";
28
33
  import { formatError } from "../cli/format";
29
34
  import type { CommandContext } from "../cli/registry";
35
+ import { GATES_DIR } from "../lifecycle/gate-ledger";
30
36
  import { readPathSha } from "../lifecycle/git";
31
37
  import { latestPerComponent, readReleaseLedger, type ReleaseRecord } from "../lifecycle/release-ledger";
32
38
  import { findWorkspaceRoot } from "../project-root";
33
39
  import { readDeclaration, readerVersion, WorkspaceReadError, type ErrorLocation, type Member } from "./declaration";
34
40
  import type { ReasonCode } from "./reason-codes";
41
+ import { GATE_REASON_CODES, readMemberGates, type GateLedgerReader, type StatusGate, type StatusGateLedger } from "./status-gates";
35
42
  import { gitTop, workingTree } from "./tree";
36
43
  import { handToRootChant } from "./which-chant";
37
44
 
@@ -61,6 +68,9 @@ export const STATUS_REASON_CODES = [
61
68
  ] as const satisfies readonly ReasonCode[];
62
69
  export type StatusReasonCode = (typeof STATUS_REASON_CODES)[number];
63
70
 
71
+ /** Why a member's gates can't be listed (#2674). Closed, like {@link STATUS_REASON_CODES}. */
72
+ export const STATUS_GATE_REASON_CODES = GATE_REASON_CODES;
73
+
64
74
  /**
65
75
  * Why the status couldn't be read at all. The declaration's own codes, except
66
76
  * the two that only `--at` returns, and one for the environment name.
@@ -129,7 +139,12 @@ export interface StatusMember {
129
139
  environments: StatusEnvironment[];
130
140
  /** Null without `--compare-to`. */
131
141
  compare: StatusCompare | null;
142
+ /** True when no environment's release ledger has a reason. Gate reasons don't change it. */
132
143
  readable: boolean;
144
+ /** Where the member's gates were read from, and why none are listed when none can be (#2674). */
145
+ gateLedger: StatusGateLedger;
146
+ /** Each gate in the member's gate ledger, one per environment asked for, sorted by component then gate. */
147
+ gates: StatusGate[];
133
148
  }
134
149
 
135
150
  export type StatusDocument =
@@ -160,6 +175,10 @@ export interface StatusQuery {
160
175
  compareTo?: string;
161
176
  /** Reads one ledger; the lifecycle reader unless a test swaps it. */
162
177
  readLedger?: LedgerReader;
178
+ /** Reads one member's gate ledger directory; git unless a test swaps it. */
179
+ readGates?: GateLedgerReader;
180
+ /** The instant a gate's expiry is measured against; now by default. */
181
+ now?: string;
163
182
  }
164
183
 
165
184
  class StatusError extends Error {
@@ -192,9 +211,14 @@ function lifecycleTip(cwd: string): string | null {
192
211
  }
193
212
  }
194
213
 
214
+ /** Whether the member writes under `_members/<member>/` (#2538). The root member never does. */
215
+ async function hasMemberLedger(member: Member, cwd: string): Promise<boolean> {
216
+ return member.dir !== "." && (await readPathSha(MEMBERS_DIR, member.name, { cwd })) !== null;
217
+ }
218
+
195
219
  /** Which ledger a member's releases for `env` are in (#2524 D7). */
196
220
  async function ledgerFor(member: Member, env: string, cwd: string): Promise<Omit<StatusLedger, "shared">> {
197
- if (member.dir !== "." && (await readPathSha(MEMBERS_DIR, member.name, { cwd })) !== null) {
221
+ if (await hasMemberLedger(member, cwd)) {
198
222
  return { layout: "members", path: `${MEMBERS_DIR}/${member.name}/${env}/releases.jsonl` };
199
223
  }
200
224
  return { layout: "flat", path: `${env}/releases.jsonl` };
@@ -276,10 +300,15 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
276
300
  const read = query.readLedger ?? defaultReader;
277
301
  const envs = query.compareTo !== undefined && query.compareTo !== query.env ? [query.env, query.compareTo] : [query.env];
278
302
 
303
+ const commit = lifecycleTip(found.dir);
304
+ const now = query.now ?? new Date().toISOString();
305
+
279
306
  const members: StatusMember[] = [];
280
307
  for (const m of declaration.members) {
281
308
  const environments: StatusEnvironment[] = [];
282
309
  for (const env of envs) environments.push(await readEnvironment(m, env, found.dir, read));
310
+ const own = await hasMemberLedger(m, found.dir);
311
+ const gates = await readMemberGates(own ? `${MEMBERS_DIR}/${m.name}/${GATES_DIR}` : GATES_DIR, own ? "members" : "flat", commit, envs, found.dir, now, query.readGates);
283
312
  members.push({
284
313
  name: m.name,
285
314
  dir: m.dir,
@@ -287,18 +316,22 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
287
316
  environments,
288
317
  compare: query.compareTo === undefined ? null : compareEnvironments(environments[0], environments[environments.length - 1]),
289
318
  readable: environments.every((e) => e.reason === null),
319
+ gateLedger: gates.ledger,
320
+ gates: gates.gates,
290
321
  });
291
322
  }
292
323
  // Several members read from one flat ledger see the same records; say so.
293
324
  const readers = new Map<string, number>();
294
325
  for (const m of members) for (const e of m.environments) if (e.ledger.layout === "flat") readers.set(e.ledger.path, (readers.get(e.ledger.path) ?? 0) + 1);
295
326
  for (const m of members) for (const e of m.environments) e.ledger.shared = e.ledger.layout === "flat" && (readers.get(e.ledger.path) ?? 0) > 1;
327
+ const flatGates = members.filter((m) => m.gateLedger.layout === "flat").length;
328
+ for (const m of members) m.gateLedger.shared = m.gateLedger.layout === "flat" && flatGates > 1;
296
329
 
297
330
  return {
298
331
  ...head,
299
332
  env: query.env,
300
333
  compareTo: query.compareTo ?? null,
301
- lifecycle: { ref: LIFECYCLE_REF, commit: lifecycleTip(found.dir) },
334
+ lifecycle: { ref: LIFECYCLE_REF, commit },
302
335
  workspace: { name: declaration.name, root: rootDir === "" ? "." : rootDir, file: declaration.file },
303
336
  members,
304
337
  summary: {
@@ -0,0 +1,232 @@
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, runRecordsWrite, 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 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/) });
231
+ });
232
+ });
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Sealed verdicts (#2687, part of #2547): a review entry signed by the
3
+ * principal it names, so the quorum counts it only when that principal
4
+ * signed it.
5
+ *
6
+ * A seal is a detached ssh signature, the same mechanism as the ssh-commit
7
+ * attestor (./ssh-commit.ts): `ssh-keygen -Y sign` to make it, and
8
+ * `ssh-keygen -Y verify` against the signers read at base to check it. It is
9
+ * made in its own namespace, `chant-review`, so neither a commit signature
10
+ * nor a signer-set signature can stand in for one.
11
+ *
12
+ * The signed bytes are the record id, the verdict's digest, the verdict, the
13
+ * reviewer and the date, each on its own line with no final newline. The
14
+ * digest is the record's text digest (`recordTextDigest`), so the seal binds
15
+ * the verdict to the text it judged. The seal lives inside the reviews block,
16
+ * which the digest leaves out, so sealing a verdict never moves the digest.
17
+ */
18
+
19
+ import { execFileSync } from "node:child_process";
20
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
21
+ import { homedir, tmpdir } from "node:os";
22
+ import { join, resolve } from "node:path";
23
+ import type { SealCode } from "../records";
24
+ import { type TrustPolicy } from "./policy";
25
+ import { sshKeygen, verifySshSignature } from "./ssh-commit";
26
+
27
+ /** The ssh signature namespace of a verdict seal. */
28
+ export const REVIEW_SEAL_NAMESPACE = "chant-review";
29
+
30
+ /** A seal as a review entry holds it. */
31
+ export interface VerdictSeal {
32
+ /** The principal who signed: the reviewer, as the signers file names them. */
33
+ signer: string;
34
+ /** The signing key's fingerprint, such as `SHA256:...`. Reported, never trusted: the signature is the proof. */
35
+ key: string;
36
+ /** The armored ssh signature. */
37
+ signature: string;
38
+ }
39
+
40
+ /** What a verdict's seal establishes. */
41
+ export interface SealCheck {
42
+ /** true: the seal verifies for the reviewer against the signers at base. false: it is missing under an active policy, or it fails. null: nothing here can say. */
43
+ attested: boolean | null;
44
+ /** Why it is not attested. Absent when `attested` is true. */
45
+ code?: SealCode;
46
+ message: string;
47
+ /** The fingerprint of the key that made the signature, when the signature was checked. */
48
+ key?: string;
49
+ }
50
+
51
+ /** The verdict a seal covers. */
52
+ export interface SealedVerdict {
53
+ record: string | null;
54
+ reviewer: string;
55
+ verdict: string;
56
+ on: unknown;
57
+ digest: string | null;
58
+ seal: unknown;
59
+ }
60
+
61
+ /** The bytes a verdict seal signs. */
62
+ export function reviewSealPayload(record: string, digest: string, verdict: string, reviewer: string, on: string): Buffer {
63
+ return Buffer.from(`${record}\n${digest}\n${verdict}\n${reviewer}\n${on}`, "utf-8");
64
+ }
65
+
66
+ const normalise = (name: string): string => name.normalize("NFKC").trim().toLowerCase();
67
+
68
+ const FINGERPRINT = /key (SHA256:[A-Za-z0-9+/=]+)/;
69
+
70
+ /**
71
+ * Check a verdict's seal. With a signers file active at base, it verifies
72
+ * against the keys listed there for the reviewer. With none, a seal present
73
+ * is checked for integrity only (`ssh-keygen -Y check-novalidate`): nothing
74
+ * says whose key it is.
75
+ */
76
+ export function checkVerdictSeal(policy: TrustPolicy, v: SealedVerdict): SealCheck {
77
+ const where = `${policy.signersPath} at base`;
78
+ if (v.seal === undefined || v.seal === null) {
79
+ 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` };
82
+ }
83
+ const seal = v.seal as Partial<Record<keyof VerdictSeal, unknown>>;
84
+ 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` };
89
+ }
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` };
92
+ }
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));
96
+ 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` };
98
+ }
99
+ const r = verifySshSignature(listed, payload, seal.signature, REVIEW_SEAL_NAMESPACE);
100
+ 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}` };
103
+ }
104
+
105
+ /** A seal checked with no signers file: is the signature over these bytes intact? */
106
+ function checkIntegrity(reviewer: string, payload: Buffer, signature: string): SealCheck {
107
+ const dir = mkdtempSync(join(tmpdir(), "chant-seal-"));
108
+ try {
109
+ const sigFile = join(dir, "signature");
110
+ 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` };
113
+ 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` };
115
+ }
116
+ const key = FINGERPRINT.exec(r.stdout + r.stderr)?.[1];
117
+ return {
118
+ attested: null,
119
+ code: "seal-unverifiable",
120
+ message: `the signature is intact${key ? ` (${key})` : ""}, and there is no signers file at base to say whose key it is`,
121
+ ...(key ? { key } : {}),
122
+ };
123
+ } finally {
124
+ rmSync(dir, { recursive: true, force: true });
125
+ }
126
+ }
127
+
128
+ /** Why a seal could not be made. */
129
+ export class SealError extends Error {
130
+ constructor(message: string) {
131
+ super(message);
132
+ this.name = "SealError";
133
+ }
134
+ }
135
+
136
+ /**
137
+ * The key `--sign` names: the file given, or with none, git's
138
+ * `user.signingkey` when `gpg.format` is `ssh`, as `git commit -S` reads it.
139
+ * A literal public key (`key::ssh-...`, or `ssh-...`) signs through the ssh
140
+ * agent holding its private half. Returns the file to pass to ssh-keygen,
141
+ * and a cleanup for a temporary one.
142
+ */
143
+ export function resolveSigningKey(sign: string | true, cwd: string): { file: string; cleanup: () => void } {
144
+ const none = { cleanup: () => {} };
145
+ if (sign !== true) return { file: resolve(cwd, expandHome(sign)), ...none };
146
+ const config = (key: string): string | undefined => {
147
+ try {
148
+ return execFileSync("git", ["config", "--get", key], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ };
153
+ const format = config("gpg.format");
154
+ const key = config("user.signingkey");
155
+ if (format !== "ssh" || key === undefined) {
156
+ throw new SealError(
157
+ `--sign with no key file uses git's user.signingkey when gpg.format is ssh, and git has ${format === "ssh" ? "no user.signingkey" : `gpg.format ${format ?? "unset"}`}; pass --sign <key file>`,
158
+ );
159
+ }
160
+ const literal = key.startsWith("key::") ? key.slice(5) : key.startsWith("ssh-") || key.startsWith("ecdsa-") || key.startsWith("sk-") ? key : undefined;
161
+ if (literal === undefined) return { file: resolve(cwd, expandHome(key)), ...none };
162
+ const dir = mkdtempSync(join(tmpdir(), "chant-sign-"));
163
+ const file = join(dir, "key.pub");
164
+ writeFileSync(file, `${literal}\n`);
165
+ return { file, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
166
+ }
167
+
168
+ function expandHome(path: string): string {
169
+ return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
170
+ }
171
+
172
+ /**
173
+ * Seal a verdict with the key in `keyFile` (a private key, or a public key
174
+ * whose private half the ssh agent holds). Throws a {@link SealError}.
175
+ */
176
+ 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);
179
+ if (signed.missing) throw new SealError("--sign needs ssh-keygen, and it is not installed here");
180
+ if (signed.status !== 0 || !signed.stdout.startsWith("-----BEGIN SSH SIGNATURE-----")) {
181
+ throw new SealError(`ssh-keygen could not sign with ${keyFile}: ${signed.stderr.trim() || `exit ${signed.status}`}`);
182
+ }
183
+ // The fingerprint, read back from the signature itself.
184
+ const dir = mkdtempSync(join(tmpdir(), "chant-seal-"));
185
+ try {
186
+ const sigFile = join(dir, "signature");
187
+ writeFileSync(sigFile, signed.stdout);
188
+ const check = sshKeygen(["-Y", "check-novalidate", "-n", REVIEW_SEAL_NAMESPACE, "-s", sigFile], payload);
189
+ const key = FINGERPRINT.exec(check.stdout + check.stderr)?.[1];
190
+ 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 };
192
+ } finally {
193
+ rmSync(dir, { recursive: true, force: true });
194
+ }
195
+ }
@@ -65,8 +65,8 @@ function objectFormat(repo: string): string {
65
65
  }
66
66
  }
67
67
 
68
- /** Run ssh-keygen; `missing` is true when it is not installed. */
69
- function sshKeygen(args: string[], input?: Buffer): { status: number | null; stdout: string; stderr: string; missing: boolean } {
68
+ /** Run ssh-keygen; `missing` is true when it is not installed. Shared with the verdict seals (./seal.ts). */
69
+ export function sshKeygen(args: string[], input?: Buffer): { status: number | null; stdout: string; stderr: string; missing: boolean } {
70
70
  const r = spawnSync("ssh-keygen", args, { input, encoding: "buffer", timeout: 30_000 });
71
71
  const missing = (r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
72
72
  return { status: r.status, stdout: r.stdout?.toString("utf-8") ?? "", stderr: r.stderr?.toString("utf-8") ?? "", missing };