@intentius/chant 0.87.0 → 0.89.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 (79) hide show
  1. package/dist/cli/handlers/misc.d.ts.map +1 -1
  2. package/dist/cli/handlers/serve.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +10 -1
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/workspace-plugins.d.ts +40 -0
  7. package/dist/cli/mcp/workspace-plugins.d.ts.map +1 -0
  8. package/dist/cli/registry.d.ts +6 -1
  9. package/dist/cli/registry.d.ts.map +1 -1
  10. package/dist/cli/version.d.ts +8 -0
  11. package/dist/cli/version.d.ts.map +1 -0
  12. package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
  13. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  14. package/dist/workspace/composites.d.ts +13 -0
  15. package/dist/workspace/composites.d.ts.map +1 -1
  16. package/dist/workspace/environments.d.ts +80 -0
  17. package/dist/workspace/environments.d.ts.map +1 -0
  18. package/dist/workspace/reason-codes.d.ts +14 -5
  19. package/dist/workspace/reason-codes.d.ts.map +1 -1
  20. package/dist/workspace/records-cli.d.ts +6 -2
  21. package/dist/workspace/records-cli.d.ts.map +1 -1
  22. package/dist/workspace/records-close.d.ts +41 -0
  23. package/dist/workspace/records-close.d.ts.map +1 -0
  24. package/dist/workspace/records-since.d.ts +40 -1
  25. package/dist/workspace/records-since.d.ts.map +1 -1
  26. package/dist/workspace/records-write.d.ts +109 -11
  27. package/dist/workspace/records-write.d.ts.map +1 -1
  28. package/dist/workspace/records.d.ts +40 -12
  29. package/dist/workspace/records.d.ts.map +1 -1
  30. package/dist/workspace/runtimes.d.ts +6 -0
  31. package/dist/workspace/runtimes.d.ts.map +1 -1
  32. package/dist/workspace/session-kinds.d.ts +28 -0
  33. package/dist/workspace/session-kinds.d.ts.map +1 -0
  34. package/dist/workspace/status.d.ts +2 -0
  35. package/dist/workspace/status.d.ts.map +1 -1
  36. package/dist/workspace/trust/seal.d.ts +42 -0
  37. package/dist/workspace/trust/seal.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/handlers/misc.ts +1 -9
  40. package/src/cli/handlers/serve.ts +10 -1
  41. package/src/cli/main.test.ts +7 -0
  42. package/src/cli/main.ts +60 -9
  43. package/src/cli/mcp/server.test.ts +14 -1
  44. package/src/cli/mcp/server.ts +13 -2
  45. package/src/cli/mcp/workspace-plugins.ts +123 -0
  46. package/src/cli/registry.ts +6 -1
  47. package/src/cli/serve-mcp-workspace.test.ts +142 -0
  48. package/src/cli/version.ts +15 -0
  49. package/src/workspace/__fixtures__/sessions.ts +41 -0
  50. package/src/workspace/composites.schema.json +68 -3
  51. package/src/workspace/composites.test.ts +119 -5
  52. package/src/workspace/composites.ts +26 -7
  53. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +11 -0
  54. package/src/workspace/environments.ts +165 -0
  55. package/src/workspace/read-contract.test.ts +3 -0
  56. package/src/workspace/reason-codes.test.ts +8 -3
  57. package/src/workspace/reason-codes.ts +18 -6
  58. package/src/workspace/record-assets.test.ts +4 -3
  59. package/src/workspace/records-amend.schema.json +30 -1
  60. package/src/workspace/records-cli.ts +44 -7
  61. package/src/workspace/records-close.schema.json +192 -0
  62. package/src/workspace/records-close.ts +129 -0
  63. package/src/workspace/records-contract.test.ts +4 -3
  64. package/src/workspace/records-new.schema.json +25 -0
  65. package/src/workspace/records-review.schema.json +55 -2
  66. package/src/workspace/records-sessions-write.test.ts +274 -0
  67. package/src/workspace/records-since.schema.json +27 -2
  68. package/src/workspace/records-since.ts +120 -6
  69. package/src/workspace/records-write-contract.test.ts +5 -1
  70. package/src/workspace/records-write.test.ts +4 -2
  71. package/src/workspace/records-write.ts +307 -43
  72. package/src/workspace/records.schema.json +22 -3
  73. package/src/workspace/records.ts +73 -22
  74. package/src/workspace/runtimes.ts +12 -3
  75. package/src/workspace/session-kinds.ts +79 -0
  76. package/src/workspace/status.ts +1 -1
  77. package/src/workspace/trust/record-seal.test.ts +315 -0
  78. package/src/workspace/trust/seal.test.ts +4 -14
  79. package/src/workspace/trust/seal.ts +119 -25
