@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.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +6 -1
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +9 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +6 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +9 -2
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +47 -5
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +85 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +7 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/work.d.ts +3 -3
- package/package.json +1 -1
- package/src/cli/main.ts +10 -3
- package/src/cli/registry.ts +2 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +12 -0
- package/src/workspace/intent-gaps.test.ts +217 -0
- package/src/workspace/intent.schema.json +23 -1
- package/src/workspace/intent.test.ts +2 -0
- package/src/workspace/intent.ts +35 -3
- package/src/workspace/reason-codes.test.ts +2 -1
- package/src/workspace/reason-codes.ts +10 -2
- package/src/workspace/record-assets.test.ts +2 -2
- package/src/workspace/records-cli.ts +70 -8
- package/src/workspace/records-contract.test.ts +2 -2
- package/src/workspace/records-review.schema.json +26 -1
- package/src/workspace/records-write.ts +32 -3
- package/src/workspace/records.schema.json +16 -1
- package/src/workspace/records.ts +78 -9
- package/src/workspace/trust/seal.test.ts +232 -0
- package/src/workspace/trust/seal.ts +195 -0
- package/src/workspace/trust/ssh-commit.ts +2 -2
- package/src/workspace/work.test.ts +3 -1
- 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;
|
|
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",
|
package/src/workspace/records.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
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"
|
|
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
|
|
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
|
|
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
|
+
});
|