@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
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The two gaps a work item could not name before #2686, a stale pin and an
3
+ * open dissent, and `work-done-gap-open` on a records read, on a workspace
4
+ * built in a throwaway git repository:
5
+ *
6
+ * - c1 adds the app, the screen spec `design/screens/home.json`, and dec-001,
7
+ * which constrains the spec's path and pins it by hash.
8
+ * - c2 adds dec-002, which supersedes dec-001, constrains the same path and
9
+ * pins the spec at the same hash, so its pin is stale; dec-003, which
10
+ * constrains `app/server.mjs` and has one open dissent (bob's), one
11
+ * withdrawn and one addressed; and the work items:
12
+ * W-001 (open, from intent-pin-stale on the spec), W-002 (open, from
13
+ * intent-decision-contested on the server), W-003 (done, from
14
+ * intent-decision-contested on the server, which still fires), W-004
15
+ * (done, from intent-pin-drifted on the spec, which does not fire) and
16
+ * W-005 (open, implements dec-002 and dec-003, no gap source).
17
+ */
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
22
+ import { sha256Hex } from "../content-digest";
23
+ import { cleanScratch, contract, git, REPO, repo, writeFiles } from "./__fixtures__/contract-repo";
24
+ import { intentGraph, type FindingNode, type IntentDocument, type WorkNode } from "./intent";
25
+ import intentSchema from "./intent.schema.json";
26
+ import { parseFrontMatter } from "./records";
27
+ import { queryRecords } from "./records-cli";
28
+ import recordsSchema from "./records.schema.json";
29
+
30
+ const REF = join(REPO, "reference-workspace");
31
+ const BASE = (() => {
32
+ const fm = parseFrontMatter(readFileSync(join(REF, "decisions", "ref-001-how-the-app-is-deployed.md"), "utf-8"));
33
+ if (!fm.ok) throw new Error(fm.message);
34
+ return fm.value;
35
+ })();
36
+
37
+ const HOME = `${JSON.stringify({ route: "/", regions: ["header"] }, null, 2)}\n`;
38
+ const pin = { title: "The home screen spec", path: "design/screens/home.json", sha256: sha256Hex(Buffer.from(HOME)), as_of: "2026-09-24T12:00:00Z" };
39
+
40
+ function decision(id: string, fields: Record<string, unknown>): string {
41
+ const data = { ...BASE, id, title: `Decision ${id}`, state: "decided", supersedes: [], evidence: [], reviews: [], ...fields };
42
+ return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n`;
43
+ }
44
+
45
+ function work(id: string, fields: Record<string, unknown>): string {
46
+ const data = { schema: 1, id, title: `Work ${id}`, state: "open", implements: [], needs: [], constrains: ["path:app/server.mjs"], evidence: [], opened_on: "2026-09-24", supersedes: [], ...fields };
47
+ return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n\nWhat the work is.\n`;
48
+ }
49
+
50
+ const done = { state: "done", closed_on: "2026-09-25", evidence: [{ title: "The review", url: "https://example.com/review" }] };
51
+ const KIND_FILES = {
52
+ "decisions/decision.kind.mjs": readFileSync(join(REF, "decisions", "decision.kind.mjs"), "utf-8"),
53
+ "decisions/decision.schema.json": readFileSync(join(REF, "decisions", "decision.schema.json"), "utf-8"),
54
+ "work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
55
+ "work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
56
+ };
57
+ const DECLARATION = JSON.stringify({
58
+ name: "studio",
59
+ schema: 1,
60
+ members: [
61
+ { name: "app", dir: "app", kind: "other", because: "a plain Node server" },
62
+ { name: "design", dir: "design", kind: "other", because: "the screen specs" },
63
+ ],
64
+ });
65
+
66
+ const DEC_003 = decision("dec-003", {
67
+ constrains: ["path:app/server.mjs"],
68
+ reviews: [
69
+ { reviewer: "Bob", verdict: "dissent", note: "The port is wrong.", on: "2026-09-24" },
70
+ { reviewer: "carol", verdict: "dissent", note: "Too early.", on: "2026-09-24", withdrawn_on: "2026-09-25" },
71
+ { reviewer: "dave", verdict: "dissent", note: "Needs a test.", on: "2026-09-24", addressed_by: "acme/studio#7" },
72
+ { reviewer: "erin", verdict: "agree", on: "2026-09-24" },
73
+ ],
74
+ });
75
+
76
+ let root: string;
77
+
78
+ function commit(message: string): void {
79
+ git(root, "add", "-A");
80
+ git(root, "commit", "-q", "-m", message);
81
+ }
82
+
83
+ beforeAll(() => {
84
+ root = repo({
85
+ "chant.workspace.json": DECLARATION,
86
+ "app/server.mjs": "export const port = 8080;\n",
87
+ "design/screens/home.json": HOME,
88
+ ...KIND_FILES,
89
+ "decisions/dec-001-spec.md": decision("dec-001", { constrains: ["path:design/screens/home.json"], evidence: [pin] }),
90
+ });
91
+ commit("the app, the spec and dec-001");
92
+ writeFiles(root, {
93
+ "decisions/dec-002-spec-again.md": decision("dec-002", { constrains: ["path:design/screens/home.json"], evidence: [pin], supersedes: [{ decision: "dec-001" }] }),
94
+ "decisions/dec-003-server.md": DEC_003,
95
+ "work/W-001-stale.md": work("W-001", { constrains: ["path:design/screens/home.json"], source: { finding: "intent-pin-stale", region: "design/screens/home.json", decision: "dec-002" } }),
96
+ "work/W-002-contested.md": work("W-002", { source: { finding: "intent-decision-contested", region: "app/server.mjs", decision: "dec-003" } }),
97
+ "work/W-003-contested-done.md": work("W-003", { ...done, source: { finding: "intent-decision-contested", region: "app/server.mjs", decision: "dec-003" } }),
98
+ "work/W-004-drift-done.md": work("W-004", { ...done, constrains: ["path:design/screens/home.json"], source: { finding: "intent-pin-drifted", region: "design/screens/home.json" } }),
99
+ "work/W-005-carry-out.md": work("W-005", { implements: ["dec-002", "dec-003"] }),
100
+ });
101
+ commit("dec-002 supersedes dec-001, dec-003 is contested, and the work");
102
+ });
103
+ afterAll(cleanScratch);
104
+
105
+ const intent = contract(intentSchema);
106
+ const records = contract(recordsSchema);
107
+ type Result = Exclude<IntentDocument, { error: unknown }>;
108
+
109
+ async function walk(region: string): Promise<Result> {
110
+ const { doc } = await intentGraph({ cwd: root, region, kinds: ["decisions/decision.kind.mjs", "work/work.kind.mjs"].map((k) => join(root, k)) });
111
+ intent.expectValid(doc);
112
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
113
+ return doc;
114
+ }
115
+
116
+ const findingsOf = (doc: Result, code: string) => doc.nodes.filter((n): n is FindingNode => n.kind === "finding" && n.code === code);
117
+
118
+ describe("intent-pin-stale (#2686)", () => {
119
+ test("a current decision pinning the artifact at the hash the record it supersedes pinned is stale, beside drifted and missing", async () => {
120
+ const doc = await walk("design/screens/home.json");
121
+ expect(doc.nodes.find((n) => n.id === "artifact:design/screens/home.json")).toMatchObject({ pinState: "stale" });
122
+ const stale = findingsOf(doc, "intent-pin-stale");
123
+ expect(stale).toHaveLength(1);
124
+ expect(stale[0].concerns).toEqual(["record:decision/dec-002", "artifact:design/screens/home.json", "record:decision/dec-001"]);
125
+ expect(stale[0].message).toContain("dec-001");
126
+ expect(findingsOf(doc, "intent-pin-drifted")).toEqual([]);
127
+ expect(findingsOf(doc, "intent-pin-missing")).toEqual([]);
128
+ });
129
+
130
+ test("it is addressed by the item that came from it and by the item implementing the decision", async () => {
131
+ const doc = await walk("design/screens/home.json");
132
+ const [stale] = findingsOf(doc, "intent-pin-stale");
133
+ expect(stale).toMatchObject({ addressed: true, addressedBy: [{ id: "W-001", state: "open" }, { id: "W-005", state: "open" }] });
134
+ expect(doc.edges).toContainEqual({ kind: "addressed-by", from: stale.id, to: "record:work/W-001" });
135
+ expect(doc.edges).toContainEqual({ kind: "addressed-by", from: stale.id, to: "record:work/W-005" });
136
+ });
137
+
138
+ test("a stale pin on a decision in the graph only through supersession is not raised", async () => {
139
+ // app/server.mjs brings in no decision pinning the spec.
140
+ const doc = await walk("app/server.mjs");
141
+ expect(findingsOf(doc, "intent-pin-stale")).toEqual([]);
142
+ });
143
+ });
144
+
145
+ describe("intent-decision-contested (#2686)", () => {
146
+ test("a current decision constraining the region with a dissent neither addressed nor withdrawn is contested, with the count and principals", async () => {
147
+ const doc = await walk("app/server.mjs");
148
+ const contested = findingsOf(doc, "intent-decision-contested");
149
+ expect(contested).toHaveLength(1);
150
+ expect(contested[0]).toMatchObject({ concerns: ["record:decision/dec-003", "region:app/server.mjs"], openConcerns: { count: 1, principals: ["bob"] } });
151
+ expect(contested[0].message).toContain("1 open concern, from Bob");
152
+ });
153
+
154
+ test("it is addressed by the items that came from it, and not through implements", async () => {
155
+ const doc = await walk("app/server.mjs");
156
+ const [contested] = findingsOf(doc, "intent-decision-contested");
157
+ expect(contested.addressedBy).toEqual([
158
+ { id: "W-002", state: "open" },
159
+ { id: "W-003", state: "done" },
160
+ ]);
161
+ expect(doc.edges).toContainEqual({ kind: "addressed-by", from: contested.id, to: "record:work/W-002" });
162
+ const w003 = doc.nodes.find((n): n is WorkNode => n.kind === "work" && n.record === "W-003");
163
+ expect(w003!.warnings.map((w) => w.code)).toEqual(["work-done-gap-open"]);
164
+ });
165
+
166
+ test("a decision whose dissents are all addressed or withdrawn is not contested", async () => {
167
+ writeFiles(root, { "decisions/dec-003-server.md": DEC_003.replace(`"note": "The port is wrong.",`, `"note": "The port is wrong.", "addressed_by": "acme/studio#8",`) });
168
+ try {
169
+ const doc = await walk("app/server.mjs");
170
+ expect(findingsOf(doc, "intent-decision-contested")).toEqual([]);
171
+ } finally {
172
+ writeFiles(root, { "decisions/dec-003-server.md": DEC_003 });
173
+ }
174
+ });
175
+ });
176
+
177
+ describe("work-done-gap-open in records (#2686)", () => {
178
+ test("records --json warns on a done item whose gap still fires, and not on one whose gap closed", async () => {
179
+ const doc = await queryRecords({ kind: "work/work.kind.mjs", cwd: root });
180
+ records.expectValid(doc);
181
+ if ("error" in doc) throw new Error(doc.error.message);
182
+ const warned = Object.fromEntries(doc.records.map((r) => [r.id, r.warnings.map((w) => w.code)]));
183
+ expect(warned).toEqual({ "W-001": [], "W-002": [], "W-003": ["work-done-gap-open"], "W-004": [], "W-005": [] });
184
+ expect(doc.records.find((r) => r.id === "W-003")!.warnings[0].message).toContain("intent-decision-contested");
185
+ });
186
+
187
+ test("the warning follows the revision read: under --at c1 there is no work, and under --at HEAD W-003 is warned", async () => {
188
+ const c1 = git(root, "rev-parse", "HEAD~1");
189
+ const doc = await queryRecords({ kind: "work/work.kind.mjs", cwd: root, at: c1 });
190
+ records.expectValid(doc);
191
+ if ("error" in doc) throw new Error(doc.error.message);
192
+ expect(doc.records).toEqual([]);
193
+ const head = await queryRecords({ kind: "work/work.kind.mjs", cwd: root, at: "HEAD" });
194
+ if ("error" in head) throw new Error(head.error.message);
195
+ expect(head.records.find((r) => r.id === "W-003")!.warnings.map((w) => w.code)).toEqual(["work-done-gap-open"]);
196
+ });
197
+
198
+ test("without a workspace declaration the walk can't be made, so the record is left as it was", async () => {
199
+ const bare = repo({
200
+ "app/server.mjs": "export const port = 8080;\n",
201
+ ...KIND_FILES,
202
+ "decisions/dec-003-server.md": DEC_003,
203
+ "work/W-003-contested-done.md": work("W-003", { ...done, source: { finding: "intent-decision-contested", region: "app/server.mjs" } }),
204
+ });
205
+ git(bare, "add", "-A");
206
+ git(bare, "commit", "-q", "-m", "one");
207
+ const doc = await queryRecords({ kind: "work/work.kind.mjs", cwd: bare });
208
+ if ("error" in doc) throw new Error(doc.error.message);
209
+ expect(doc.records[0].warnings).toEqual([]);
210
+ });
211
+
212
+ test("the intent graph reads its kinds without asking records for the warning, so it is raised once", async () => {
213
+ const doc = await walk("app/server.mjs");
214
+ const w003 = doc.nodes.find((n): n is WorkNode => n.kind === "work" && n.record === "W-003");
215
+ expect(w003!.warnings.filter((w) => w.code === "work-done-gap-open")).toHaveLength(1);
216
+ });
217
+ });
@@ -924,7 +924,7 @@
924
924
  },
