@intentius/chant 0.87.0 → 0.88.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 (71) hide show
  1. package/dist/cli/handlers/misc.d.ts.map +1 -1
  2. package/dist/cli/main.d.ts.map +1 -1
  3. package/dist/cli/mcp/server.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +4 -1
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/cli/version.d.ts +8 -0
  7. package/dist/cli/version.d.ts.map +1 -0
  8. package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
  9. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  10. package/dist/workspace/composites.d.ts +13 -0
  11. package/dist/workspace/composites.d.ts.map +1 -1
  12. package/dist/workspace/environments.d.ts +80 -0
  13. package/dist/workspace/environments.d.ts.map +1 -0
  14. package/dist/workspace/reason-codes.d.ts +14 -5
  15. package/dist/workspace/reason-codes.d.ts.map +1 -1
  16. package/dist/workspace/records-cli.d.ts +6 -2
  17. package/dist/workspace/records-cli.d.ts.map +1 -1
  18. package/dist/workspace/records-close.d.ts +41 -0
  19. package/dist/workspace/records-close.d.ts.map +1 -0
  20. package/dist/workspace/records-since.d.ts +40 -1
  21. package/dist/workspace/records-since.d.ts.map +1 -1
  22. package/dist/workspace/records-write.d.ts +109 -11
  23. package/dist/workspace/records-write.d.ts.map +1 -1
  24. package/dist/workspace/records.d.ts +40 -12
  25. package/dist/workspace/records.d.ts.map +1 -1
  26. package/dist/workspace/runtimes.d.ts +6 -0
  27. package/dist/workspace/runtimes.d.ts.map +1 -1
  28. package/dist/workspace/session-kinds.d.ts +28 -0
  29. package/dist/workspace/session-kinds.d.ts.map +1 -0
  30. package/dist/workspace/status.d.ts +2 -0
  31. package/dist/workspace/status.d.ts.map +1 -1
  32. package/dist/workspace/trust/seal.d.ts +42 -0
  33. package/dist/workspace/trust/seal.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/cli/handlers/misc.ts +1 -9
  36. package/src/cli/main.ts +20 -8
  37. package/src/cli/mcp/server.test.ts +14 -1
  38. package/src/cli/mcp/server.ts +3 -1
  39. package/src/cli/registry.ts +4 -1
  40. package/src/cli/version.ts +15 -0
  41. package/src/workspace/__fixtures__/sessions.ts +41 -0
  42. package/src/workspace/composites.schema.json +68 -3
  43. package/src/workspace/composites.test.ts +119 -5
  44. package/src/workspace/composites.ts +26 -7
  45. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +11 -0
  46. package/src/workspace/environments.ts +165 -0
  47. package/src/workspace/read-contract.test.ts +3 -0
  48. package/src/workspace/reason-codes.test.ts +8 -3
  49. package/src/workspace/reason-codes.ts +18 -6
  50. package/src/workspace/record-assets.test.ts +4 -3
  51. package/src/workspace/records-amend.schema.json +30 -1
  52. package/src/workspace/records-cli.ts +44 -7
  53. package/src/workspace/records-close.schema.json +192 -0
  54. package/src/workspace/records-close.ts +129 -0
  55. package/src/workspace/records-contract.test.ts +4 -3
  56. package/src/workspace/records-new.schema.json +25 -0
  57. package/src/workspace/records-review.schema.json +55 -2
  58. package/src/workspace/records-sessions-write.test.ts +274 -0
  59. package/src/workspace/records-since.schema.json +27 -2
  60. package/src/workspace/records-since.ts +120 -6
  61. package/src/workspace/records-write-contract.test.ts +5 -1
  62. package/src/workspace/records-write.test.ts +4 -2
  63. package/src/workspace/records-write.ts +307 -43
  64. package/src/workspace/records.schema.json +22 -3
  65. package/src/workspace/records.ts +73 -22
  66. package/src/workspace/runtimes.ts +12 -3
  67. package/src/workspace/session-kinds.ts +79 -0
  68. package/src/workspace/status.ts +1 -1
  69. package/src/workspace/trust/record-seal.test.ts +315 -0
  70. package/src/workspace/trust/seal.test.ts +4 -14
  71. package/src/workspace/trust/seal.ts +119 -25
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `chant workspace records close <session id>` (#2693): close a review
3
+ * session in one write. It sets the session's state to its kind's closed
4
+ * state, the time it closed and the commit it closed at (the fields the
5
+ * kind's `session` block names in `closedOn` and `closedRev`), and then the
6
+ * seal by chant's rule ({@link sessionSeal}), so a UI never computes a seal.
7
+ *
8
+ * Like `new`, `amend` and `review`, it reads the records again with the file
9
+ * in place and writes only when the session comes back valid: a verdict
10
+ * naming a record the subjects lack is refused with
11
+ * `session-verdict-unknown-record`. A session already closed is
12
+ * `record-closed`. It writes one file or none and never commits.
13
+ */
14
+
15
+ import { writeFileSync } from "node:fs";
16
+ import type { ReasonCode } from "./reason-codes";
17
+ import { sessionSeal } from "./record-sessions";
18
+ import { RECORD_REASON_CODES } from "./records";
19
+ import {
20
+ abs,
21
+ failure,
22
+ findRecord,
23
+ LOAD_ERROR_CODES,
24
+ open,
25
+ openedRevFill,
26
+ readAll,
27
+ RECORDS_WRITE_CONTRACT_VERSION,
28
+ RecordWriteError,
29
+ replaceFields,
30
+ stableJson,
31
+ validateWrite,
32
+ type WriteFailure,
33
+ type WriteResult,
34
+ } from "./records-write";
35
+ import { headCommit } from "./session-kinds";
36
+
37
+ export const RECORDS_CLOSE_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records-close/v1/records-close.schema.json";
38
+
39
+ /** Why `records close` wrote nothing. Closed. */
40
+ export const CLOSE_ERROR_CODES = [
41
+ ...LOAD_ERROR_CODES,
42
+ "write-usage-invalid",
43
+ "record-not-found",
44
+ "record-closed",
45
+ ...RECORD_REASON_CODES,
46
+ ] as const satisfies readonly ReasonCode[];
47
+ export type CloseErrorCode = (typeof CLOSE_ERROR_CODES)[number];
48
+
49
+ export type CloseDocument =
50
+ | (WriteResult & {
51
+ /** The top-level fields the close set, in the order written. */
52
+ changed: string[];
53
+ /** The seal field and the digest written in it. */
54
+ seal: { field: string; digest: string };
55
+ /** The commit HEAD named at the close, or null outside git or before the first commit. */
56
+ closedRev: string | null;
57
+ })
58
+ | WriteFailure<CloseErrorCode>;
59
+
60
+ export interface CloseRecordOptions {
61
+ /** The session kind file, resolved against `cwd`. */
62
+ kind: string;
63
+ id: string;
64
+ dryRun?: boolean;
65
+ cwd: string;
66
+ /** The close time. Defaults to now. */
67
+ now?: Date;
68
+ }
69
+
70
+ /** An ISO 8601 time in UTC to the second, as the reference session schema's dateTime takes it. */
71
+ function isoSeconds(d: Date): string {
72
+ return d.toISOString().replace(/\.\d{3}Z$/, "Z");
73
+ }
74
+
75
+ /** `records close`: close one open session and seal it. */
76
+ export async function closeRecord(opts: CloseRecordOptions): Promise<CloseDocument> {
77
+ try {
78
+ const o = await open(opts.kind, opts.cwd);
79
+ const { kind } = o.loaded;
80
+ const decl = kind.session;
81
+ if (!decl || kind.stateField === undefined) {
82
+ throw new RecordWriteError("write-usage-invalid", `the ${kind.name} kind has no session block, and records close closes a review session; amend sets the state of other records`);
83
+ }
84
+ const closedState = (kind.closedStates ?? [])[0];
85
+ if (closedState === undefined) throw new RecordWriteError("write-usage-invalid", `the ${kind.name} kind lists no closed state, so a session of it can't close`);
86
+ const before = await readAll(o, o.source);
87
+ const target = findRecord(before, opts.id, kind.name);
88
+ if (target.state !== null && (kind.closedStates ?? []).includes(target.state)) {
89
+ throw new RecordWriteError("record-closed", `${opts.id} is already ${target.state}, and a closed session is sealed and stays as it is`);
90
+ }
91
+ if (target.data === null) throw new RecordWriteError("record-unparseable", `${target.path} can't be read, so it can't be closed`);
92
+ const old = target.data;
93
+ const closedRev = headCommit(o.root);
94
+ const set: Record<string, unknown> = {
95
+ ...openedRevFill(kind, old, o.root),
96
+ [kind.stateField]: closedState,
97
+ ...(decl.closedOn ? { [decl.closedOn]: isoSeconds(opts.now ?? new Date()) } : {}),
98
+ ...(decl.closedRev ? { [decl.closedRev]: closedRev } : {}),
99
+ };
100
+ // The seal line is written last, into text that already holds everything else, so the seal is the digest of the file without it.
101
+ const merged = { ...old, ...set };
102
+ delete merged[decl.seal];
103
+ const unsealed = replaceFields(o.source.read(target.path), set, merged);
104
+ if (unsealed === undefined) throw new RecordWriteError("record-unparseable", `${target.path}: its fields can't be rewritten in place without changing the rest of the file`);
105
+ const digest = sessionSeal(unsealed, decl.seal, kind.format);
106
+ const text = replaceFields(unsealed, { [decl.seal]: digest }, { ...merged, [decl.seal]: digest });
107
+ if (text === undefined || sessionSeal(text, decl.seal, kind.format) !== digest) {
108
+ throw new RecordWriteError("record-unparseable", `${target.path}: the ${decl.seal} line can't be added without changing the text it seals`);
109
+ }
110
+ const warnings = await validateWrite(o, before, target.path, text);
111
+ if (!opts.dryRun) writeFileSync(abs(o, target.path), text);
112
+ const written = { ...merged, [decl.seal]: digest };
113
+ return {
114
+ $schema: RECORDS_CLOSE_SCHEMA_ID,
115
+ contract: RECORDS_WRITE_CONTRACT_VERSION,
116
+ kind: o.view,
117
+ path: target.path,
118
+ id: opts.id,
119
+ changed: Object.keys(written).filter((k) => stableJson(old[k]) !== stableJson(written[k])),
120
+ seal: { field: decl.seal, digest },
121
+ closedRev,
122
+ dryRun: !!opts.dryRun,
123
+ warnings,
124
+ ...(opts.dryRun ? { text } : {}),
125
+ };
126
+ } catch (err) {
127
+ return failure<CloseErrorCode>(RECORDS_CLOSE_SCHEMA_ID, err);
128
+ }
129
+ }
@@ -11,7 +11,7 @@ import { join } from "node:path";
11
11
  import Ajv2020 from "ajv/dist/2020";
12
12
  import { afterAll, describe, expect, test } from "vitest";
13
13
  import { queryRecords, RECORDS_CONTRACT_VERSION, RECORDS_OUTPUT_SCHEMA_ID, type RecordsDocument } from "./records-cli";
14
- import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, recordTextDigest, REVIEW_REASON_CODES } from "./records";
14
+ import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, recordTextDigest, REVIEW_REASON_CODES, SEAL_WARNING_CODES } from "./records";
15
15
  import { WORK_WARNING_CODES } from "./work";
