@intentius/chant 0.94.0 → 0.95.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 (110) hide show
  1. package/dist/cli/handlers/operator.d.ts.map +1 -1
  2. package/dist/cli/handlers/run.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +2 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/op/builders.d.ts +14 -3
  7. package/dist/op/builders.d.ts.map +1 -1
  8. package/dist/op/index.d.ts +6 -3
  9. package/dist/op/index.d.ts.map +1 -1
  10. package/dist/op/operator.d.ts +90 -0
  11. package/dist/op/operator.d.ts.map +1 -1
  12. package/dist/op/steward-beside.d.ts +84 -0
  13. package/dist/op/steward-beside.d.ts.map +1 -0
  14. package/dist/op/steward.d.ts +87 -2
  15. package/dist/op/steward.d.ts.map +1 -1
  16. package/dist/workspace/box-services.d.ts +31 -0
  17. package/dist/workspace/box-services.d.ts.map +1 -0
  18. package/dist/workspace/compose-graph.d.ts +11 -0
  19. package/dist/workspace/compose-graph.d.ts.map +1 -1
  20. package/dist/workspace/composites.d.ts +5 -1
  21. package/dist/workspace/composites.d.ts.map +1 -1
  22. package/dist/workspace/declaration.d.ts +37 -0
  23. package/dist/workspace/declaration.d.ts.map +1 -1
  24. package/dist/workspace/declaration.schema.json +57 -1
  25. package/dist/workspace/graph-cache.d.ts +168 -0
  26. package/dist/workspace/graph-cache.d.ts.map +1 -0
  27. package/dist/workspace/graph-cli.d.ts +11 -5
  28. package/dist/workspace/graph-cli.d.ts.map +1 -1
  29. package/dist/workspace/kind-readers.d.ts +39 -0
  30. package/dist/workspace/kind-readers.d.ts.map +1 -0
  31. package/dist/workspace/kinds.d.ts +29 -0
  32. package/dist/workspace/kinds.d.ts.map +1 -1
  33. package/dist/workspace/member-commands.d.ts +15 -1
  34. package/dist/workspace/member-commands.d.ts.map +1 -1
  35. package/dist/workspace/member-run.d.ts +2 -0
  36. package/dist/workspace/member-run.d.ts.map +1 -1
  37. package/dist/workspace/reason-codes.d.ts +3 -1
  38. package/dist/workspace/reason-codes.d.ts.map +1 -1
  39. package/dist/workspace/records-cli.d.ts +10 -1
  40. package/dist/workspace/records-cli.d.ts.map +1 -1
  41. package/dist/workspace/records-write.d.ts +4 -2
  42. package/dist/workspace/records-write.d.ts.map +1 -1
  43. package/dist/workspace/records.d.ts +8 -3
  44. package/dist/workspace/records.d.ts.map +1 -1
  45. package/dist/workspace/status-stewards.d.ts +23 -7
  46. package/dist/workspace/status-stewards.d.ts.map +1 -1
  47. package/dist/workspace/status.d.ts +12 -0
  48. package/dist/workspace/status.d.ts.map +1 -1
  49. package/dist/workspace/work-evidence.d.ts +1 -1
  50. package/dist/workspace/work-evidence.d.ts.map +1 -1
  51. package/dist/workspace/workspace-kinds.schema.json +26 -0
  52. package/package.json +1 -1
  53. package/src/cli/commands/carve-bridge.test.ts +7 -3
  54. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
  55. package/src/cli/handlers/operator.ts +53 -11
  56. package/src/cli/handlers/run.test.ts +71 -0
  57. package/src/cli/handlers/run.ts +65 -7
  58. package/src/cli/main.ts +17 -6
  59. package/src/cli/mcp/workspace-tools.ts +1 -1
  60. package/src/cli/registry.ts +2 -0
  61. package/src/cli/static-config-read.test.ts +8 -2
  62. package/src/meta/source-is-text.test.ts +21 -3
  63. package/src/okf.test.ts +6 -1
  64. package/src/op/builders.ts +14 -3
  65. package/src/op/index.ts +8 -2
  66. package/src/op/operator.ts +264 -16
  67. package/src/op/steward-beside.test.ts +267 -0
  68. package/src/op/steward-beside.ts +219 -0
  69. package/src/op/steward-points.test.ts +61 -1
  70. package/src/op/steward.ts +135 -3
  71. package/src/workspace/box-services.test.ts +129 -0
  72. package/src/workspace/box-services.ts +51 -0
  73. package/src/workspace/checks/boxes.test.ts +1 -0
  74. package/src/workspace/compose-graph.test.ts +1 -0
  75. package/src/workspace/compose-graph.ts +11 -0
  76. package/src/workspace/composites.test.ts +1 -1
  77. package/src/workspace/composites.ts +12 -5
  78. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
  79. package/src/workspace/declaration.schema.json +57 -1
  80. package/src/workspace/declaration.ts +104 -0
  81. package/src/workspace/graph-cache.test.ts +343 -0
  82. package/src/workspace/graph-cache.ts +409 -0
  83. package/src/workspace/graph-cli.ts +93 -26
  84. package/src/workspace/graph-contract.test.ts +129 -6
  85. package/src/workspace/graph.schema.json +21 -1
  86. package/src/workspace/kind-readers.e2e.test.ts +68 -0
  87. package/src/workspace/kind-readers.test.ts +134 -0
  88. package/src/workspace/kind-readers.ts +111 -0
  89. package/src/workspace/kinds.test.ts +49 -0
  90. package/src/workspace/kinds.ts +59 -2
  91. package/src/workspace/member-commands.test.ts +15 -0
  92. package/src/workspace/member-commands.ts +47 -7
  93. package/src/workspace/member-run.ts +10 -2
  94. package/src/workspace/reason-codes.ts +3 -1
  95. package/src/workspace/records-amend.schema.json +1 -0
  96. package/src/workspace/records-cli.ts +20 -12
  97. package/src/workspace/records-contract.test.ts +2 -1
  98. package/src/workspace/records-new.schema.json +1 -0
  99. package/src/workspace/records-quorum.test.ts +10 -3
  100. package/src/workspace/records-sessions-write.test.ts +2 -1
  101. package/src/workspace/records-write.test.ts +101 -1
  102. package/src/workspace/records-write.ts +50 -6
  103. package/src/workspace/records.ts +22 -5
  104. package/src/workspace/status-contract.test.ts +31 -1
  105. package/src/workspace/status-stewards.ts +39 -6
  106. package/src/workspace/status.schema.json +49 -4
  107. package/src/workspace/status.ts +22 -0
  108. package/src/workspace/trust/record-seal.test.ts +26 -2
  109. package/src/workspace/work-evidence.schema.json +1 -0
  110. package/src/workspace/workspace-kinds.schema.json +26 -0