@@ -87,11 +87,13 @@ export const REASONS = {
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
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.",
90
+ // A seal that is not attested (records): a verdict's (#2687) or a record's author seal (#2688). Each reports one of these in its attestation, unless its seal verified.
91
+ "seal-missing": "The verdict, or the record, carries no seal.",
92
+ "seal-signer-unlisted": "The reviewer, or the record's author, 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 author, or its signature does not verify over the verdict or record.",
94
94
  "seal-unverifiable": "Nothing here can say whose seal it is: there is no signers file at base, or ssh-keygen is not installed.",
95
+ // A record whose author seal is not attested under a signers file at base (records, #2688). A warning: the record is still read.
96
+ "record-unattested": "A signers file is active at base, and the record names an author whose seal does not verify: it has none, the author has no key in the file, or the signature fails.",
95
97
  // A records read that fails (records).
96
98
  "kind-unreadable": "The record kind file is missing or could not be imported.",
97
99
  "kind-invalid": "The record kind file exports no recordKind, or its shape is wrong.",
@@ -112,8 +114,14 @@ export const REASONS = {
112
114
  "review-unsupported": "The kind's schema has no reviews field, so its records take no review.",
113
115
  "review-note-required": "A dissent was given with no note: a dissent needs a reason.",
114
116
  "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.",
117
+ "record-sign-failed": "--sign was given and no author seal could be made: the record names no author, the key can't be read or used, git names no ssh signing key, or ssh-keygen is not installed.",
118
+ // A review given in a session (records review --session, #2693).
119
+ "session-unknown": "--session names no session of a session kind whose subjects are the record's kind.",
120
+ "session-not-open": "--session names a session in a closed state, which takes no more verdicts.",
115
121
  // records --since that fails (#2673).
116
- "since-rev-unknown": "--since names no commit.",
122
+ "since-rev-unknown": "--since names no commit, or a session with no opening revision and no commit that added it.",
123
+ "since-session-unknown": "--since has the shape of a session id and names no commit, and no session the kind or the declaration reads has that id.",
124
+ "since-session-open": "--since names a session that is still open, so the comparison runs to the working tree.",
117
125
  // The intent graph (graph --intent, #2651): a read that fails.
118
126
  "intent-region-invalid": "The region's path, or its line range, does not exist in the tree read.",
119
127
  // The intent graph: part of the walk that can't be read. The document is still printed.
@@ -142,8 +150,12 @@ export const REASONS = {
142
150
  "composites-none-declared": "The members read declare no composite instance.",
143
151
  "composites-no-component": "The members read declare no component, so no composite instance has one.",
144
152
  // The runtimes a member's components can deploy on (graph --composites, #2674).
145
- "runtimes-config-unreadable": "The member's chant.config.ts could not be read, so only the built-in local runtime is listed.",
153
+ "runtimes-config-unreadable": "The member's chant.config.ts could not be read, so only the built-in local runtime is listed, and no environment from the config.",
146
154
  "runtimes-lexicon-unreadable": "A lexicon the member's config lists could not be loaded, so it is not listed as a runtime.",
155
+ // The environments a member's components may deploy to (graph --composites, #2695).
156
+ "environments-none-declared": "The member's chant.config.ts declares no environments, so only local and the environments in its ledger are listed.",
157
+ "environments-ledger-undeclared": "The member's ledger has releases in an environment its config's environments don't cover, so chant run --env would refuse it and it is not listed.",
158
+ "environments-ledger-unreadable": "The chant/lifecycle branch exists and the member's ledger environments could not be listed.",
147
159
  // The lineage lock (check).
148
160
  "lock-invalid": "The lineage lock can't be read.",
149
161
  "manual-step-open": "A scope in the lineage lock has an open manual step.",
@@ -16,7 +16,7 @@ import { cleanScratch, commitAll, contract, declaration, repo, REPO } from "./__
16
16
  import { runChecks } from "./lineage-check";
17
17
  import { constraintCovers, isWorkspacePath, memberHolding, WORKSPACE_PATH_PATTERN } from "./record-assets";
18
18
  import { pinFile, queryRecords, type RecordsDocument } from "./records-cli";
19
- import { parseFrontMatter, RECORD_WARNING_CODES } from "./records";
19
+ import { parseFrontMatter, RECORD_WARNING_CODES, SEAL_WARNING_CODES } from "./records";
20
20
  import { WORK_WARNING_CODES } from "./work";
21
21
  import { workspaceGraph } from "./graph-cli";
22
22
  import graphSchema from "./graph.schema.json";
@@ -169,8 +169,9 @@ 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 (#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]);
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
+ // and records adds record-unattested for an author seal under a signers file at base (#2688).
174
+ expect(recordsSchema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES, ...SEAL_WARNING_CODES]);
174
175
  });
175
176
 
176
177
  test("records pin <path> prints the entry's path from the workspace root and the file's hash", () => {
@@ -67,7 +67,35 @@
67
67
  "items": {
68
68
  "type": "string"
69
69
  },
70
- "description": "The top-level fields whose value differs from the record as it was, in the record's order. Empty when the fields set change nothing, and then nothing is written."
70
+ "description": "The top-level fields whose value differs from the record as it was, in the record's order, with seal last when the author seal was written or removed (#2688). Empty when the fields set change nothing, and then nothing is written."
71
+ },
72
+ "seal": {
73
+ "type": "object",
74
+ "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.",
75
+ "required": [
76
+ "signer",
77
+ "key",
78
+ "signature"
79
+ ],
80
+ "properties": {
81
+ "signer": {
82
+ "type": "string",
83
+ "description": "The record's author, as its reviews.decider field names them."
84
+ },
85
+ "key": {
86
+ "type": "string",
87
+ "pattern": "^SHA256:[A-Za-z0-9+/]+=*$",
88
+ "description": "The fingerprint of the key that signed, read back from the signature."
89
+ },
90
+ "signature": {
91
+ "type": "string",
92
+ "description": "The armored ssh signature."
93
+ }
94
+ }
95
+ },
96
+ "sealDropped": {
97
+ "type": "string",
98
+ "description": "Added in contract 1 by #2688. Present when the record carried an author seal, the amendment moved its digest and --sign was not given: the seal no longer covers the text, so it was removed, and this says so. changed then lists seal."
71
99
  },
72
100
  "dryRun": {
73
101
  "type": "boolean",
@@ -145,6 +173,7 @@
145
173
  "amend-id-immutable",
146
174
  "record-closed",
147
175
  "amend-supersede-instead",
176
+ "record-sign-failed",
148
177
  "record-unparseable",
149
178
  "record-schema-invalid",
150
179
  "record-id-duplicate",
@@ -34,6 +34,7 @@ import {
34
34
  loadRecordKind,
35
35
  normalisePrincipal,
36
36
  readRecords,
37
+ RECORD_SEAL_FIELD,
37
38
  RecordReadError,
38
39
  type LoadedRecordKind,
39
40
  type Quorum,
@@ -43,11 +44,14 @@ import {
43
44
  type RecordFormat,
44
45
  type RecordHistory,
45
46
  type SealInput,
47
+ type VerdictAttestation,
46
48
  } from "./records";
47
49
  import { gitTree, workingTree, type WorkspaceTree } from "./tree";
48
50
  import type { DecisionWork } from "./work";
49
51
  import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
50
52
  import { policyAtBase, recordProvenance, resolveBase, type BaseSource, type RecordProvenance } from "./trust/provenance";
53
+ import type { TrustPolicy } from "./trust/policy";
54
+ import type { SealedRecord } from "./trust/seal";
51
55
 
52
56
  /** The version of the `records` output this chant writes. */
53
57
  export const RECORDS_CONTRACT_VERSION = 1;
@@ -56,7 +60,7 @@ export const RECORDS_CONTRACT_VERSION = 1;
56
60
  export const RECORDS_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json";
57
61
 
58
62
  const USAGE =
59
- "chant workspace records [--kind <kind file>] [--current] [--at <rev>] [--base <rev>] [--require attested] [--json] | chant workspace records [--kind <kind file>] --since <rev> [--at <rev>] [--json] | chant workspace records pin <path> | chant workspace records new|amend|review (#2670)";
63
+ "chant workspace records [--kind <kind file>] [--current] [--at <rev>] [--base <rev>] [--require attested] [--json] | chant workspace records [--kind <kind file>] --since <rev|session id> [--at <rev>] [--json] | chant workspace records pin <path> | chant workspace records new|amend|review|close (#2670, #2693)";
60
64
 
61
65
  /** Exit code when the read worked and a record falls below `--require`. */
62
66
  export const EXIT_BELOW_REQUIRED = 2;
@@ -79,9 +83,16 @@ export interface RecordsQuery {
79
83
 
80
84
  /**
81
85
  * A record as the output carries it: the entry plus its provenance (#2547),
82
- * and its quorum when the kind has a reviews list (#2671).
86
+ * and, when the kind has a reviews list, its quorum (#2671) and what its
87
+ * author seal establishes (#2688).
83
88
  */
84
- export type RecordView = RecordEntry & { provenance: RecordProvenance; quorum?: Quorum | null };
89
+ export type RecordView = RecordEntry & {
90
+ provenance: RecordProvenance;
91
+ quorum?: Quorum | null;
92
+ /** true when the record's seal verifies for its author against the signers at base; false when it fails, or is missing under an active policy; null when nothing here can say (#2688). */
93
+ attested?: boolean | null;
94
+ attestation?: VerdictAttestation;
95
+ };
85
96
 
86
97
  /** The role in the trust policy whose holders' verdicts the quorum does not count (#2671). */
87
98
  export const AGENT_ROLE = "agent";
@@ -276,7 +287,8 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
276
287
  // The quorum: the need from the declaration in the tree read, agents,
277
288
  // whether verdicts need a seal, and the keys a seal verifies against,
278
289
  // all from the policy at base (#2671, #2687).
279
- const checkVerdictSeal = loaded.kind.reviews ? (await import("./trust/seal")).checkVerdictSeal : undefined;
290
+ const seals = loaded.kind.reviews ? await import("./trust/seal") : undefined;
291
+ const checkVerdictSeal = seals?.checkVerdictSeal;
280
292
  const quorumOptions = checkVerdictSeal
281
293
  ? {
282
294
  ...declaredQuorum(tree),
@@ -289,6 +301,7 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
289
301
  ...r,
290
302
  provenance: provenance.get(r.path)!,
291
303
  ...(quorumOptions ? { quorum: computeQuorum(loaded.kind, r, quorumOptions) } : {}),
304
+ ...(seals ? authorSeal(loaded.kind, r, policy, seals.checkRecordSeal) : {}),
292
305
  }));
293
306
  if (loaded.kind.work && query.workGaps !== false && top) await raiseWorkGaps(loaded, records, { root, workspaceRoot, at: query.at });
294
307
  return {
@@ -314,6 +327,29 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
314
327
  }
315
328
  }
316
329
 
330
+ /**
331
+ * A record's author seal checked against the policy at base (#2688), for a
332
+ * kind with a reviews list: its author is the kind's `reviews.decider` field.
333
+ * Under an active signers file, a record that names an author and is not
334
+ * attested gains the warning `record-unattested`, and is still read: sealing
335
+ * records is opt-in for now. A record that can't be parsed gets neither field.
336
+ */
337
+ function authorSeal(
338
+ kind: LoadedRecordKind["kind"],
339
+ r: RecordEntry,
340
+ policy: TrustPolicy,
341
+ check: (policy: TrustPolicy, r: SealedRecord) => { attested: boolean | null } & VerdictAttestation,
342
+ ): { attested?: boolean | null; attestation?: VerdictAttestation } {
343
+ if (r.data === null) return {};
344
+ const field = kind.reviews!.decider;
345
+ const author = typeof r.data[field] === "string" && (r.data[field] as string).trim() !== "" ? (r.data[field] as string) : null;
346
+ const { attested, ...attestation } = check(policy, { record: r.id, digest: r.digest, author, authorField: field, state: r.state, seal: r.data[RECORD_SEAL_FIELD] });
347
+ if (policy.active && author !== null && attested !== true) {
348
+ r.warnings.push({ code: "record-unattested", message: `an attestation policy is active at base, and ${attestation.message}` });
349
+ }
350
+ return { attested, attestation };
351
+ }
352
+
317
353
  /**
318
354
  * `work-done-gap-open` on a records read (#2686): for each done work record
319
355
  * whose `source` names a finding and a region, walk that region with the
@@ -383,7 +419,7 @@ export function pinFile(file: string, cwd: string): { path: string; sha256: stri
383
419
 
384
420
  export async function runWorkspaceRecords(ctx: CommandContext): Promise<number> {
385
421
  const { args } = ctx;
386
- if (args.extraPositional === "new" || args.extraPositional === "amend" || args.extraPositional === "review") {
422
+ if (args.extraPositional === "new" || args.extraPositional === "amend" || args.extraPositional === "review" || args.extraPositional === "close") {
387
423
  return (await import("./records-write")).runRecordsWrite(ctx);
388
424
  }
389
425
  if (args.extraPositional === "pin") {
@@ -400,7 +436,7 @@ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number>
400
436
  return 0;
401
437
  }
402
438
  if (args.extraPositional) {
403
- console.error(formatError({ message: `chant workspace records takes no argument but pin, new, amend or review (got ${args.extraPositional})`, hint: USAGE }));
439
+ console.error(formatError({ message: `chant workspace records takes no argument but pin, new, amend, review or close (got ${args.extraPositional})`, hint: USAGE }));
404
440
  return 1;
405
441
  }
406
442
  if (!args.kind) return runDeclaredRecords(args);
@@ -555,7 +591,8 @@ function formatRecords(records: RecordView[], summary: { total: number; valid: n
555
591
  const flag = r.valid ? "" : " INVALID";
556
592
  const superseded = r.supersededBy ? ` superseded by ${r.supersededBy}` : "";
557
593
  const attested = r.provenance.level === "attested" ? ` attested by ${r.provenance.principal}` : "";
558
- lines.push(`${(r.id ?? "-").padEnd(idWidth)} ${(r.state ?? "-").padEnd(stateWidth)} ${title}${superseded}${attested}${flag}`);
594
+ const sealed = r.attested === true ? ` sealed by ${(r.data?.[RECORD_SEAL_FIELD] as { signer: string }).signer}` : "";
595
+ lines.push(`${(r.id ?? "-").padEnd(idWidth)} ${(r.state ?? "-").padEnd(stateWidth)} ${title}${superseded}${attested}${sealed}${flag}`);
559
596
  if (r.ready !== undefined) {
560
597
  const blocked = (r.blockedBy ?? []).map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ");
561
598
  const implemented = (r.implements ?? []).map((d) => `${d.id} (${d.state ?? "unknown"})`).join(", ");
@@ -0,0 +1,192 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://intentius.io/chant/schemas/workspace/records-close/v1/records-close.schema.json",
4
+ "title": "chant workspace records close output",
5
+ "description": "What `chant workspace records close <session id> [--kind <session kind file>]` prints (#2693): the session's path and id, the fields the close set (the state, and the close time and closing revision when the session kind names fields for them), the seal written and the commit the session closed at, 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.88.0 and newer; an older chant refuses the close verb.",
6
+ "oneOf": [
7
+ {
8
+ "$ref": "#/$defs/result"
9
+ },
10
+ {
11
+ "$ref": "#/$defs/failure"
12
+ }
13
+ ],
14
+ "$defs": {
15
+ "result": {
16
+ "description": "The record was written, or would be with --dry-run. Exit code 0.",
17
+ "type": "object",
18
+ "required": [
19
+ "$schema",
20
+ "contract",
21
+ "kind",
22
+ "path",
23
+ "id",
24
+ "changed",
25
+ "seal",
26
+ "closedRev",
27
+ "dryRun",
28
+ "warnings"
29
+ ],
30
+ "properties": {
31
+ "$schema": {
32
+ "const": "https://intentius.io/chant/schemas/workspace/records-close/v1/records-close.schema.json"
33
+ },
34
+ "contract": {
35
+ "const": 1
36
+ },
37
+ "kind": {
38
+ "type": "object",
39
+ "required": [
40
+ "name",
41
+ "schema",
42
+ "file"
43
+ ],
44
+ "properties": {
45
+ "name": {
46
+ "type": "string",
47
+ "description": "The kind's name, such as \"session\"."
48
+ },
49
+ "schema": {
50
+ "type": "string",
51
+ "description": "The `$id` of the schema the record was validated against."
52
+ },
53
+ "file": {
54
+ "type": "string",
55
+ "description": "The kind file, relative to the repository root, with / separators."
56
+ }
57
+ }
58
+ },
59
+ "path": {
60
+ "type": "string",
61
+ "description": "The record file, relative to the repository root (the working directory outside git), with / separators."
62
+ },
63
+ "id": {
64
+ "type": "string",
65
+ "description": "The record's id."
66
+ },
67
+ "changed": {
68
+ "type": "array",
69
+ "items": {
70
+ "type": "string"
71
+ },
72
+ "description": "The top-level fields whose value differs from the session as it was: its state, the close time, the closing revision and the seal, and the opening revision when it was null and the repository now has a commit."
73
+ },
74
+ "seal": {
75
+ "type": "object",
76
+ "required": [
77
+ "field",
78
+ "digest"
79
+ ],
80
+ "properties": {
81
+ "field": {
82
+ "type": "string",
83
+ "description": "The seal field, as the session kind's session.seal names it, such as closed_digest."
84
+ },
85
+ "digest": {
86
+ "type": "string",
87
+ "pattern": "^[0-9a-f]{64}$",
88
+ "description": "The seal written: the lowercase hex SHA-256 of the file with LF line endings and without its seal line, which records checks on every read (session-seal-mismatch)."
89
+ }
90
+ }
91
+ },
92
+ "closedRev": {
93
+ "type": [
94
+ "string",
95
+ "null"
96
+ ],
97
+ "pattern": "^[0-9a-f]{40,64}$",
98
+ "description": "The full commit id HEAD named at the close, or null outside git or before the first commit. It is written to the field session.closedRev names, when the kind names one. The caller commits the close, so the commit that carries it comes after this one."
99
+ },
100
+ "dryRun": {
101
+ "type": "boolean",
102
+ "description": "True when --dry-run was given and nothing was written."
103
+ },
104
+ "warnings": {
105
+ "type": "array",
106
+ "description": "The written record's warnings, as chant workspace records reports them. A warning never refuses a write.",
107
+ "items": {
108
+ "$ref": "#/$defs/warning"
109
+ }
110
+ },
111
+ "text": {
112
+ "type": "string",
113
+ "description": "With --dry-run only: the whole text of the file the command would write."
114
+ },
115
+ "error": false
116
+ }
117
+ },
118
+ "warning": {
119
+ "type": "object",
120
+ "required": [
121
+ "code",
122
+ "message"
123
+ ],
124
+ "properties": {
125
+ "code": {
126
+ "enum": [
127
+ "asset-drift",
128
+ "asset-missing",
129
+ "asset-stale",
130
+ "record-supersedes-pending",
131
+ "record-no-evidence",
132
+ "review-undigested"
133
+ ]
134
+ },
135
+ "message": {
136
+ "type": "string"
137
+ }
138
+ }
139
+ },
140
+ "failure": {
141
+ "description": "Nothing was written. Exit code 1.",
142
+ "type": "object",
143
+ "required": [
144
+ "$schema",
145
+ "contract",
146
+ "error"
147
+ ],
148
+ "properties": {
149
+ "$schema": {
150
+ "const": "https://intentius.io/chant/schemas/workspace/records-close/v1/records-close.schema.json"
151
+ },
152
+ "contract": {
153
+ "const": 1
154
+ },
155
+ "error": {
156
+ "type": "object",
157
+ "required": [
158
+ "code",
159
+ "message"
160
+ ],
161
+ "properties": {
162
+ "code": {
163
+ "enum": [
164
+ "kind-unreadable",
165
+ "kind-invalid",
166
+ "schema-unreadable",
167
+ "schema-id-mismatch",
168
+ "schema-invalid",
169
+ "location-missing",
170
+ "write-usage-invalid",
171
+ "record-not-found",
172
+ "record-closed",
173
+ "record-unparseable",
174
+ "record-schema-invalid",
175
+ "record-id-duplicate",
176
+ "record-supersedes-unknown",
177
+ "record-supersedes-conflict",
178
+ "session-seal-mismatch",
179
+ "session-verdict-unknown-record"
180
+ ]
181
+ },
182
+ "message": {
183
+ "type": "string",
184
+ "description": "What was wrong, and what to do instead when there is something. A session already closed is record-closed; a verdict naming a record the session kind's subjects lack is session-verdict-unknown-record."
185
+ }
186
+ }
187
+ },
188
+ "path": false
189
+ }
190
+ }
191
+ }
192
+ }
@@ -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",