16
16
  import schema from "./records.schema.json";
17
17
  import { PROVENANCE_LEVELS } from "./trust/attestor";
@@ -148,8 +148,9 @@ 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 (#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]);
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
+ // and records adds record-unattested for an author seal under a signers file at base (#2688).
153
+ expect(schema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES, ...SEAL_WARNING_CODES]);
153
154
  });
154
155
 
155
156
  test("every failure validates with its code", async () => {
@@ -61,6 +61,30 @@
61
61
  "type": "string",
62
62
  "description": "The record's id."
63
63
  },
64
+ "seal": {
65
+ "type": "object",
66
+ "description": "Added in contract 1 by #2688. With --sign only: the author seal written into the record's top-level seal field, an ssh signature by the key given (or git's user.signingkey) over <id>\\n<digest>\\n<author>\\n<state>, in the ssh-keygen namespace chant-record. The author is the kind's reviews.decider field (decided_by for decisions), and the digest is the record's digest by the digest rule, which leaves the seal field out. The write does not check the key against the signers file; chant workspace records reports whether the seal verifies.",
67
+ "required": [
68
+ "signer",
69
+ "key",
70
+ "signature"
71
+ ],
72
+ "properties": {
73
+ "signer": {
74
+ "type": "string",
75
+ "description": "The record's author, as its reviews.decider field names them."
76
+ },
77
+ "key": {
78
+ "type": "string",
79
+ "pattern": "^SHA256:[A-Za-z0-9+/]+=*$",
80
+ "description": "The fingerprint of the key that signed, read back from the signature."
81
+ },
82
+ "signature": {
83
+ "type": "string",
84
+ "description": "The armored ssh signature."
85
+ }
86
+ }
87
+ },
64
88
  "dryRun": {
65
89
  "type": "boolean",
66
90
  "description": "True when --dry-run was given and nothing was written."
@@ -136,6 +160,7 @@
136
160
  "record-id-taken",
137
161
  "record-id-unallocatable",
138
162
  "record-path-unmatched",
163
+ "record-sign-failed",
139
164
  "record-unparseable",
140
165
  "record-schema-invalid",
141
166
  "record-id-duplicate",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/records-review/v1/records-review.schema.json",
4
4
  "title": "chant workspace records review output",
5
- "description": "What `chant workspace records review <id> --kind <kind file> --verdict <verdict> --by <principal>` prints (#2670): the record's path and id and the review entry appended, or the reason it wrote nothing. Version 1 of the write contract for records. The command writes one file or none and never commits. Readers ignore fields they do not know; a field is only ever added within a version. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Contract version 1 of this document is written by chant 0.86.0 and newer.",
5
+ "description": "What `chant workspace records review <id> --kind <kind file> --verdict <verdict> --by <principal>` prints (#2670): the record's path and id and the review entry appended, or the reason it wrote nothing. Version 1 of the write contract for records. The command writes one file or none, or with --session the record and the session (#2693), and never commits. Readers ignore fields they do not know; a field is only ever added within a version. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Contract version 1 of this document is written by chant 0.86.0 and newer.",
6
6
  "oneOf": [
7
7
  {
8
8
  "$ref": "#/$defs/result"
@@ -101,7 +101,7 @@
101
101
  "session": {
102
102
  "type": "string",
103
103
  "minLength": 1,
104
- "description": "The session given with --session. Absent when none was given."
104
+ "description": "The session given with --session. Absent when none was given. Since #2693 it names an open session of a session kind whose subjects are this kind, found through the workspace declaration (session-unknown, session-not-open)."
105
105
  },
106
106
  "seal": {
107
107
  "type": "object",
@@ -144,6 +144,57 @@
144
144
  "type": "string",
145
145
  "description": "With --dry-run only: the whole text of the file the command would write."
146
146
  },
147
+ "session": {
148
+ "type": "object",
149
+ "description": "Added in contract 1 by #2693, with --session only: the session record the verdict was also appended to, in the same command, so the session's verdicts list and the reviews that name the session never differ. Both files are validated before either is written.",
150
+ "required": [
151
+ "id",
152
+ "path",
153
+ "verdict"
154
+ ],
155
+ "properties": {
156
+ "id": {
157
+ "type": "string"
158
+ },
159
+ "path": {
160
+ "type": "string",
161
+ "description": "The session file, relative to the repository root, with / separators."
162
+ },
163
+ "verdict": {
164
+ "type": "object",
165
+ "description": "The entry appended to the session's verdicts list (the field the session kind's session.verdicts names): the record's id, the principal given with --by, the verdict and the same digest as the review.",
166
+ "required": [
167
+ "record",
168
+ "principal",
169
+ "verdict",
170
+ "digest"
171
+ ],
172
+ "properties": {
173
+ "record": {
174
+ "type": "string"
175
+ },
176
+ "principal": {
177
+ "type": "string"
178
+ },
179
+ "verdict": {
180
+ "enum": [
181
+ "agree",
182
+ "dissent",
183
+ "abstain"
184
+ ]
185
+ },
186
+ "digest": {
187
+ "type": "string",
188
+ "pattern": "^[0-9a-f]{64}$"
189
+ }
190
+ }
191
+ },
192
+ "text": {
193
+ "type": "string",
194
+ "description": "With --dry-run only: the whole text the session file would hold."
195
+ }
196
+ }
197
+ },
147
198
  "error": false
148
199
  }
149
200
  },
@@ -205,6 +256,8 @@
205
256
  "record-closed",
206
257
  "review-note-required",
207
258
  "review-sign-failed",
259
+ "session-unknown",
260
+ "session-not-open",
208
261
  "record-unparseable",
209
262
  "record-schema-invalid",
210
263
  "record-id-duplicate",
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Sessions from a UI (#2693): `records new` on a session kind records the
3
+ * commit it opened at, `records close` closes and seals a session in one
4
+ * write, `records review --session` refuses a session that does not exist or
5
+ * is not open and appends the verdict to the session too, and
6
+ * `records --since <session id>` compares the session's own revisions. The
7
+ * fixture is a copy of the reference workspace's decisions and design
8
+ * member with a declaration naming both kinds.
9
+ */
10
+
11
+ import { spawnSync } from "node:child_process";
12
+ import { cpSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { pathToFileURL } from "node:url";
15
+ import { afterAll, describe, expect, test } from "vitest";
16
+ import { cleanScratch, commitAll, contract, git, REPO, scratchDir } from "./__fixtures__/contract-repo";
17
+ import { declareSessions, DECISIONS_KIND, newSessionFields, sessionsRepo, SESSIONS_KIND } from "./__fixtures__/sessions";
18
+ import { sessionSeal } from "./record-sessions";
19
+ import { parseFrontMatter, recordTextDigest } from "./records";
20
+ import { queryRecords } from "./records-cli";
21
+ import { closeRecord } from "./records-close";
22
+ import closeSchema from "./records-close.schema.json";
23
+ import { queryRecordsSince, type RecordsSinceDocument } from "./records-since";
24
+ import sinceSchema from "./records-since.schema.json";
25
+ import { amendRecord, newRecord, reviewRecord } from "./records-write";
26
+ import newSchema from "./records-new.schema.json";
27
+ import reviewSchema from "./records-review.schema.json";
28
+
29
+ afterAll(cleanScratch);
30
+
31
+ const close = contract(closeSchema);
32
+ const since = contract(sinceSchema);
33
+ const review = contract(reviewSchema);
34
+ const created = contract(newSchema);
35
+
36
+ const code = (doc: object) => ("error" in doc ? (doc as { error: { code: string } }).error.code : "ok");
37
+ const HEX = /^[0-9a-f]{40}$/;
38
+
39
+ /** The reference fixture with a declaration, committed. */
40
+ function declared(): string {
41
+ const root = sessionsRepo();
42
+ declareSessions(root);
43
+ commitAll(root, "declare");
44
+ return root;
45
+ }
46
+
47
+ function front(root: string, path: string): Record<string, unknown> {
48
+ const fm = parseFrontMatter(readFileSync(join(root, path), "utf-8"));
49
+ if (!fm.ok) throw new Error(fm.message);
50
+ return fm.value;
51
+ }
52
+
53
+ /** Open S-0002 through records new, and commit it. */
54
+ async function opened(root: string): Promise<{ path: string; head: string }> {
55
+ const head = git(root, "rev-parse", "HEAD");
56
+ const doc = await newRecord({ kind: SESSIONS_KIND, fields: newSessionFields(), cwd: root });
57
+ created.expectValid(doc);
58
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
59
+ expect(doc.id).toBe("S-0002");
60
+ commitAll(root, "open S-0002");
61
+ return { path: doc.path, head };
62
+ }
63
+
64
+ async function sessions(root: string) {
65
+ const doc = await queryRecords({ kind: SESSIONS_KIND, cwd: root });
66
+ if ("error" in doc) throw new Error(doc.error.message);
67
+ return doc;
68
+ }
69
+
70
+ describe("opening revisions (#2693)", () => {
71
+ test("records new on a session kind writes opened_rev, the commit HEAD names", async () => {
72
+ const root = declared();
73
+ const { path, head } = await opened(root);
74
+ expect(front(root, path).opened_rev).toBe(head);
75
+ expect((await sessions(root)).records.find((r) => r.id === "S-0002")).toMatchObject({ state: "open", valid: true });
76
+ });
77
+
78
+ test("before the first commit it is null, and the next amend fills it", async () => {
79
+ const root = scratchDir("chant-sessions-nocommit-");
80
+ for (const d of ["decisions", "design"]) cpSync(join(REPO, "reference-workspace", d), join(root, d), { recursive: true });
81
+ declareSessions(root);
82
+ git(root, "init", "-q");
83
+ const doc = await newRecord({ kind: SESSIONS_KIND, fields: newSessionFields(), cwd: root });
84
+ if ("error" in doc) throw new Error(doc.error.message);
85
+ expect(front(root, doc.path).opened_rev).toBeNull();
86
+ const head = commitAll(root, "first");
87
+ const amended = await amendRecord({ kind: SESSIONS_KIND, id: "S-0002", fields: "{}", cwd: root });
88
+ expect(amended).toMatchObject({ changed: ["opened_rev"] });
89
+ expect(front(root, doc.path).opened_rev).toBe(head);
90
+ });
91
+
92
+ test("a caller can't set the opening or closing revision", async () => {
93
+ const root = declared();
94
+ expect(code(await newRecord({ kind: SESSIONS_KIND, fields: newSessionFields({ opened_rev: "a".repeat(40) }), cwd: root }))).toBe("write-input-invalid");
95
+ await opened(root);
96
+ expect(code(await amendRecord({ kind: SESSIONS_KIND, id: "S-0002", fields: JSON.stringify({ opened_rev: "b".repeat(40) }), cwd: root }))).toBe("write-input-invalid");
97
+ expect(code(await amendRecord({ kind: SESSIONS_KIND, id: "S-0002", fields: JSON.stringify({ closed_rev: "b".repeat(40) }), cwd: root }))).toBe("write-input-invalid");
98
+ });
99
+ });
100
+
101
+ describe("records close (#2693)", () => {
102
+ test("sets the state, the close time, the closing revision and the seal in one write, and the session reads back sealed", async () => {
103
+ const root = declared();
104
+ const { path } = await opened(root);
105
+ const head = git(root, "rev-parse", "HEAD");
106
+ const dry = await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", dryRun: true, cwd: root, now: new Date("2026-09-25T10:30:00.123Z") });
107
+ close.expectValid(dry);
108
+ expect(dry).toMatchObject({ dryRun: true, closedRev: head, text: expect.stringContaining('state: "closed"') });
109
+ expect(front(root, path).state).toBe("open");
110
+
111
+ const doc = await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", cwd: root, now: new Date("2026-09-25T10:30:00.123Z") });
112
+ close.expectValid(doc);
113
+ if ("error" in doc) throw new Error(doc.error.message);
114
+ expect(doc.changed.sort()).toEqual(["closed", "closed_digest", "closed_rev", "state"]);
115
+ const fm = front(root, path);
116
+ expect(fm).toMatchObject({ state: "closed", closed: "2026-09-25T10:30:00Z", closed_rev: head, opened_rev: expect.stringMatching(HEX) });
117
+ const text = readFileSync(join(root, path), "utf-8");
118
+ expect(fm.closed_digest).toBe(sessionSeal(text, "closed_digest"));
119
+ expect(doc.seal).toEqual({ field: "closed_digest", digest: fm.closed_digest });
120
+ expect((await sessions(root)).records.find((r) => r.id === "S-0002")).toMatchObject({ state: "closed", valid: true, reasons: [] });
121
+ });
122
+
123
+ test("refuses a closed session, an unknown id and a verdict naming a record the subjects lack", async () => {
124
+ const root = declared();
125
+ const s1 = await closeRecord({ kind: SESSIONS_KIND, id: "S-0001", cwd: root });
126
+ close.expectValid(s1);
127
+ expect(code(s1)).toBe("record-closed");
128
+ expect(code(await closeRecord({ kind: SESSIONS_KIND, id: "S-0404", cwd: root }))).toBe("record-not-found");
129
+ expect(code(await closeRecord({ kind: DECISIONS_KIND, id: "ref-001", cwd: root }))).toBe("write-usage-invalid");
130
+ await opened(root);
131
+ const file = join(root, "design", "sessions", "S-0002-second-walk.md");
132
+ writeFileSync(file, readFileSync(file, "utf-8").replace("verdicts: []", 'verdicts:\n - record: "ref-999"\n principal: "alice"\n verdict: "agree"'));
133
+ const before = readFileSync(file, "utf-8");
134
+ const bad = await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", cwd: root });
135
+ close.expectValid(bad);
136
+ expect(code(bad)).toBe("session-verdict-unknown-record");
137
+ expect(readFileSync(file, "utf-8")).toBe(before);
138
+ });
139
+
140
+ test("amend's refusal on a closed session does not advise a supersedes field the session schema lacks", async () => {
141
+ const root = declared();
142
+ const doc = await amendRecord({ kind: SESSIONS_KIND, id: "S-0001", fields: JSON.stringify({ title: "x" }), cwd: root });
143
+ expect(code(doc)).toBe("record-closed");
144
+ const message = "error" in doc ? doc.error.message : "";
145
+ expect(message).not.toContain("supersedes");
146
+ expect(message).toContain("open a new session");
147
+ });
148
+ });
149
+
150
+ describe("records review --session (#2693)", () => {
151
+ test("refuses a session that does not exist and one that is closed, writing nothing", async () => {
152
+ const root = declared();
153
+ const decision = join(root, "decisions", "ref-001-how-the-app-is-deployed.md");
154
+ const before = readFileSync(decision, "utf-8");
155
+ const unknown = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-9999", cwd: root });
156
+ review.expectValid(unknown);
157
+ expect(code(unknown)).toBe("session-unknown");
158
+ const closed = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0001", cwd: root });
159
+ review.expectValid(closed);
160
+ expect(code(closed)).toBe("session-not-open");
161
+ expect(readFileSync(decision, "utf-8")).toBe(before);
162
+ });
163
+
164
+ test("a verdict given in a session shows on both the decision and the session, with the same digest", async () => {
165
+ const root = declared();
166
+ const { path } = await opened(root);
167
+ const decision = join(root, "decisions", "ref-001-how-the-app-is-deployed.md");
168
+ const digest = recordTextDigest(readFileSync(decision, "utf-8"));
169
+
170
+ const dry = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0002", dryRun: true, cwd: root });
171
+ review.expectValid(dry);
172
+ expect(dry).toMatchObject({ session: { id: "S-0002", path, text: expect.stringContaining('principal: "alice"') } });
173
+ expect(front(root, path).verdicts).toEqual([]);
174
+
175
+ const doc = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0002", on: "2026-09-25", cwd: root });
176
+ review.expectValid(doc);
177
+ if ("error" in doc) throw new Error(doc.error.message);
178
+ const verdict = { record: "ref-001", principal: "alice", verdict: "agree", digest };
179
+ expect(doc.session).toEqual({ id: "S-0002", path, verdict });
180
+ expect(front(root, "decisions/ref-001-how-the-app-is-deployed.md").reviews).toEqual([{ reviewer: "alice", verdict: "agree", on: "2026-09-25", digest, session: "S-0002" }]);
181
+ expect(front(root, path).verdicts).toEqual([verdict]);
182
+
183
+ const s2 = (await sessions(root)).records.find((r) => r.id === "S-0002")!;
184
+ expect(s2).toMatchObject({ valid: true, citedBy: [{ id: "ref-001", index: 0, reviewer: "alice", verdict: "agree" }] });
185
+ expect(s2.data?.verdicts).toEqual([verdict]);
186
+
187
+ // The session closes over the verdict, and a later one is refused.
188
+ close.expectValid(await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", cwd: root }));
189
+ expect(code(await reviewRecord({ kind: DECISIONS_KIND, id: "ref-002", verdict: "agree", by: "bob", session: "S-0002", cwd: root }))).toBe("session-not-open");
190
+ });
191
+ });
192
+
193
+ describe("records --since <session id> (#2693)", () => {
194
+ async function sinceDoc(q: { kind: string; since: string; at?: string; cwd: string }): Promise<Extract<RecordsSinceDocument, { changes: unknown }>> {
195
+ const doc = await queryRecordsSince(q);
196
+ since.expectValid(doc);
197
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
198
+ return doc;
199
+ }
200
+
201
+ test("an open session compares its opening revision with the working tree, and a closed one its opening and closing revisions", async () => {
202
+ const root = declared();
203
+ const { head } = await opened(root);
204
+ await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0002", on: "2026-09-25", cwd: root });
205
+
206
+ const open = await sinceDoc({ kind: DECISIONS_KIND, since: "S-0002", cwd: root });
207
+ expect(open.since).toBe(head);
208
+ expect(open.at).toBeNull();
209
+ expect(open.session).toMatchObject({ id: "S-0002", state: "open", sinceFrom: "opened-rev", atFrom: "working-tree", reasons: [{ code: "since-session-open" }] });
210
+ expect(open.changes).toEqual([{ change: "verdict", id: "ref-001", principal: "alice", verdict: "agree", index: 0, session: "S-0002" }]);
211
+
212
+ // Closed but not committed: closed_rev is HEAD before the close, and the close is only in the working tree.
213
+ const closing = await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", cwd: root });
214
+ expect(closing).toMatchObject({ closedRev: git(root, "rev-parse", "HEAD") });
215
+ expect((await sinceDoc({ kind: DECISIONS_KIND, since: "S-0002", cwd: root })).session).toMatchObject({ state: "closed", atFrom: "working-tree", reasons: [] });
216
+ const closeCommit = commitAll(root, "the verdict and the close");
217
+ await reviewRecord({ kind: DECISIONS_KIND, id: "ref-002", verdict: "agree", by: "bob", cwd: root });
218
+
219
+ const done = await sinceDoc({ kind: DECISIONS_KIND, since: "S-0002", cwd: root });
220
+ expect(done).toMatchObject({ since: head, at: closeCommit, session: { state: "closed", sinceFrom: "opened-rev", atFrom: "close-commit", reasons: [] } });
221
+ // The later review of ref-002 came after the session closed, so it is not listed.
222
+ expect(done.changes).toEqual([{ change: "verdict", id: "ref-001", principal: "alice", verdict: "agree", index: 0, session: "S-0002" }]);
223
+ // The session was written after its opening commit, so it is new, with the verdict it produced.
224
+ const own = await sinceDoc({ kind: SESSIONS_KIND, since: "S-0002", cwd: root });
225
+ expect(own.changes).toEqual([
226
+ { change: "new", id: "S-0002", path: "design/sessions/S-0002-second-walk.md", state: "closed" },
227
+ { change: "verdict", id: "S-0002", principal: "alice", verdict: "agree", index: 0, record: "ref-001" },
228
+ ]);
229
+ });
230
+
231
+ test("a session without revision fields falls back to its file's history, and an unknown id is since-session-unknown", async () => {
232
+ const root = declared();
233
+ const added = git(root, "log", "--diff-filter=A", "--format=%H", "--", "design/sessions/S-0001-first-walk-of-the-reference-decisions.md");
234
+ const s1 = await sinceDoc({ kind: SESSIONS_KIND, since: "S-0001", cwd: root });
235
+ expect(s1).toMatchObject({ since: added, at: added, session: { sinceFrom: "history", atFrom: "history" }, changes: [] });
236
+ const unknown = await queryRecordsSince({ kind: DECISIONS_KIND, since: "S-9999", cwd: root });
237
+ since.expectValid(unknown);
238
+ expect(code(unknown)).toBe("since-session-unknown");
239
+ // An id-shaped value that names a commit, such as a tag, is still read as the commit.
240
+ git(root, "tag", "v-1");
241
+ expect((await sinceDoc({ kind: DECISIONS_KIND, since: "v-1", cwd: root })).session).toBeUndefined();
242
+ });
243
+
244
+ test(
245
+ "through the CLI: close with the declared session kind, and --since <session id> without --kind",
246
+ async () => {
247
+ const root = declared();
248
+ await opened(root);
249
+ const run = (...args: string[]) =>
250
+ spawnSync(process.execPath, ["--import", pathToFileURL(join(REPO, "node_modules/tsx/dist/loader.mjs")).href, join(REPO, "packages/core/src/cli/main.ts"), "workspace", "records", ...args], {
251
+ cwd: root,
252
+ encoding: "utf-8",
253
+ env: { ...process.env, NO_COLOR: "1" },
254
+ timeout: 60_000,
255
+ });
256
+ const closed = run("close", "S-0002");
257
+ expect(closed.status, closed.stderr).toBe(0);
258
+ close.expectValid(JSON.parse(closed.stdout));
259
+ const again = run("close", "S-0002");
260
+ expect(again.status).toBe(1);
261
+ expect(JSON.parse(again.stdout).error.code).toBe("record-closed");
262
+ commitAll(root, "close");
263
+ const set = run("--since", "S-0002", "--json");
264
+ expect(set.status, set.stderr).toBe(0);
265
+ const kinds = JSON.parse(set.stdout).kinds as RecordsSinceDocument[];
266
+ for (const k of kinds) since.expectValid(k);
267
+ expect(kinds.map((k) => ("session" in k ? k.session?.id : null))).toEqual(["S-0002", "S-0002"]);
268
+ const text = run("--kind", SESSIONS_KIND, "--since", "S-0002");
269
+ expect(text.stdout).toContain("session S-0002 (closed)");
270
+ expect(text.stdout).toContain("S-0002 new, closed");
271
+ },
272
+ 120_000,
273
+ );
274
+ });
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/records-since/v1/records-since.schema.json",
4
4
  "title": "chant workspace records --since output",
5
- "description": "What `chant workspace records --kind <kind file> --since <rev> --json` prints (#2673, #2650 C11): what changed in a kind's records between the commit --since names and the tree --at names, or the working tree without it. Records are matched by id; a record whose id could not be read is not compared. Between a review session's open and close commits, the changes are what the session did. Readers ignore fields they do not know; a field is only ever added within a version. The change kinds and the error codes are closed lists. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2673; a chant without --since on records refuses the flag. Every code is in the one closed list of `reason-codes.ts`.",
5
+ "description": "What `chant workspace records --kind <kind file> --since <rev|session id> --json` prints (#2673, #2650 C11): what changed in a kind's records between the commit --since names and the tree --at names, or the working tree without it. Records are matched by id; a record whose id could not be read is not compared. Between a review session's open and close commits, the changes are what the session did. Readers ignore fields they do not know; a field is only ever added within a version. The change kinds and the error codes are closed lists. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2673; a chant without --since on records refuses the flag. Every code is in the one closed list of `reason-codes.ts`.",
6
6
  "oneOf": [{ "$ref": "#/$defs/result" }, { "$ref": "#/$defs/failure" }, { "$ref": "#/$defs/set" }],
7
7
  "$defs": {
8
8
  "set": {
@@ -59,6 +59,7 @@
59
59
  },
60
60
  "since": { "type": "string", "pattern": "^[0-9a-f]{40,64}$", "description": "The full commit id --since names." },
61
61
  "at": { "type": ["string", "null"], "pattern": "^[0-9a-f]{40,64}$", "description": "The full commit id --at names, or null for the working tree." },
62
+ "session": { "$ref": "#/$defs/session" },
62
63
  "error": false,
63
64
  "changes": {
64
65
  "description": "In the order new, removed, state, verdict, supersession, pin, and by id within each.",
@@ -80,6 +81,29 @@
80
81
  }
81
82
  }
82
83
  },
84
+ "session": {
85
+ "description": "Added in contract 1 by #2693, with --since <session id> only: the session named, and where since and at came from. since is the commit the session opened at: its opening revision field (the kind's session.openedRev), or, for a session without one, the latest commit that added its file (history). at is the commit that carries the close: the first commit after its closing revision field (session.closedRev, HEAD when records close wrote the close) that changed the session file (close-commit), or, for a session without that field, the last commit that changed its file (history); --at overrides it (at). An open session, or one whose close is not committed yet, is compared with the working tree (working-tree), and an open one carries since-session-open.",
86
+ "type": "object",
87
+ "required": ["id", "path", "state", "sinceFrom", "atFrom", "reasons"],
88
+ "properties": {
89
+ "id": { "type": "string" },
90
+ "path": { "type": "string", "description": "The session file, from the repository root, with / separators." },
91
+ "state": { "type": ["string", "null"] },
92
+ "sinceFrom": { "enum": ["opened-rev", "history"] },
93
+ "atFrom": { "enum": ["close-commit", "history", "at", "working-tree"] },
94
+ "reasons": {
95
+ "type": "array",
96
+ "items": {
97
+ "type": "object",
98
+ "required": ["code", "message"],
99
+ "properties": {
100
+ "code": { "enum": ["since-session-open"] },
101
+ "message": { "type": "string" }
102
+ }
103
+ }
104
+ }
105
+ }
106
+ },
83
107
  "change": {
84
108
  "oneOf": [
85
109
  { "$ref": "#/$defs/new" },
@@ -180,7 +204,8 @@
180
204
  "location-missing",
181
205
  "not-a-git-repository",
182
206
  "revision-unknown",
183
- "since-rev-unknown"
207
+ "since-rev-unknown",
208
+ "since-session-unknown"
184
209
  ]
185
210
  },
186
211
  "message": { "type": "string" }