@@ -67,6 +67,16 @@ export type KindOutputs =
67
67
  /** The names listed here, plus any the member entry lists in `outputs` (`other`, and kinds from a package). */
68
68
  | { from: "declared"; names: string[] };
69
69
 
70
+ /**
71
+ * How `chant workspace graph` reads a member of a package's kind (#2874): a
72
+ * lexicon, by name, and that lexicon's config namespace for a reader project
73
+ * chant writes itself. See {@link substituteGraphConfig} for the placeholders.
74
+ */
75
+ export interface KindGraph {
76
+ lexicon: string;
77
+ config: Record<string, unknown>;
78
+ }
79
+
70
80
  export interface MemberKind {
71
81
  name: string;
72
82
  /** One line for listings and error messages. */
@@ -84,6 +94,10 @@ export interface MemberKind {
84
94
  outputs: KindOutputs;
85
95
  /** Where the kind comes from: `builtin`, or the package that supplies it. */
86
96
  source: string;
97
+ /** The supplying package's directory, absolute; unset for a built-in kind. */
98
+ packageDir?: string;
99
+ /** How `workspace graph` reads a member of this kind (#2874); unset, the member is `kind-not-run`. */
100
+ graph?: KindGraph;
87
101
  }
88
102
 
89
103
  export interface KindRegistry {
@@ -310,7 +324,7 @@ export function parseKindData(text: string, source: string): KindData {
310
324
  const problems: string[] = [];
311
325
  const kinds: MemberKind[] = [];
312
326
  const seen = new Set<string>();
313
- for (const k of (raw as { kinds: { name: string; description: string; precedence: number; probe: FileProbe; outputs?: string[] }[] }).kinds) {
327
+ for (const k of (raw as { kinds: { name: string; description: string; precedence: number; probe: FileProbe; outputs?: string[]; graph?: KindGraph }[] }).kinds) {
314
328
  if (BUILTIN_KIND_NAMES.includes(k.name)) {
315
329
  problems.push(`${source}: kind ${k.name} is built in and can't be supplied by a package`);
316
330
  continue;
@@ -331,6 +345,7 @@ export function parseKindData(text: string, source: string): KindData {
331
345
  shape: "member",
332
346
  outputs: { from: "declared", names: [...(k.outputs ?? [])] },
333
347
  source,
348
+ ...(k.graph ? { graph: { lexicon: k.graph.lexicon, config: structuredClone(k.graph.config) } } : {}),
334
349
  });
335
350
  }
336
351
  return { kinds, problems };
@@ -389,7 +404,49 @@ export function readPackageKinds(packageDir: string, source?: string): PackageKi
389
404
  } catch {
390
405
  return { kinds: [], problems: [`${name}: exports["${KINDS_SUBPATH}"] names ${target}, which does not exist`], file };
391
406
  }
392
- return { ...parseKindData(text, name), file };
407
+ const data = parseKindData(text, name);
408
+ const packageName = typeof pkg.name === "string" ? pkg.name : undefined;
409
+ for (const k of data.kinds) {
410
+ k.packageDir = packageDir;
411
+ if (k.graph && packageName !== lexiconPackageName(k.graph.lexicon)) {
412
+ data.problems.push(
413
+ `${name}: kind ${k.name} reads its members with lexicon ${k.graph.lexicon}, which is ${lexiconPackageName(k.graph.lexicon)}, ` +
414
+ `and this package is ${packageName ?? "unnamed"}; the kind is kept, and workspace graph lists its members with kind-not-run`,
415
+ );
416
+ delete k.graph;
417
+ }
418
+ }
419
+ return { ...data, file };
420
+ }
421
+
422
+ /** The package a lexicon name resolves to. */
423
+ export function lexiconPackageName(lexicon: string): string {
424
+ return `@intentius/chant-lexicon-${lexicon}`;
425
+ }
426
+
427
+ /** Where each placeholder in a kind's graph config points (#2874). */
428
+ export interface GraphPlaceholders {
429
+ /** The member's name. */
430
+ member: string;
431
+ /** The member's directory, absolute. */
432
+ dir: string;
433
+ /** The workspace root, absolute. */
434
+ workspace: string;
435
+ }
436
+
437
+ /**
438
+ * A kind's graph config with `{member}`, `{dir}` and `{workspace}` replaced
439
+ * in every string key and value. Other braces are left as they are.
440
+ */
441
+ export function substituteGraphConfig(config: Record<string, unknown>, at: GraphPlaceholders): Record<string, unknown> {
442
+ const text = (v: string) => v.replace(/\{(member|dir|workspace)\}/g, (_, key: keyof GraphPlaceholders) => at[key]);
443
+ const walk = (v: unknown): unknown => {
444
+ if (typeof v === "string") return text(v);
445
+ if (Array.isArray(v)) return v.map(walk);
446
+ if (v !== null && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [text(k), walk(x)]));
447
+ return v;
448
+ };
449
+ return walk(config) as Record<string, unknown>;
393
450
  }
394
451
 
395
452
  /**
@@ -156,6 +156,21 @@ describe("member command lines", () => {
156
156
  expect(memberArgv("audit", unit, args({ failOn: "error" }))).toEqual(["audit", ".", "--format", "json", "--fail-on", "error"]);
157
157
  expect(memberArgv("graph", unit, args({ format: "mermaid" }))).toEqual(["graph", ".", "--format", "ir"]);
158
158
  });
159
+
160
+ test("graph hands each member the live read's flags as they are (#2875)", () => {
161
+ expect(memberArgv("graph", unit, args({ env: "prod", live: true, overlay: true, traffic: "100 rps, p50" }))).toEqual([
162
+ "graph", ".", "--format", "ir", "--env", "prod", "--live", "--overlay", "--traffic", "100 rps, p50",
163
+ ]);
164
+ expect(memberArgv("graph", unit, args({ env: "prod" }))).toEqual(["graph", ".", "--format", "ir", "--env", "prod"]);
165
+ });
166
+
167
+ test("a reader unit takes --env only with --live, which needs one (#2874, #2875)", () => {
168
+ const reader = { ...unit, reader: { lexicon: "terraform", config: {}, packageDir: "/p" } } as typeof unit;
169
+ expect(memberArgv("graph", reader, args({ env: "prod" }))).toEqual(["graph", ".", "--format", "ir"]);
170
+ expect(memberArgv("graph", reader, args({ env: "prod", live: true, overlay: true }))).toEqual([
171
+ "graph", ".", "--format", "ir", "--env", "prod", "--live", "--overlay",
172
+ ]);
173
+ });
159
174
  });
160
175
 
161
176
  describe("the member-run protocol", () => {
@@ -9,6 +9,10 @@
9
9
  * Members of kind `other` are never read, and a nested `workspace` member
10
10
  * runs its own workspace commands, so both are listed as skipped with the
11
11
  * reason code `kind-not-run`.
12
+ * - For `graph` only, members of a pinned package's kind whose kinds file
13
+ * says how to read them (#2874, a `graph` block): each runs `chant graph`
14
+ * in a reader project chant writes for it (`kind-readers.ts`). Other
15
+ * package kinds are `kind-not-run`, as before.
12
16
  * - For `build` and `lint` only, every project an example group matches
13
17
  * (ws-051): groups are built and linted, and have no ledger, audit or place
14
18
  * in the graph. On a large repository this is most of the run, so
@@ -38,7 +42,7 @@ import { findWorkspaceRoot } from "../project-root";
38
42
  import type { ComposedMember, MemberReason } from "./compose-graph";
39
43
  import { mergeAudit, mergeSarif, type MemberOutput } from "./compose-reports";
40
44
  import { readDeclaration, resolveGroups, rootExclusions, WorkspaceReadError, type Declaration } from "./declaration";
41
- import { builtinKindRegistry, type KindRegistry } from "./kinds";
45
+ import { builtinKindRegistry, type KindGraph, type KindRegistry } from "./kinds";
42
46
  import { memberReason } from "./ls";
43
47
  import { MEMBER_RUN_PROTOCOL, PROTOCOL_PREFIX, type MemberRunLine, type MemberRunRequest } from "./member-run";
44
48
  import { workingTree, type WorkspaceTree } from "./tree";
@@ -58,6 +62,12 @@ export interface RunUnit {
58
62
  abs: string;
59
63
  /** Directories (relative to `dir`) left out of discovery; set for member `.`. */
60
64
  exclude: string[];
65
+ /**
66
+ * Set for a member read through its kind's `graph` block (#2874). `abs` is
67
+ * the member's directory until `kind-readers.ts` writes the reader project
68
+ * and points `abs` at it.
69
+ */
70
+ reader?: KindGraph & { packageDir: string };
61
71
  }
