@intentius/chant 0.86.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 (40) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/registry.d.ts +2 -0
  3. package/dist/cli/registry.d.ts.map +1 -1
  4. package/dist/workspace/intent.d.ts +6 -1
  5. package/dist/workspace/intent.d.ts.map +1 -1
  6. package/dist/workspace/reason-codes.d.ts +9 -2
  7. package/dist/workspace/reason-codes.d.ts.map +1 -1
  8. package/dist/workspace/records-cli.d.ts +6 -0
  9. package/dist/workspace/records-cli.d.ts.map +1 -1
  10. package/dist/workspace/records-write.d.ts +9 -2
  11. package/dist/workspace/records-write.d.ts.map +1 -1
  12. package/dist/workspace/records.d.ts +47 -5
  13. package/dist/workspace/records.d.ts.map +1 -1
  14. package/dist/workspace/trust/seal.d.ts +85 -0
  15. package/dist/workspace/trust/seal.d.ts.map +1 -0
  16. package/dist/workspace/trust/ssh-commit.d.ts +7 -0
  17. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  18. package/dist/workspace/work.d.ts +3 -3
  19. package/package.json +1 -1
  20. package/src/cli/main.ts +10 -3
  21. package/src/cli/registry.ts +2 -0
  22. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +12 -0
  23. package/src/workspace/intent-gaps.test.ts +217 -0
  24. package/src/workspace/intent.schema.json +23 -1
  25. package/src/workspace/intent.test.ts +2 -0
  26. package/src/workspace/intent.ts +35 -3
  27. package/src/workspace/reason-codes.test.ts +2 -1
  28. package/src/workspace/reason-codes.ts +10 -2
  29. package/src/workspace/record-assets.test.ts +2 -2
  30. package/src/workspace/records-cli.ts +70 -8
  31. package/src/workspace/records-contract.test.ts +2 -2
  32. package/src/workspace/records-review.schema.json +26 -1
  33. package/src/workspace/records-write.ts +32 -3
  34. package/src/workspace/records.schema.json +16 -1
  35. package/src/workspace/records.ts +78 -9
  36. package/src/workspace/trust/seal.test.ts +232 -0
  37. package/src/workspace/trust/seal.ts +195 -0
  38. package/src/workspace/trust/ssh-commit.ts +2 -2
  39. package/src/workspace/work.test.ts +3 -1
  40. package/src/workspace/work.ts +4 -4
@@ -80,6 +80,7 @@ export const REVIEW_ERROR_CODES = [
80
80
  "review-unsupported",
81
81
  "record-closed",
82
82
  "review-note-required",
83
+ "review-sign-failed",
83
84
  ...RECORD_REASON_CODES,
84
85
  ] as const satisfies readonly ReasonCode[];
85
86
 
@@ -562,7 +563,7 @@ export interface ReviewRecordOptions {
562
563
  kind: string;
563
564
  id: string;
564
565
  verdict: string;
565
- /** The reviewer, as the caller names them. chant does not check who it is; attestation does (#2547). */
566
+ /** The reviewer, as the caller names them. chant does not check who it is; a seal does, on read (#2687). */
566
567
  by: string;
567
568
  note?: string;
568
569
  /** The review session the verdict was given in. */
@@ -571,11 +572,18 @@ export interface ReviewRecordOptions {
571
572
  cwd: string;
572
573
  /** The date written as `on`, YYYY-MM-DD. Defaults to today, in UTC. */
573
574
  on?: string;
575
+ /**
576
+ * Seal the verdict (#2687): a key file, resolved against `cwd`, or true for
577
+ * git's `user.signingkey`. Without it the verdict is written unsealed.
578
+ */
579
+ sign?: string | true;
574
580
  }
575
581
 
576
582
  /**
577
583
  * `records review`: append one verdict to a record's reviews, with the date
578
584
  * and the digest of the record text it judged ({@link recordTextDigest}).
585
+ * With `sign`, the verdict carries a seal: an ssh signature over the record
586
+ * id, the digest, the verdict, the reviewer and the date (`trust/seal.ts`).
579
587
  */
580
588
  export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDocument> {
581
589
  try {
@@ -609,6 +617,7 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
609
617
  digest: recordTextDigest(current, field),
610
618
  ...(opts.session !== undefined ? { session: opts.session } : {}),
611
619
  };
620
+ if (opts.sign !== undefined) review.seal = await seal(opts.sign, opts.cwd, { record: opts.id, digest: review.digest as string, verdict: opts.verdict, reviewer: opts.by, on: review.on as string });
612
621
  // Only the reviews block changes, so the digest the verdict names stays the record's digest (#2672).
613
622
  const list = [...reviews, review];
614
623
  const text = replaceFields(current, { [field]: list }, { ...target.data, [field]: list });
@@ -631,12 +640,28 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
631
640
  }
632
641
  }