925
925
  "warnings": {
926
926
  "type": "array",
927
- "description": "The item's work warnings as records reports them, and work-done-gap-open, which only this walk raises: the item is done and the finding in its source still fires on its region.",
927
+ "description": "The item's work warnings as records reports them, and work-done-gap-open: the item is done and the finding in its source still fires on its region. records raises it too, by walking the source's region with this code (#2686).",
928
928
  "items": {
929
929
  "type": "object",
930
930
  "required": [
@@ -1088,9 +1088,11 @@
1088
1088
  "intent-commit-bare",
1089
1089
  "intent-pin-drifted",
1090
1090
  "intent-pin-missing",
1091
+ "intent-pin-stale",
1091
1092
  "intent-artifact-unpinned",
1092
1093
  "intent-decision-superseded-live",
1093
1094
  "intent-decision-provisional",
1095
+ "intent-decision-contested",
1094
1096
  "intent-constraint-coarse",
1095
1097
  "intent-constraint-lost",
1096
1098
  "intent-evidence-unpinned",
@@ -1129,6 +1131,26 @@
1129
1131
  },
1130
1132
  "description": "For a plugin's finding: the refs as the plugin gave them. Those that name a node in the graph are resolved to its id in concerns, after the commit the finding was returned for."
1131
1133
  },
1134
+ "openConcerns": {
1135
+ "type": "object",
1136
+ "description": "Added by #2686, on intent-decision-contested only: how many open concerns the decision's quorum reports, dissents neither addressed nor withdrawn, and the principals who raised them, normalised as the quorum compares them.",
1137
+ "required": [
1138
+ "count",
1139
+ "principals"
1140
+ ],
1141
+ "properties": {
1142
+ "count": {
1143
+ "type": "integer",
1144
+ "minimum": 1
1145
+ },
1146
+ "principals": {
1147
+ "type": "array",
1148
+ "items": {
1149
+ "type": "string"
1150
+ }
1151
+ }
1152
+ }
1153
+ },
1132
1154
  "addressed": {
1133
1155
  "type": "boolean",
1134
1156
  "description": "Added by #2683, present when a work kind was read: whether a work item addresses the finding, because the item's source names this finding code on a region that contains or is contained by this one, or because the item implements a decision the finding concerns. A finding with addressed false is work nobody has taken yet."
@@ -443,6 +443,8 @@ describe("findings the fixture does not raise (#2651)", () => {
443
443
  expect(findings(doc).map(([c]) => c)).toEqual([
444
444
  "intent-commit-undecided",
445
445
  "intent-commit-bare",
446
+ // dec-002 supersedes dec-001 and pins the spec at the hash dec-001 pinned, unchanged since (#2686).
447
+ "intent-pin-stale",
446
448
  "intent-decision-provisional",
447
449
  "intent-constraint-coarse",
448
450
  "intent-constraint-lost",
@@ -71,12 +71,16 @@ export const INTENT_FINDING_CODES = [
71
71
  "intent-pin-drifted",
72
72
  /** A pinned artifact does not exist in the tree read. */
73
73
  "intent-pin-missing",
74
+ /** A current decision's pin is unchanged at the hash a record it supersedes pinned: the decision moved on and the artifact did not (#2686). */
75
+ "intent-pin-stale",
74
76
  /** An artifact that decisions in the graph pinned, and that no current decision pins. */
75
77
  "intent-artifact-unpinned",
76
78
  /** Every decision constraining the region is superseded. */
77
79
  "intent-decision-superseded-live",
78
80
  /** The current decisions constraining the region are all in states their kind does not close, such as decided and not ratified. */
79
81
  "intent-decision-provisional",
82
+ /** A current decision constraining the region has an open concern: a dissent neither addressed nor withdrawn (#2686). */
83
+ "intent-decision-contested",
80
84
  /** The region is constrained only at member granularity. */
81
85
  "intent-constraint-coarse",
82
86
  /** A decision's path constraint names a path that does not exist. */
@@ -269,6 +273,8 @@ export interface FindingNode {
269
273
  plugin?: string;
270
274
  /** For a plugin's finding: the refs as the plugin gave them. The ones that name a node in the graph are in concerns. */
271
275
  refs?: string[];
276
+ /** For intent-decision-contested (#2686): how many open concerns the decision has, and whose, as its quorum reports them. */
277
+ openConcerns?: { count: number; principals: string[] };
272
278
  /** With a work kind read (#2683): whether a work item addresses the finding. */
273
279
  addressed?: boolean;
274
280
  /** With a work kind read: the work items addressing the finding, each with its state. */
@@ -618,7 +624,7 @@ async function loadKinds(query: IntentQuery, top: string): Promise<LoadedKind[]>
618
624
  if (typeof joins === "string") throw new IntentError("kind-invalid", `kind file ${k} has a commitJoins export that can't be read: ${joins}`);
619
625
  const kind: LoadedKind = { file, display, name: joins?.name ?? basename(file).replace(/(?:\.kind)?\.[cm]?[jt]s$/, ""), ...(joins ? { joins } : {}) };
620
626
  if (mod.recordKind !== undefined) {
621
- const doc = await queryRecords({ kind: file, at: query.at, cwd: query.cwd });
627
+ const doc = await queryRecords({ kind: file, at: query.at, cwd: query.cwd, workGaps: false });
622
628
  if ("error" in doc) throw new IntentError(doc.error.code, doc.error.message);
623
629
  try {
624
630
  kind.records = { loaded: await loadRecordKind(file), views: doc.records, workspaceRoot: doc.workspaceRoot };
@@ -951,6 +957,18 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
951
957
  edges.push({ kind: "pins", from: p.decision.id, to: aid, pinnedSha256: p.sha256, pinState: p.state });
952
958
  if (p.state === "drifted") find("intent-pin-drifted", `${path} changed after ${p.decision.record} pinned it: sha256 ${p.sha256?.slice(0, 12)} is pinned, the file hashes to ${p.actual?.slice(0, 12)}`, [p.decision.id, aid]);
953
959
  if (p.state === "missing") find("intent-pin-missing", `${p.decision.record} pins ${path}, which does not exist${located.tree.label}`, [p.decision.id, aid]);
960
+ // Stale against a current decision constraining the region (#2686): the
961
+ // record it supersedes pinned the same hash, and the file has not
962
+ // changed since. A decision in the graph only through supersession
963
+ // does not govern the region, so its stale pin is not raised here.
964
+ if (p.state === "stale" && p.decision.supersededBy === null && p.decision.constrains.length > 0) {
965
+ const old = pins.find((o) => o.decision.supersededBy === p.decision.record && o.decision.recordKind === p.decision.recordKind && o.sha256 === p.sha256);
966
+ find(
967
+ "intent-pin-stale",
968
+ `${path} is pinned by ${p.decision.record} at sha256 ${p.sha256?.slice(0, 12)}, the hash ${old ? old.decision.record : "the record it supersedes"} pinned, and it has not changed since: the decision moved on and the artifact did not`,
969
+ [p.decision.id, aid, ...(old ? [old.decision.id] : [])],
970
+ );
971
+ }
954
972
  }
955
973
  if (!worst && pins.some((p) => p.state !== "unpinned")) {
956
974
  find("intent-artifact-unpinned", `${path} was pinned by ${[...new Set(pins.map((p) => p.decision.record))].join(", ")}, and no current decision pins it`, [aid, ...new Set(pins.map((p) => p.decision.id))]);
@@ -1143,6 +1161,20 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
1143
1161
  } else if (current.every((c) => !c.node.closed)) {
1144
1162
  find("intent-decision-provisional", `the decisions constraining ${region.path} are ${[...new Set(current.map((c) => c.node.state ?? "stateless"))].join(" or ")}, and none is in a closed state`, [rid, ...current.map((c) => c.node.id)]);
1145
1163
  }
1164
+ // A current decision with a dissent nobody addressed or withdrew (#2686), from its quorum.
1165
+ for (const c of current) {
1166
+ const open = c.view.quorum?.openConcerns ?? [];
1167
+ if (open.length === 0) continue;
1168
+ const principals = [...new Set(open.map((o) => o.principal))];
1169
+ findings.push({
1170
+ id: `finding:intent-decision-contested:${findings.filter((f) => f.code === "intent-decision-contested").length + 1}`,
1171
+ kind: "finding",
1172
+ code: "intent-decision-contested",
1173
+ message: `${c.node.record} constrains ${region.path} and has ${open.length} open ${open.length === 1 ? "concern" : "concerns"}, from ${open.map((o) => o.reviewer).join(", ")}: a dissent neither addressed nor withdrawn`,
1174
+ concerns: [c.node.id, rid],
1175
+ openConcerns: { count: open.length, principals },
1176
+ });
1177
+ }
1146
1178
  const granularities = new Set(covering.flatMap((c) => c.node.constrains.map((x) => x.granularity)));
1147
1179
  if (covering.length > 0 && !granularities.has("path") && granularities.has("member")) {
1148
1180
  find("intent-constraint-coarse", `${region.path} is constrained only through its member, ${member}`, [rid, ...covering.map((c) => c.node.id)]);
@@ -1222,7 +1254,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
1222
1254
  findings.push({ id: `finding:${code}:${findings.filter((f) => f.code === code).length + 1}`, kind: "finding", code, message: finding.message, concerns, plugin, refs });
1223
1255
  }
1224
1256
  // A finding a work item addresses (#2683): the item came from that gap on
1225
- // this region, or it implements the decision a drifted or missing pin, or a
1257
+ // this region, or it implements the decision a drifted, missing or stale pin, or a
1226
1258
  // plugin's finding, is about. A done item whose gap still fires here gets
1227
1259
  // work-done-gap-open.
1228
1260
  if (readsWork) {
@@ -1230,7 +1262,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
1230
1262
  // artifacts, not gaps in the decision record itself (its review, its
1231
1263
  // granularity, its evidence), so only these findings, and a plugin's,
1232
1264
  // count as addressed through implements.
1233
- const CLOSED_BY_IMPLEMENTING = new Set<string>(["intent-pin-drifted", "intent-pin-missing"]);
1265
+ const CLOSED_BY_IMPLEMENTING = new Set<string>(["intent-pin-drifted", "intent-pin-missing", "intent-pin-stale"]);
1234
1266
  const fromGap = (h: WorkHit, code: string): boolean => {
1235
1267
  const src = gapSource(h.view.data);
1236
1268
  if (src === null || src.finding !== code) return false;
@@ -19,7 +19,7 @@ import { GROUP_REASON_CODES, MEMBER_REASON_CODES } from "./ls";
19
19
  import intentSchema from "./intent.schema.json";
20
20
  import { isPluginCode, isReasonCode, REASON_CODES, REASONS } from "./reason-codes";
21
21
  import { contract } from "./__fixtures__/contract-repo";
22
- import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, REVIEW_REASON_CODES } from "./records";
22
+ import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, REVIEW_REASON_CODES, SEAL_REASON_CODES } from "./records";
23
23
  import { AMEND_ERROR_CODES, NEW_ERROR_CODES, REVIEW_ERROR_CODES } from "./records-write";
24
24
  import { RECORDS_SINCE_ERROR_CODES } from "./records-since";
25
25
  import { STATUS_ERROR_CODES, STATUS_GATE_REASON_CODES, STATUS_REASON_CODES } from "./status";
@@ -41,6 +41,7 @@ const PER_COMMAND: Record<string, readonly string[]> = {
41
41
  RECORD_REASON_CODES,
42
42
  RECORD_WARNING_CODES,
43
43
  REVIEW_REASON_CODES,
44
+ SEAL_REASON_CODES,
44
45
  WORK_WARNING_CODES,
45
46
  READ_ERROR_CODES,
46
47
  NEW_ERROR_CODES,
@@ -80,13 +80,18 @@ export const REASONS = {
80
80
  "work-implements-undecided": "A work record implements a decision whose state is not approved, such as proposed.",
81
81
  "work-done-unpinned": "A work record is done and its evidence list is empty: nothing shows the work was done.",
82
82
  "work-closed-without-date": "A work record is done or dropped and has no closing date.",
83
- "work-done-gap-open": "A work record is done, and the finding it came from still fires on its region. Only graph --intent raises it.",
83
+ "work-done-gap-open": "A work record is done, and the finding it came from still fires on its region. graph --intent raises it, and records does by walking that region.",
84
84
  // A verdict the quorum does not count (records, #2671, #2672).
85
85
  "review-decider": "The verdict is the decider's own, and the quorum counts verdicts besides the decider's.",
86
86
  "review-agent": "The reviewer holds the agent role in the trust policy at base.",
87
87
  "review-duplicate": "A later verdict by the same principal replaces this one. Names are compared after NFKC, trimming and lower-casing.",
88
88
  "review-older-digest": "The verdict names a digest other than the record's text now: the record changed after the verdict.",
89
- "review-unattested": "An attestation policy is active at base, and the verdict carries no seal.",
89
+ "review-unattested": "An attestation policy is active at base, and the verdict carries no seal that verifies for its reviewer.",
90
+ // A verdict's seal that is not attested (records, #2687). Every verdict reports one of these in its attestation, unless its seal verified.
91
+ "seal-missing": "The verdict carries no seal.",
92
+ "seal-signer-unlisted": "The reviewer has no key in the signers file at base, so the seal can't count.",
93
+ "seal-signature-invalid": "The seal is malformed, names a signer other than the reviewer, or its signature does not verify over the verdict.",
94
+ "seal-unverifiable": "Nothing here can say whose seal it is: there is no signers file at base, or ssh-keygen is not installed.",
90
95
  // A records read that fails (records).
91
96
  "kind-unreadable": "The record kind file is missing or could not be imported.",
92
97
  "kind-invalid": "The record kind file exports no recordKind, or its shape is wrong.",
@@ -106,6 +111,7 @@ export const REASONS = {
106
111
  "amend-supersede-instead": "The record is approved, and the amendment changes a field the approval rule does not let change in place; a new record supersedes it instead.",
107
112
  "review-unsupported": "The kind's schema has no reviews field, so its records take no review.",
108
113
  "review-note-required": "A dissent was given with no note: a dissent needs a reason.",
114
+ "review-sign-failed": "--sign was given and no seal could be made: the key can't be read or used, git names no ssh signing key, or ssh-keygen is not installed.",
109
115
  // records --since that fails (#2673).
110
116
  "since-rev-unknown": "--since names no commit.",
111
117
  // The intent graph (graph --intent, #2651): a read that fails.
@@ -118,9 +124,11 @@ export const REASONS = {
118
124
  "intent-commit-bare": "A commit names no unit, no pull request and no decision covering the region at its time.",
119
125
  "intent-pin-drifted": "A decision's pinned artifact no longer hashes to the pin.",
120
126
  "intent-pin-missing": "A decision's pinned artifact does not exist in the tree read.",
127
+ "intent-pin-stale": "A current decision pins an artifact at the hash a record it supersedes pinned, and the artifact has not changed since: the decision moved on and the artifact did not.",
121
128
  "intent-artifact-unpinned": "An artifact decisions in the graph pinned, which no current decision pins.",
122
129
  "intent-decision-superseded-live": "Every decision constraining the region is superseded.",
123
130
  "intent-decision-provisional": "The current decisions constraining the region are all in states their kind does not close, such as decided.",
131
+ "intent-decision-contested": "A current decision constraining the region has an open concern: a dissent neither addressed nor withdrawn.",
124
132
  "intent-constraint-coarse": "The region is constrained only through its member, not by path.",
125
133
  "intent-constraint-lost": "A decision's path constraint names a path that does not exist in the tree read.",
126
134
  "intent-evidence-unpinned": "A decision's evidence has no hash: a URL, or a path with no sha256.",
@@ -169,8 +169,8 @@ describe("chant workspace records checks each pin", () => {
169
169
  });
170
170
 
171
171
  test("the schema lists exactly the warning codes", () => {
172
- // A work kind's records carry the work warnings too, except work-done-gap-open, which only graph --intent raises (#2683).
173
- expect(recordsSchema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES.filter((c) => c !== "work-done-gap-open")]);
172
+ // A work kind's records carry the work warnings too (#2683), work-done-gap-open included since records walks a done item's region (#2686).
173
+ expect(recordsSchema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES]);
174
174
  });
175
175
 
176
176
  test("records pin <path> prints the entry's path from the workspace root and the file's hash", () => {
@@ -42,6 +42,7 @@ import {
42
42
  type RecordEntry,
43
43
  type RecordFormat,
44
44
  type RecordHistory,
45
+ type SealInput,
45
46
  } from "./records";
46
47
  import { gitTree, workingTree, type WorkspaceTree } from "./tree";
47
48
  import type { DecisionWork } from "./work";
@@ -68,6 +69,12 @@ export interface RecordsQuery {
68
69
  base?: string;
69
70
  /** Where `kind` is resolved from and the repository is found. */
70
71
  cwd: string;
72
+ /**
73
+ * For a work kind: walk the region of each done item's `source` and raise
74
+ * `work-done-gap-open` when its finding still fires (#2686). On unless
75
+ * false; the intent graph passes false, since it raises the warning itself.
76
+ */
77
+ workGaps?: boolean;
71
78
  }
72
79
 
73
80
  /**
@@ -266,15 +273,24 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
266
273
  paths: result.records.map((r) => r.path),
267
274
  attestors: policy.active ? await activeAttestors() : [],
268
275
  });
269
- // The quorum: the need from the declaration in the tree read, agents and
270
- // whether verdicts need a seal from the policy at base (#2671).
271
- const quorumOptions = loaded.kind.reviews
276
+ // The quorum: the need from the declaration in the tree read, agents,
277
+ // whether verdicts need a seal, and the keys a seal verifies against,
278
+ // all from the policy at base (#2671, #2687).
279
+ const checkVerdictSeal = loaded.kind.reviews ? (await import("./trust/seal")).checkVerdictSeal : undefined;
280
+ const quorumOptions = checkVerdictSeal
272
281
  ? {
273
282
  ...declaredQuorum(tree),
274
283
  agents: new Set((policy.roles[AGENT_ROLE] ?? []).map(normalisePrincipal)),
275
284
  attestation: policy.active,
285
+ verifySeal: (v: SealInput) => checkVerdictSeal(policy, v),
276
286
  }
277
287
  : undefined;
288
+ const records: RecordView[] = result.records.map((r) => ({
289
+ ...r,
290
+ provenance: provenance.get(r.path)!,
291
+ ...(quorumOptions ? { quorum: computeQuorum(loaded.kind, r, quorumOptions) } : {}),
292
+ }));
293
+ if (loaded.kind.work && query.workGaps !== false && top) await raiseWorkGaps(loaded, records, { root, workspaceRoot, at: query.at });
278
294
  return {
279
295
  $schema: RECORDS_OUTPUT_SCHEMA_ID,
280
296
  contract: RECORDS_CONTRACT_VERSION,
@@ -288,11 +304,7 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
288
304
  workspaceRoot,
289
305
  current: !!query.current,
290
306
  trust: { base: base.commit, baseFrom: base.from, active: policy.active, signersPath: policy.signersPath, problems: policy.problems },
291
- records: result.records.map((r) => ({
292
- ...r,
293
- provenance: provenance.get(r.path)!,
294
- ...(quorumOptions ? { quorum: computeQuorum(loaded.kind, r, quorumOptions) } : {}),
295
- })),
307
+ records,
296
308
  summary: result.summary,
297
309
  ...(result.decisions ? { decisions: result.decisions } : {}),
298
310
  };
@@ -302,6 +314,56 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
302
314
  }
303
315
  }
304
316
 
317
+ /**
318
+ * `work-done-gap-open` on a records read (#2686): for each done work record
319
+ * whose `source` names a finding and a region, walk that region with the
320
+ * intent graph, at the same revision, and copy the warning the walk raises
321
+ * on the record. One walk per region, and only a region some done item names.
322
+ * The walk reads the record kinds the declaration names, and the work kind
323
+ * and its decision kind if it names neither. It needs git and a workspace
324
+ * declaration; when the walk can't be made, for one without them or a region
325
+ * that no longer exists, the record is left as it was.
326
+ */
327
+ async function raiseWorkGaps(loaded: LoadedRecordKind, records: RecordView[], opts: { root: string; workspaceRoot: string; at: string | undefined }): Promise<void> {
328
+ const work = loaded.kind.work!;
329
+ const byRegion = new Map<string, RecordView[]>();
330
+ for (const r of records) {
331
+ if (r.id === null || r.state !== work.done) continue;
332
+ const src = r.data?.source;
333
+ if (src === null || typeof src !== "object" || Array.isArray(src)) continue;
334
+ const { finding, region } = src as Record<string, unknown>;
335
+ if (typeof finding !== "string" || typeof region !== "string") continue;
336
+ byRegion.set(region, [...(byRegion.get(region) ?? []), r]);
337
+ }
338
+ if (byRegion.size === 0) return;
339
+ const cwd = opts.workspaceRoot === "." ? opts.root : join(opts.root, ...opts.workspaceRoot.split("/"));
340
+ let declared: string[];
341
+ try {
342
+ declared = declaredKindFiles(cwd, opts.at).map((k) => k.file);
343
+ } catch (err) {
344
+ if (err instanceof WorkspaceReadError) return;
345
+ throw err;
346
+ }
347
+ const kinds: string[] = [];
348
+ const seen = new Set<string>();
349
+ for (const file of [...declared, resolve(dirname(loaded.file), work.decisions), loaded.file]) {
350
+ const real = realpathOr(file);
351
+ if (seen.has(real)) continue;
352
+ seen.add(real);
353
+ kinds.push(file);
354
+ }
355
+ const { intentGraph } = await import("./intent");
356
+ for (const [region, items] of byRegion) {
357
+ const { doc } = await intentGraph({ cwd, region, at: opts.at, kinds });
358
+ if ("error" in doc) continue;
359
+ for (const r of items) {
360
+ const node = doc.nodes.find((n) => n.kind === "work" && n.id === `record:${loaded.kind.name}/${r.id}`);
361
+ const warning = node?.kind === "work" ? node.warnings.find((w) => w.code === "work-done-gap-open") : undefined;
362
+ if (warning && !r.warnings.some((w) => w.code === warning.code)) r.warnings.push(warning);
363
+ }
364
+ }
365
+ }
366
+
305
367
  /**
306
368
  * `chant workspace records pin <path>`: the `{path, sha256}` a decision's
307
369
  * evidence entry holds for a file, with the path from the workspace root
@@ -148,8 +148,8 @@ describe("records output schema", () => {
148
148
  });
149
149
 
150
150
  test("lists exactly the warning codes the code can return", () => {
151
- // A work kind's records carry the work warnings too, except work-done-gap-open, which only graph --intent raises (#2683).
152
- expect(schema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES.filter((c) => c !== "work-done-gap-open")]);
151
+ // A work kind's records carry the work warnings too (#2683), work-done-gap-open included since records walks a done item's region (#2686).
152
+ expect(schema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES]);
153
153
  });
154
154
 
155
155
  test("every failure validates with its code", async () => {
@@ -75,7 +75,7 @@
75
75
  "reviewer": {
76
76
  "type": "string",
77
77
  "minLength": 1,
78
- "description": "The principal given with --by, as given. chant does not check who it is; attestation of the commit that carries the review does (#2547)."
78
+ "description": "The principal given with --by, as given. The write does not check who it is. With --sign, the seal lets chant workspace records check it against the signers file at base (#2687)."
79
79
  },
80
80
  "verdict": {
81
81
  "enum": [
@@ -102,6 +102,30 @@
102
102
  "type": "string",
103
103
  "minLength": 1,
104
104
  "description": "The session given with --session. Absent when none was given."
105
+ },
106
+ "seal": {
107
+ "type": "object",
108
+ "description": "Added in contract 1 by #2687. With --sign only: an ssh signature by the key given (or git's user.signingkey) over <id>\\n<digest>\\n<verdict>\\n<reviewer>\\n<on>, in the ssh-keygen namespace chant-review. The write does not check the key against the signers file; chant workspace records reports whether the seal counts.",
109
+ "required": [
110
+ "signer",
111
+ "key",
112
+ "signature"
113
+ ],
114
+ "properties": {
115
+ "signer": {
116
+ "type": "string",
117
+ "description": "The reviewer given with --by."
118
+ },
119
+ "key": {
120
+ "type": "string",
121
+ "pattern": "^SHA256:[A-Za-z0-9+/]+=*$",
122
+ "description": "The fingerprint of the key that signed, read back from the signature."
123
+ },
124
+ "signature": {
125
+ "type": "string",
126
+ "description": "The armored ssh signature."
127
+ }
128
+ }
105
129
  }
106
130
  }
107
131
  },
@@ -180,6 +204,7 @@
180
204
  "review-unsupported",
181
205
  "record-closed",
182
206
  "review-note-required",
207
+ "review-sign-failed",
183
208
  "record-unparseable",
184
209
  "record-schema-invalid",
185
210
  "record-id-duplicate",