62
72
 
63
73
  export interface SkippedEntry {
@@ -189,6 +199,25 @@ export function planMembers(verb: WorkspaceVerb, root: string, options: PlanOpti
189
199
  }
190
200
  continue;
191
201
  }
202
+ const byKind = verb === "graph" && entry.kind !== "chant" ? kinds.get(entry.kind) : undefined;
203
+ if (byKind?.graph && byKind.packageDir) {
204
+ const reason = memberReason(entry, tree, kinds);
205
+ if (reason) {
206
+ unreadable.push({ name: entry.name, dir: entry.dir, kind: entry.kind, reason });
207
+ continue;
208
+ }
209
+ units.push({
210
+ id: entry.name,
211
+ member: entry.name,
212
+ kind: entry.kind,
213
+ group: false,
214
+ dir: entry.dir,
215
+ abs: entry.dir === "." ? root : join(root, entry.dir),
216
+ exclude: [],
217
+ reader: { ...byKind.graph, packageDir: byKind.packageDir },
218
+ });
219
+ continue;
220
+ }
192
221
  if (entry.kind !== "chant") {
193
222
  const why =
194
223
  entry.kind === "workspace"
@@ -265,8 +294,15 @@ export function memberArgv(verb: WorkspaceVerb, unit: RunUnit, args: ParsedArgs)
265
294
  if (args.tier) argv.push("--tier", args.tier);
266
295
  if (args.failOn) argv.push("--fail-on", args.failOn);
267
296
  if (args.maxFiles !== undefined) argv.push("--max-files", String(args.maxFiles));
268
- } else if (args.env) {
269
- argv.push("--env", args.env);
297
+ } else {
298
+ // graph. A reader project declares no environments, so a source read
299
+ // leaves --env to the chant members. A live read needs one, and a reader
300
+ // project with none declared takes any name, so --live carries it (#2875).
301
+ if (args.env && (!unit.reader || args.live)) argv.push("--env", args.env);
302
+ // The live read's flags go to every member as they are (#2875).
303
+ if (args.live) argv.push("--live");
304
+ if (args.overlay) argv.push("--overlay");
305
+ if (args.traffic) argv.push("--traffic", args.traffic);
270
306
  }