633
642
 
643
+ /** A seal over one verdict, or a refusal with review-sign-failed. Loaded only when --sign is given. */
644
+ async function seal(sign: string | true, cwd: string, v: { record: string; digest: string; verdict: string; reviewer: string; on: string }): Promise<Record<string, unknown>> {
645
+ const { resolveSigningKey, sealVerdict, SealError } = await import("./trust/seal");
646
+ try {
647
+ const key = resolveSigningKey(sign, cwd);
648
+ try {
649
+ return { ...sealVerdict(key.file, v) };
650
+ } finally {
651
+ key.cleanup();
652
+ }
653
+ } catch (err) {
654
+ if (err instanceof SealError) throw new RecordWriteError("review-sign-failed", err.message);
655
+ throw err;
656
+ }
657
+ }
658
+
634
659
  // ── The command ──────────────────────────────────────────────────────────────
635
660
 
636
661
  export const WRITE_USAGE = [
637
662
  "chant workspace records new [<kind file or declared kind>] --from <file|-> [--prefix <prefix>] [--dry-run]",
638
663
  "chant workspace records amend <id> [--kind <kind file>] --set <file|-> [--dry-run]",
639
- "chant workspace records review <id> [--kind <kind file>] --verdict agree|dissent|abstain --by <principal> [--note <text>] [--session <id>] [--dry-run]",
664
+ "chant workspace records review <id> [--kind <kind file>] --verdict agree|dissent|abstain --by <principal> [--note <text>] [--session <id>] [--sign [<key file>]] [--dry-run]",
640
665
  ].join("\n");
641
666
 
642
667
  function usage(schema: string, message: string): WriteFailure<"write-usage-invalid"> {
@@ -710,6 +735,10 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
710
735
  console.log(JSON.stringify(doc, null, 2));
711
736
  return "error" in doc ? 1 : 0;
712
737
  };
738
+ if (args.sign !== undefined && verb !== "review") {
739
+ const schema = verb === "new" ? RECORDS_NEW_SCHEMA_ID : RECORDS_AMEND_SCHEMA_ID;
740
+ return print(usage(schema, `--sign seals a review verdict; sealing a record's author on ${verb} is not supported yet (#2688)`));
741
+ }
713
742
  if (verb === "new") {
714
743
  const named = args.extraPositional2 ?? args.kind;
715
744
  const kind = named !== undefined ? resolveWriteKind(named, cwd) : declaredWriteKind(RECORDS_NEW_SCHEMA_ID, cwd, "new needs the kind file");
@@ -731,6 +760,6 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
731
760
  if (args.verdict === undefined) return print(usage(schema, "--verdict agree|dissent|abstain is required"));
732
761
  if (args.by === undefined) return print(usage(schema, "--by <principal> is required"));
733
762
  return print(
734
- await reviewRecord({ kind, id, verdict: args.verdict, by: args.by, note: args.note, session: args.session, dryRun: args.dryRun, cwd }),
763
+ await reviewRecord({ kind, id, verdict: args.verdict, by: args.by, note: args.note, session: args.session, sign: args.sign, dryRun: args.dryRun, cwd }),
735
764
  );
736
765
  }
@@ -206,7 +206,8 @@
206
206
  "work-needs-cycle",
207
207
  "work-implements-undecided",
208
208
  "work-done-unpinned",
209
- "work-closed-without-date"
209
+ "work-closed-without-date",
210
+ "work-done-gap-open"
210
211
  ]
211
212
  },
212
213
  "message": { "type": "string" }
@@ -248,6 +249,20 @@
248
249
  "reviewer": { "type": "string", "description": "The reviewer as the record writes it." },
249
250
  "verdict": { "enum": ["agree", "dissent", "abstain"] },
250
251
  "digest": { "type": ["string", "null"], "description": "The digest the verdict names, or null when it names none (a warning review-undigested on the record)." },