271
307
  return argv;
272
308
  }
@@ -286,6 +322,8 @@ export interface UnitResult extends MemberOutput {
286
322
  chant: string | null;
287
323
  /** How the member ran: inside its toolchain's shared process, or in a level-0 process of its own. */
288
324
  mode: "member-run" | "per-member";
325
+ /** When the member's command finished, as an ISO time (#2875). */
326
+ finishedAt?: string;
289
327
  }
290
328
 
291
329
  interface Spawned {
@@ -349,13 +387,15 @@ async function runGroup(verb: WorkspaceVerb, group: ToolchainGroup, root: string
349
387
  units: units.map((u) => ({ id: u.id, dir: u.abs, argv: argvs.get(u.id)!, ...(u.exclude.length ? { exclude: u.exclude } : {}) })),
350
388
  };
351
389
  const answer = await run(toolchain.command, ["workspace", "member-run"], root, JSON.stringify(request));
390
+ const ended = new Date().toISOString();
352
391
  const parsed = parseMemberRunOutput(answer.stdout);
353
392
  if (parsed) {
354
393
  if (parsed.stray) process.stderr.write(`${parsed.stray}\n`);
355
394
  return units.map((unit) => {
356
395
  const r = parsed.results.get(unit.id);
357
396
  const base = { unit, toolchain, chant: parsed.chant, mode: "member-run" as const, id: unit.id, member: unit.member, dir: unit.dir, exclude: unit.exclude };
358
- if (r) return { ...base, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
397
+ // A member-run from before #2875 names no time; the group's end is the closest one.
398
+ if (r) return { ...base, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, finishedAt: r.finishedAt ?? ended };
359
399
  return { ...base, exitCode: answer.exitCode || 1, stdout: "", stderr: `the member-run process for ${toolchain.identity} ended before running this member\n${answer.stderr}` };
360
400
  });
361
401
  }
@@ -363,7 +403,7 @@ async function runGroup(verb: WorkspaceVerb, group: ToolchainGroup, root: string
363
403
  const out: UnitResult[] = [];
364
404
  for (const unit of units) {
365
405
  const r = await run(toolchain.command, argvs.get(unit.id)!, unit.abs);
366
- out.push({ unit, toolchain, chant: null, mode: "per-member", id: unit.id, member: unit.member, dir: unit.dir, exclude: unit.exclude, ...r });
406
+ out.push({ unit, toolchain, chant: null, mode: "per-member", id: unit.id, member: unit.member, dir: unit.dir, exclude: unit.exclude, ...r, finishedAt: new Date().toISOString() });
367
407
  }
368
408
  return out;
369
409
  }
@@ -385,7 +425,7 @@ const USAGE: Record<WorkspaceVerb, string> = {
385
425
  build: "chant workspace build [dir] [--member <name>] [-o <dir>] [--format json|yaml] [--env <env>] [--param k=v] [--dry-run]",
386
426
  lint: "chant workspace lint [dir] [--member <name>] [--format stylish|json|sarif] [-o <file>] [--fix] [--dry-run]",
387
427
  audit: "chant workspace audit [dir] [--member <name>] [--format stylish|json] [-o <file>] [--tier <tier>] [--fail-on <level>] [--dry-run]",
388
- graph: "chant workspace graph [dir] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run]",
428
+ graph: "chant workspace graph [dir] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--live [--overlay] [--traffic <level>]] [--no-cache] [--dry-run]",
389
429
  };
390
430
 
391
431
  export function describePlan(plan: MemberPlan): string {
@@ -461,7 +501,7 @@ function formatAuditText(doc: ReturnType<typeof mergeAudit>, plan: MemberPlan):
461
501
  }
462
502
 
463
503
  export function memberStatus(name: string, dir: string, kind: string, status: ComposedMember["status"], reason: MemberReason | null, chant: string | null): ComposedMember {
464
- return { name, dir, kind, status, reason, chant, irVersion: null };
504
+ return { name, dir, kind, status, reason, chant, irVersion: null, live: false };
465
505
  }
466
506
 
467
507
  export async function runWorkspaceMembers(ctx: CommandContext, verb: WorkspaceVerb): Promise<number> {
@@ -49,7 +49,15 @@ export interface MemberRunUnit {
49
49
 
50
50
  export type MemberRunLine =
51
51
  | { type: "header"; protocol: number; chant: string }
52
- | { type: "result"; id: string; exitCode: number; stdout: string; stderr: string };
52
+ | {
53
+ type: "result";
54
+ id: string;
55
+ exitCode: number;
56
+ stdout: string;
57
+ stderr: string;
58
+ /** When the member's command finished, as an ISO time (#2875). A member-run from before it leaves this out. */
59
+ finishedAt?: string;
60
+ };
53
61
 
54
62
  /** A `process.exit` inside a command, turned into a value. */
55
63
  class ExitRequest extends Error {
@@ -125,7 +133,7 @@ export async function runMemberUnits(request: MemberRunRequest, run: (argv: stri
125
133
  if (startEnv === undefined) delete process.env[ENV_VAR];
126
134
  else process.env[ENV_VAR] = startEnv;
127
135
  process.chdir(startDir);
128
- emit({ type: "result", id: unit.id, ...result });
136
+ emit({ type: "result", id: unit.id, ...result, finishedAt: new Date().toISOString() });
129
137
  }
130
138
  return 0;
131
139
  }
@@ -38,6 +38,7 @@ export const REASONS = {
38
38
  // --at and git.
39
39
  "not-a-git-repository": "--at, or a ledger read, needs a git repository and there is none.",
40
40
  "revision-unknown": "--at names no commit.",
41
+ "live-at-revision": "graph was given --live and --at: a live read is of the account now, not of a revision.",
41
42
  // status.
42
43
  "environment-invalid": "An environment name that can't name a ledger directory.",
43
44
  // A member or group that can't be read (ls, graph).
@@ -47,7 +48,7 @@ export const REASONS = {
47
48
  "no-matches": "An example group matches no directory holding a chant project.",
48
49
  // A member graph leaves out (graph, and the other per-member commands).
49
50
  "kind-not-run": "The member's kind is one the per-member commands don't run, such as other.",
50
- "command-failed": "The member's own command exited with a failure.",
51
+ "command-failed": "The member's own command exited with a failure, or the lexicon that reads the member through its kind's graph block isn't installed where the workspace resolves its pin.",
51
52
  "output-unreadable": "The member's command printed something that isn't the document asked for.",
52
53
  "ir-version-unsupported": "The member's IR has a version this chant can't read.",
53
54
  // A ledger status can't fully read.
@@ -129,6 +130,7 @@ export const REASONS = {
129
130
  "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.",
130
131
  "record-state-not-initial": "A record written through chant serve mcp gives a state other than the kind's first: a new record opens proposed, and a person moves it on.",
131
132
  "source-harvest-not-proposed": "A harvested record (source.via harvest) was written in a state other than the kind's first: a harvest proposes, and a person decides.",
133
+ "ratify-quorum-not-met": "The write puts a record in its kind's ratified state (reviews.ratified), and the record's quorum is not met: too few agreeing verdicts count.",
132
134
  "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.",
133
135
  // A review given in a session (records review --session, #2693).
134
136
  "session-unknown": "--session names no session of a session kind whose subjects are the record's kind.",
@@ -175,6 +175,7 @@
175
175
  "record-closed",
176
176
  "amend-supersede-instead",
177
177
  "record-sign-failed",
178
+ "ratify-quorum-not-met",
178
179
  "record-unparseable",
179
180
  "record-schema-invalid",
180
181
  "record-id-duplicate",
@@ -38,6 +38,7 @@ import {
38
38
  RecordReadError,
39
39
  type LoadedRecordKind,
40
40
  type Quorum,
41
+ type QuorumOptions,
41
42
  type ReadErrorCode,
42
43
  type ReadRecordsResult,
43
44
  type RecordEntry,
@@ -210,6 +211,24 @@ function declaredQuorum(tree: WorkspaceTree): { need: number; needFrom: "declara
210
211
  return { need: DEFAULT_QUORUM, needFrom: "default" };
211
212
  }
212
213
 
214
+ /**
215
+ * The options a kind's quorum is computed with, or undefined when the kind
216
+ * has no reviews list: the need from the declaration in the tree read, and
217
+ * the agents, whether verdicts need a seal, and the keys a seal verifies
218
+ * against, all from the policy at base (#2671, #2687). `records` and the
219
+ * ratified check of `records new` and `amend` (#2873) both use it.
220
+ */
221
+ export async function quorumOptionsFor(loaded: LoadedRecordKind, tree: WorkspaceTree, policy: TrustPolicy): Promise<QuorumOptions | undefined> {
222
+ if (!loaded.kind.reviews) return undefined;
223
+ const { checkVerdictSeal } = await import("./trust/seal");
224
+ return {
225
+ ...declaredQuorum(tree),
226
+ agents: new Set((policy.roles[AGENT_ROLE] ?? []).map(normalisePrincipal)),
227
+ attestation: policy.active,
228
+ verifySeal: (v: SealInput) => checkVerdictSeal(policy, v),
229
+ };
230
+ }
231
+
213
232
  /**
214
233
  * Where a kind's pinned paths resolve: the workspace whose declaration sits
215
234
  * nearest above the kind file, when it is inside the repository, or else the
@@ -300,19 +319,8 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
300
319
  paths: result.records.map((r) => r.path),
301
320
  attestors: policy.active ? await activeAttestors() : [],
302
321
  });
303
- // The quorum: the need from the declaration in the tree read, agents,
304
- // whether verdicts need a seal, and the keys a seal verifies against,
305
- // all from the policy at base (#2671, #2687).
306
322
  const seals = loaded.kind.reviews ? await import("./trust/seal") : undefined;
307
- const checkVerdictSeal = seals?.checkVerdictSeal;
308
- const quorumOptions = checkVerdictSeal
309
- ? {
310
- ...declaredQuorum(tree),
311
- agents: new Set((policy.roles[AGENT_ROLE] ?? []).map(normalisePrincipal)),
312
- attestation: policy.active,
313
- verifySeal: (v: SealInput) => checkVerdictSeal(policy, v),
314
- }
315
- : undefined;
323
+ const quorumOptions = await quorumOptionsFor(loaded, tree, policy);
316
324
  const records: RecordView[] = result.records.map((r) => ({
317
325
  ...r,
318
326
  provenance: provenance.get(r.path)!,
@@ -116,7 +116,8 @@ describe("records output schema", () => {
116
116
  JSON.stringify({ name: "w", schema: 1, quorum: 1, members: [{ name: "docs", dir: "docs", kind: "other", because: "decisions only" }] }),
117
117
  );
118
118
  const base = readFileSync(join(dir, "ws-003-seal-scope.md"), "utf-8").replace(/^id: .*$/m, 'id: "ws-900"');
119
- const digest = recordTextDigest(base);
119
+ // The decision kind names a ratified state, so the state leaves the digest too (#2873).
120
+ const digest = recordTextDigest(base, ["reviews", "seal", "state"]);
120
121
  const entry = (reviewer: string, verdict: string, extra = "") => ` - reviewer: "${reviewer}"\n verdict: "${verdict}"\n on: "2026-09-24"${extra}`;
121
122
  const reviews = [
122
123
  entry("alice", "agree", `\n digest: "${digest}"`),
@@ -164,6 +164,7 @@
164
164
  "record-sign-failed",
165
165
  "source-harvest-not-proposed",
166
166
  "record-state-not-initial",
167
+ "ratify-quorum-not-met",
167
168
  "record-unparseable",
168
169
  "record-schema-invalid",
169
170
  "record-id-duplicate",
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
16
16
  import { workingTreeSource } from "./record-source";
17
- import { computeQuorum, DEFAULT_QUORUM, loadRecordKind, normalisePrincipal, readRecords, recordTextDigest, type QuorumOptions, type RecordEntry } from "./records";
17
+ import { computeQuorum, DEFAULT_QUORUM, loadRecordKind, normalisePrincipal, readRecords, recordTextDigest as rawDigest, type QuorumOptions, type RecordEntry } from "./records";
18
18
 
19
19
  const REPO = join(import.meta.dirname, "..", "..", "..", "..");
20
20
  const DECISIONS = join(REPO, "docs", "design", "decisions");
@@ -34,6 +34,13 @@ function review(reviewer: string, verdict: string, extra: Record<string, string>
34
34
  .join("\n");
35
35
  }
36
36
 
37
+ /**
38
+ * A decision's digest: the decision kind names a ratified state, so its
39
+ * state line leaves the digest with the reviews and seal blocks (#2873).
40
+ * The rule tests below hold for that list as for the reviews block alone.
41
+ */
42
+ const recordTextDigest = (text: string, fields: string | readonly string[] | null = ["reviews", "seal", "state"]) => rawDigest(text, fields);
43
+
37
44
  const BARE = recordTextDigest(SAMPLE);
38
45
 
39
46
  describe("recordTextDigest", () => {
@@ -41,7 +48,7 @@ describe("recordTextDigest", () => {
41
48
  expect(BARE).toMatch(/^[0-9a-f]{64}$/);
42
49
  const lines = SAMPLE.split("\n");
43
50
  const without = lines.filter((l) => l !== "reviews: []").join("\n");
44
- expect(recordTextDigest(SAMPLE)).toBe(recordTextDigest(without, null));
51
+ expect(rawDigest(SAMPLE)).toBe(rawDigest(without, null));
45
52
  });
46
53
 
47
54
  test("does not move when a verdict is added, changed or removed", () => {
@@ -78,7 +85,7 @@ describe("recordTextDigest", () => {
78
85
  const file = join(dir, "ws-003.md");
79
86
  writeFileSync(file, withReviews(`${review("alice", "agree")}\n${review("bob", "dissent")}`));
80
87
  // The recipe in docs/src/content/docs/cli/workspace-records.mdx.
81
- const awk = `awk 'NR==1&&$0=="---"{fm=1;print;next} fm&&$0=="---"{fm=0;skip=0;print;next} fm&&/^reviews[ \\t]*:/{skip=1;next} fm&&skip&&/^([ \\t#-]|$)/{next} {skip=0;print}' "${file}" | shasum -a 256`;
88
+ const awk = `awk 'NR==1&&$0=="---"{fm=1;print;next} fm&&$0=="---"{fm=0;skip=0;print;next} fm&&/^(reviews|seal|state)[ \\t]*:/{skip=1;next} fm&&skip&&/^([ \\t#-]|$)/{next} {skip=0;print}' "${file}" | shasum -a 256`;
82
89
  const out = execFileSync("sh", ["-c", awk], { encoding: "utf-8" });
83
90
  expect(out.split(" ")[0]).toBe(BARE);
84
91
  } finally {
@@ -164,7 +164,8 @@ describe("records review --session (#2693)", () => {
164
164
  const root = declared();
165
165
  const { path } = await opened(root);
166
166
  const decision = join(root, "decisions", "ref-001-how-the-app-is-deployed.md");
167
- const digest = recordTextDigest(readFileSync(decision, "utf-8"));
167
+ // The decision kind names a ratified state, so the state leaves the digest too (#2873).
168
+ const digest = recordTextDigest(readFileSync(decision, "utf-8"), ["reviews", "seal", "state"]);
168
169
 
169
170
  const dry = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0002", dryRun: true, cwd: root });
170
171
  review.expectValid(dry);
@@ -10,7 +10,8 @@ import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
13
- import { parseFrontMatter, recordTextDigest } from "./records";
13
+ import { parseFrontMatter, recordTextDigest as textDigest } from "./records";
14
+ import { queryRecords } from "./records-cli";
14
15
  import { allocateId, amendRecord, newRecord, renderRecord, reviewRecord, toYaml } from "./records-write";
15
16
 
16
17
  const REPO = join(import.meta.dirname, "..", "..", "..", "..");
@@ -19,6 +20,9 @@ const KIND = "decisions/decision.kind.mjs";
19
20
 
20
21
  type Data = Record<string, unknown>;
21
22
 
23
+ /** The digest of a decision: the kind names a ratified state, so the state leaves the digest with the reviews and seal (#2873). */
24
+ const recordTextDigest = (text: string) => textDigest(text, ["reviews", "seal", "state"]);
25
+
22
26
  const SAMPLE = (() => {
23
27
  const fm = parseFrontMatter(readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8"));
24
28
  if (!fm.ok) throw new Error(fm.message);
@@ -282,6 +286,7 @@ describe("records amend", () => {
282
286
  test("a decided record may be ratified, re-pin its evidence and take reviews, but not move down", async () => {
283
287
  const evidence = [{ title: "A spec", url: "https://example.com/spec" }];
284
288
  expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ evidence }), cwd: dir })).toMatchObject({ changed: ["evidence"] });
289
+ for (const by of ["alice", "bob"]) await reviewRecord({ kind: KIND, id: "ws-001", verdict: "agree", by, cwd: dir });
285
290
  expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified" }), cwd: dir })).toMatchObject({ changed: ["state"] });
286
291
  put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
287
292
  expect(code(await amendRecord({ kind: KIND, id: "ws-006", fields: fields({ state: "withdrawn" }), cwd: dir }))).toBe("amend-supersede-instead");
@@ -319,6 +324,101 @@ describe("records amend", () => {
319
324
  });
320
325
  });
321
326
 
327
+ describe("ratifying against the quorum (#2873)", () => {
328
+ const ratify = (id = "ws-001") => amendRecord({ kind: KIND, id, fields: fields({ state: "ratified" }), cwd: dir });
329
+ const agree = (by: string, id = "ws-001") => reviewRecord({ kind: KIND, id, verdict: "agree", by, cwd: dir });
330
+ async function quorumOf(id: string) {
331
+ const doc = await queryRecords({ kind: KIND, cwd: dir });
332
+ if ("error" in doc) throw new Error(doc.error.message);
333
+ return doc.records.find((r) => r.id === id)!.quorum!;
334
+ }
335
+
336
+ test("below the quorum, ratified is refused with the count and what did not count, and nothing is written", async () => {
337
+ expect(SAMPLE.decided_by).toBe("lex00");
338
+ let before = snapshot();
339
+ let doc = await ratify();
340
+ expect(code(doc)).toBe("ratify-quorum-not-met");
341
+ expect("error" in doc && doc.error.message).toMatch(/^ws-001 can't be ratified yet: its quorum needs 2 agreeing verdicts that count, and it has 0\. Add verdicts with chant workspace records review ws-001/);
342
+ expect(touched(before, snapshot())).toEqual([]);
343
+ // One agreement and the decider's own: one counts.
344
+ await agree("alice");
345
+ await agree("lex00");
346
+ before = snapshot();
347
+ doc = await ratify();
348
+ expect("error" in doc && doc.error.message).toMatch(/it has 1 \(not counted: lex00, review-decider\)/);
349
+ expect(touched(before, snapshot())).toEqual([]);
350
+ });
351
+
352
+ test("at the quorum, ratified is taken, and the verdicts still count on the ratified record", async () => {
353
+ await agree("alice");
354
+ await agree("bob");
355
+ const judged = recordTextDigest(text("ws-001-one.md"));
356
+ expect(await ratify()).toMatchObject({ changed: ["state"] });
357
+ expect(data("ws-001-one.md").state).toBe("ratified");
358
+ expect(recordTextDigest(text("ws-001-one.md"))).toBe(judged);
359
+ expect(await quorumOf("ws-001")).toMatchObject({ need: 2, agreed: 2, met: true, notCounted: [] });
360
+ });
361
+
362
+ test("a met quorum with an open concern ratifies, and the concern stays listed", async () => {
363
+ await agree("alice");
364
+ await agree("bob");
365
+ await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "carol", note: "It leaves out X.", cwd: dir });
366
+ expect(code(await ratify())).toBe("ok");
367
+ expect(await quorumOf("ws-001")).toMatchObject({ met: true, metWithObjections: true, openConcerns: [{ reviewer: "carol" }] });
368
+ });
369
+
370
+ test("verdicts on text an amendment changed do not count toward ratifying", async () => {
371
+ await agree("alice");
372
+ await agree("bob");
373
+ await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ evidence: [] }), cwd: dir });
374
+ const doc = await ratify();
375
+ expect("error" in doc && doc.error.message).toMatch(/it has 0 \(not counted: alice, review-older-digest; bob, review-older-digest\)/);
376
+ });
377
+
378
+ test("the amendment that ratifies is checked as written, with its own changes to the reviews and pins", async () => {
379
+ await agree("alice");
380
+ const digest = recordTextDigest(text("ws-001-one.md"));
381
+ const reviews = [...(data("ws-001-one.md").reviews as Data[]), { reviewer: "bob", verdict: "agree", on: "2026-09-26", digest }];
382
+ expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified", reviews }), cwd: dir })).toMatchObject({ changed: ["state", "reviews"] });
383
+ put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
384
+ await agree("alice", "ws-006");
385
+ await agree("bob", "ws-006");
386
+ // Re-pinning in the same write moves the digest, so the verdicts no longer match what would be ratified.
387
+ const doc = await amendRecord({ kind: KIND, id: "ws-006", fields: fields({ state: "ratified", evidence: [] }), cwd: dir });
388
+ expect(code(doc)).toBe("ratify-quorum-not-met");
389
+ });
390
+
391
+ test("records new refuses a record that opens ratified below its quorum", async () => {
392
+ const fresh = decision({ title: "Straight in", state: "ratified" });
393
+ delete fresh.id;
394
+ const before = snapshot();
395
+ expect(code(await newRecord({ kind: KIND, fields: fields(fresh), cwd: dir }))).toBe("ratify-quorum-not-met");
396
+ expect(touched(before, snapshot())).toEqual([]);
397
+ expect(code(await newRecord({ kind: KIND, fields: fields({ ...fresh, state: "decided" }), cwd: dir }))).toBe("ok");
398
+ });
399
+
400
+ test("a kind without reviews.ratified, or without reviews, takes ratified with no quorum, and its digest keeps the state", async () => {
401
+ const kindFile = join(dir, "decisions", "decision.kind.mjs");
402
+ const withRatified = readFileSync(kindFile, "utf-8");
403
+ expect(withRatified).toContain('reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },');
404
+ writeFileSync(kindFile, withRatified.replace(', ratified: "ratified" }', " }"));
405
+ const judged = textDigest(text("ws-001-one.md"), ["reviews", "seal"]);
406
+ expect(await ratify()).toMatchObject({ changed: ["state"] });
407
+ expect(textDigest(text("ws-001-one.md"), ["reviews", "seal"])).not.toBe(judged);
408
+ writeFileSync(kindFile, withRatified.replace(' reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },\n', ""));
409
+ put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
410
+ expect(await ratify("ws-006")).toMatchObject({ changed: ["state"] });
411
+ });
412
+
413
+ test("a kind whose reviews.ratified is not one of its states is refused", async () => {
414
+ const kindFile = join(dir, "decisions", "decision.kind.mjs");
415
+ writeFileSync(kindFile, readFileSync(kindFile, "utf-8").replace('ratified: "ratified" }', 'ratified: "approved" }'));
416
+ const doc = await ratify();
417
+ expect(code(doc)).toBe("kind-invalid");
418
+ expect("error" in doc && doc.error.message).toMatch(/reviews\.ratified must be one of states/);
419
+ });
420
+ });
421
+
322
422
  describe("records review", () => {
323
423
  test("appends a dated verdict bound to the digest of the text it judged", async () => {
324
424
  const judged = recordTextDigest(text("ws-001-one.md"));