252
+ "attested": {
253
+ "type": ["boolean", "null"],
254
+ "description": "Added in contract 1 by #2687. true when the verdict's seal verifies for its reviewer against the signers file at base. false when a signers file is active at base and the verdict has no seal, or when a seal fails, with or without one. null when nothing here can say: no seal and no signers file, a seal checked with no signers file (its signature is intact, and nothing says whose key it is), or no ssh-keygen. Under an active signers file only true counts."
255
+ },
256
+ "attestation": {
257
+ "description": "Added in contract 1 by #2687. What the verdict's seal establishes, and why when it is not attested.",
258
+ "type": "object",
259
+ "required": ["message"],
260
+ "properties": {
261
+ "code": { "enum": ["seal-missing", "seal-signer-unlisted", "seal-signature-invalid", "seal-unverifiable"], "description": "Absent exactly when attested is true." },
262
+ "message": { "type": "string" },
263
+ "key": { "type": "string", "description": "The fingerprint of the key that made the signature, when the signature was checked." }
264
+ }
265
+ },
251
266
  "reason": {
252
267
  "description": "Why the verdict does not count. Present exactly on the entries in notCounted.",
253
268
  "type": "object",
@@ -100,11 +100,41 @@ export const REVIEW_REASON_CODES = [
100
100
  "review-duplicate",
101
101
  /** The verdict's digest is not the digest of the record's text now: the record changed after the verdict (#2672). */
102
102
  "review-older-digest",
103
- /** An attestation policy is active at base, and the verdict carries no seal. */
103
+ /**
104
+ * An attestation policy is active at base, and the verdict's seal does not
105
+ * verify for its reviewer: it has none, the reviewer has no key in the
106
+ * signers file, or the signature fails (#2687).
107
+ */
104
108
  "review-unattested",
105
109
  ] as const satisfies readonly ReasonCode[];
106
110
  export type ReviewReasonCode = (typeof REVIEW_REASON_CODES)[number];
107
111
 
112
+ /**
113
+ * Why a verdict's seal is not attested (#2687). Closed, like the reason
114
+ * codes. Every verdict in the quorum carries one of these in its
115
+ * `attestation`, unless its seal verified.
116
+ */
117
+ export const SEAL_REASON_CODES = [
118
+ /** The verdict carries no seal. */
119
+ "seal-missing",
120
+ /** The reviewer has no key in the signers file at base. */
121
+ "seal-signer-unlisted",
122
+ /** The seal is malformed, names another signer, or its signature does not verify over the verdict. */
123
+ "seal-signature-invalid",
124
+ /** Nothing here can say whose seal it is: there is no signers file at base, or ssh-keygen is not installed. */
125
+ "seal-unverifiable",
126
+ ] as const satisfies readonly ReasonCode[];
127
+ export type SealCode = (typeof SEAL_REASON_CODES)[number];
128
+
129
+ /** What a verdict's seal establishes (#2687). See `trust/seal.ts`. */
130
+ export interface VerdictAttestation {
131
+ /** Why the verdict is not attested. Absent when its seal verified. */
132
+ code?: SealCode;
133
+ message: string;
134
+ /** The fingerprint of the key that made the signature, when it was checked. */
135
+ key?: string;
136
+ }
137
+
108
138
  export interface RecordWarning {
109
139
  /** A work kind's records also carry the codes of `WORK_WARNING_CODES` (#2683). */
110
140
  code: RecordWarningCode | WorkWarningCode;
@@ -664,6 +694,13 @@ export interface QuorumVerdict {
664
694
  verdict: "agree" | "dissent" | "abstain";
665
695
  /** The digest the verdict names, or null when it names none. */
666
696
  digest: string | null;
697
+ /**
698
+ * true when its seal verifies for the reviewer against the signers at base;
699
+ * false when a seal is missing under an active policy, or fails; null when
700
+ * nothing here can say (#2687).
701
+ */
702
+ attested: boolean | null;
703
+ attestation: VerdictAttestation;
667
704
  /** Why it does not count. Absent on a counted verdict. */
668
705
  reason?: { code: ReviewReasonCode; message: string };
669
706
  }
@@ -703,6 +740,30 @@ export interface QuorumOptions {
703
740
  agents: ReadonlySet<string>;
704
741
  /** Whether an attestation policy is active, so a verdict needs a seal to count. */
705
742
  attestation: boolean;
743
+ /**
744
+ * Checks one verdict's seal (#2687), as `trust/seal.ts` does against the
745
+ * policy at base. Without it no seal is checked: a sealed verdict is
746
+ * `seal-unverifiable`, so under an active policy none counts.
747
+ */
748
+ verifySeal?: (v: SealInput) => { attested: boolean | null } & VerdictAttestation;
749
+ }
750
+
751
+ /** What a seal check reads of one verdict. */
752
+ export interface SealInput {
753
+ record: string | null;
754
+ reviewer: string;
755
+ verdict: string;
756
+ on: unknown;
757
+ digest: string | null;
758
+ seal: unknown;
759
+ }
760
+
761
+ /** The check used when {@link QuorumOptions.verifySeal} is not given. */
762
+ function uncheckedSeal(attestation: boolean, v: SealInput): { attested: boolean | null } & VerdictAttestation {
763
+ if (v.seal === undefined || v.seal === null) {
764
+ return { attested: attestation ? false : null, code: "seal-missing", message: `the verdict by ${v.reviewer} carries no seal` };
765
+ }
766
+ return { attested: null, code: "seal-unverifiable", message: `the seal by ${v.reviewer} was not checked` };
706
767
  }
707
768
 
708
769
  const VERDICTS = new Set(["agree", "dissent", "abstain"]);
@@ -713,28 +774,36 @@ const VERDICTS = new Set(["agree", "dissent", "abstain"]);
713
774
  * Malformed entries are skipped; the schema reports them.
714
775
  *
715
776
  * A verdict is not counted when its reviewer is the decider, holds the agent
716
- * role, names a digest other than the record's own now, or carries no seal
717
- * under an active attestation policy, in that order. Of the rest, the latest
718
- * verdict per principal counts and each earlier one is a duplicate. No
719
- * verdict carries a seal yet (#2546), so under an active policy none counts.
777
+ * role, names a digest other than the record's own now, or, under an active
778
+ * attestation policy, carries no seal that verifies for its reviewer (#2687),
779
+ * in that order. Of the rest, the latest verdict per principal counts and
780
+ * each earlier one is a duplicate. Every verdict reports its seal's check in
781
+ * `attested` and `attestation`, whether it counts or not.
720
782
  */
721
- export function computeQuorum(kind: RecordKind, entry: Pick<RecordEntry, "data" | "digest">, options: QuorumOptions): Quorum | null {
783
+ export function computeQuorum(kind: RecordKind, entry: Pick<RecordEntry, "data" | "digest"> & { id?: string | null }, options: QuorumOptions): Quorum | null {
722
784
  if (!kind.reviews || entry.data === null) return null;
723
785
  const list = entry.data[kind.reviews.field];
724
786
  const decidedBy = entry.data[kind.reviews.decider];
725
787
  const decider = typeof decidedBy === "string" ? normalisePrincipal(decidedBy) : null;
788
+ const idValue = entry.id !== undefined ? entry.id : kind.idField !== undefined ? entry.data[kind.idField] : null;
789
+ const record = typeof idValue === "string" ? idValue : null;
726
790
  const verdicts: QuorumVerdict[] = [];
727
791
  const openConcerns: OpenConcern[] = [];
728
792
  (Array.isArray(list) ? list : []).forEach((raw, index) => {
729
793
  if (raw === null || typeof raw !== "object") return;
730
794
  const r = raw as Record<string, unknown>;
731
795
  if (typeof r.reviewer !== "string" || typeof r.verdict !== "string" || !VERDICTS.has(r.verdict)) return;
796
+ const digest = typeof r.digest === "string" ? r.digest : null;
797
+ const input: SealInput = { record, reviewer: r.reviewer, verdict: r.verdict, on: r.on, digest, seal: r.seal };
798
+ const { attested, ...attestation } = options.verifySeal ? options.verifySeal(input) : uncheckedSeal(options.attestation, input);
732
799
  const v: QuorumVerdict = {
733
800
  index,
734
801
  principal: normalisePrincipal(r.reviewer),
735
802
  reviewer: r.reviewer,
736
803
  verdict: r.verdict as QuorumVerdict["verdict"],
737
- digest: typeof r.digest === "string" ? r.digest : null,
804
+ digest,
805
+ attested,
806
+ attestation,
738
807
  };
739
808
  if (v.principal === decider) {
740
809
  v.reason = { code: "review-decider", message: `${v.reviewer} decided this record, and the quorum counts verdicts besides the decider's` };
@@ -742,8 +811,8 @@ export function computeQuorum(kind: RecordKind, entry: Pick<RecordEntry, "data"
742
811
  v.reason = { code: "review-agent", message: `${v.reviewer} holds the agent role in the trust policy at base, and an agent's verdict does not count` };
743
812
  } else if (v.digest !== null && v.digest !== entry.digest) {
744
813
  v.reason = { code: "review-older-digest", message: `${v.reviewer} judged the text at digest ${v.digest.slice(0, 12)}, and the record's text is now at ${entry.digest.slice(0, 12)}` };
745
- } else if (options.attestation) {
746
- v.reason = { code: "review-unattested", message: `an attestation policy is active at base, and the verdict by ${v.reviewer} carries no seal` };
814
+ } else if (options.attestation && attested !== true) {
815
+ v.reason = { code: "review-unattested", message: `an attestation policy is active at base, and ${attestation.message}` };
747
816
  }
748
817
  verdicts.push(v);
749
818
  if (v.verdict === "dissent" && r.addressed_by == null && r.withdrawn_on == null) {
@@ -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
+ });