@intentius/chant 0.124.24 → 0.124.26

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 (195) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.js +4 -2
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/cli/commands/lint.js +4 -2
  5. package/dist/cli/handlers/approve.d.ts.map +1 -1
  6. package/dist/cli/handlers/approve.js +39 -4
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/main.js +21 -7
  9. package/dist/cli/mcp/workspace-tools.d.ts +3 -3
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  11. package/dist/cli/mcp/workspace-tools.js +27 -4
  12. package/dist/effect-receipt.d.ts +13 -0
  13. package/dist/effect-receipt.d.ts.map +1 -1
  14. package/dist/effect-receipt.js +19 -0
  15. package/dist/gate-decision.d.ts +1 -1
  16. package/dist/gate-decision.d.ts.map +1 -1
  17. package/dist/gate-decision.js +1 -1
  18. package/dist/lexicon-module.d.ts +7 -1
  19. package/dist/lexicon-module.d.ts.map +1 -1
  20. package/dist/lexicon-module.js +7 -2
  21. package/dist/lifecycle/gate-audit.d.ts +7 -1
  22. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  23. package/dist/lifecycle/gate-audit.js +40 -3
  24. package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
  25. package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
  26. package/dist/lifecycle/gate-checkpoint.js +103 -0
  27. package/dist/lifecycle/gate-ledger.d.ts +41 -0
  28. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  29. package/dist/lifecycle/gate-ledger.js +18 -0
  30. package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
  31. package/dist/lifecycle/receipt-plan.js +4 -2
  32. package/dist/lifecycle/run-ledger.d.ts +2 -0
  33. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  34. package/dist/lifecycle/run-ledger.js +2 -0
  35. package/dist/lint/rules/op/index.d.ts +6 -2
  36. package/dist/lint/rules/op/index.d.ts.map +1 -1
  37. package/dist/lint/rules/op/index.js +7 -3
  38. package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
  39. package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
  40. package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
  41. package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
  42. package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
  43. package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
  44. package/dist/op/activity-contract.d.ts +14 -0
  45. package/dist/op/activity-contract.d.ts.map +1 -1
  46. package/dist/op/activity-contract.js +16 -0
  47. package/dist/op/builders.d.ts +7 -0
  48. package/dist/op/builders.d.ts.map +1 -1
  49. package/dist/op/builders.js +13 -0
  50. package/dist/op/gate-alias.d.ts +57 -0
  51. package/dist/op/gate-alias.d.ts.map +1 -0
  52. package/dist/op/gate-alias.js +127 -0
  53. package/dist/op/gate-checkpoint.d.ts +123 -0
  54. package/dist/op/gate-checkpoint.d.ts.map +1 -0
  55. package/dist/op/gate-checkpoint.js +280 -0
  56. package/dist/op/gate.d.ts +54 -2
  57. package/dist/op/gate.d.ts.map +1 -1
  58. package/dist/op/gate.js +142 -1
  59. package/dist/op/index.d.ts +4 -1
  60. package/dist/op/index.d.ts.map +1 -1
  61. package/dist/op/index.js +2 -1
  62. package/dist/op/local-executor.d.ts +57 -2
  63. package/dist/op/local-executor.d.ts.map +1 -1
  64. package/dist/op/local-executor.js +200 -11
  65. package/dist/op/local-output.d.ts.map +1 -1
  66. package/dist/op/local-output.js +20 -2
  67. package/dist/op/op-ir.d.ts +4 -0
  68. package/dist/op/op-ir.d.ts.map +1 -1
  69. package/dist/op/op-ir.js +4 -0
  70. package/dist/op/receipt-store.d.ts +5 -0
  71. package/dist/op/receipt-store.d.ts.map +1 -1
  72. package/dist/op/receipt-store.js +4 -3
  73. package/dist/op/runtime.d.ts +15 -0
  74. package/dist/op/runtime.d.ts.map +1 -1
  75. package/dist/op/runtimes/local.d.ts.map +1 -1
  76. package/dist/op/runtimes/local.js +5 -0
  77. package/dist/op/step-output-ref.d.ts +26 -18
  78. package/dist/op/step-output-ref.d.ts.map +1 -1
  79. package/dist/op/step-output-ref.js +86 -63
  80. package/dist/op/types.d.ts +31 -0
  81. package/dist/op/types.d.ts.map +1 -1
  82. package/dist/wave-runtime.mjs +223 -221
  83. package/dist/workspace/agent-cli.d.ts.map +1 -1
  84. package/dist/workspace/agent-cli.js +3 -0
  85. package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  86. package/dist/workspace/conformance/writer.js +1 -1
  87. package/dist/workspace/decide.d.ts +51 -9
  88. package/dist/workspace/decide.d.ts.map +1 -1
  89. package/dist/workspace/decide.js +125 -14
  90. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  91. package/dist/workspace/lineage-upgrade.js +20 -11
  92. package/dist/workspace/point-answer.schema.json +16 -1
  93. package/dist/workspace/points-cli.d.ts +5 -3
  94. package/dist/workspace/points-cli.d.ts.map +1 -1
  95. package/dist/workspace/points-cli.js +16 -7
  96. package/dist/workspace/points-write.schema.json +39 -4
  97. package/dist/workspace/points.schema.json +42 -3
  98. package/dist/workspace/reason-codes.d.ts +2 -1
  99. package/dist/workspace/reason-codes.d.ts.map +1 -1
  100. package/dist/workspace/reason-codes.js +2 -1
  101. package/dist/workspace/records-cli.d.ts +12 -2
  102. package/dist/workspace/records-cli.d.ts.map +1 -1
  103. package/dist/workspace/records-cli.js +27 -7
  104. package/dist/workspace/records-write.d.ts.map +1 -1
  105. package/dist/workspace/records-write.js +2 -3
  106. package/dist/workspace/records.d.ts +10 -4
  107. package/dist/workspace/records.d.ts.map +1 -1
  108. package/dist/workspace/records.js +16 -3
  109. package/dist/workspace/records.schema.json +2 -1
  110. package/dist/workspace/trust/gate-seal.d.ts +32 -1
  111. package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
  112. package/dist/workspace/trust/gate-seal.js +41 -2
  113. package/dist/workspace/trust/seal.d.ts +1 -1
  114. package/dist/workspace/trust/seal.d.ts.map +1 -1
  115. package/dist/workspace/trust/seal.js +1 -1
  116. package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
  117. package/dist/workspace/trust/verify-cli.js +2 -1
  118. package/dist/workspace/trust/verify.d.ts +22 -0
  119. package/dist/workspace/trust/verify.d.ts.map +1 -1
  120. package/dist/workspace/trust/verify.js +94 -14
  121. package/dist/workspace/write-scope.d.ts +3 -1
  122. package/dist/workspace/write-scope.d.ts.map +1 -1
  123. package/dist/workspace/write-scope.js +11 -4
  124. package/package.json +1 -1
  125. package/src/cli/commands/build.ts +9 -2
  126. package/src/cli/commands/lint.ts +4 -2
  127. package/src/cli/compiled-cli.e2e.test.ts +45 -1
  128. package/src/cli/handlers/approve.ts +46 -4
  129. package/src/cli/handlers/operator.test.ts +91 -0
  130. package/src/cli/main.test.ts +10 -0
  131. package/src/cli/main.ts +21 -7
  132. package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
  133. package/src/cli/mcp/workspace-tools.ts +27 -4
  134. package/src/effect-receipt.ts +22 -0
  135. package/src/gate-decision.ts +2 -0
  136. package/src/lexicon-module.ts +9 -2
  137. package/src/lifecycle/gate-audit.ts +41 -4
  138. package/src/lifecycle/gate-checkpoint.test.ts +128 -0
  139. package/src/lifecycle/gate-checkpoint.ts +133 -0
  140. package/src/lifecycle/gate-ledger.ts +61 -0
  141. package/src/lifecycle/receipt-plan.test.ts +9 -0
  142. package/src/lifecycle/receipt-plan.ts +4 -1
  143. package/src/lifecycle/run-ledger.ts +4 -0
  144. package/src/lint/rules/op/index.ts +9 -3
  145. package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
  146. package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
  147. package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
  148. package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
  149. package/src/op/activity-contract.ts +22 -1
  150. package/src/op/builders.ts +21 -1
  151. package/src/op/effect-step.test.ts +95 -1
  152. package/src/op/gate-alias.e2e.test.ts +78 -0
  153. package/src/op/gate-alias.test.ts +350 -0
  154. package/src/op/gate-alias.ts +137 -0
  155. package/src/op/gate-checkpoint.test.ts +284 -0
  156. package/src/op/gate-checkpoint.ts +322 -0
  157. package/src/op/gate.ts +198 -4
  158. package/src/op/index.ts +4 -1
  159. package/src/op/local-executor.test.ts +65 -0
  160. package/src/op/local-executor.ts +248 -12
  161. package/src/op/local-output.ts +18 -2
  162. package/src/op/op-ir.test.ts +25 -0
  163. package/src/op/op-ir.ts +8 -0
  164. package/src/op/receipt-store.ts +13 -3
  165. package/src/op/runtime.ts +8 -0
  166. package/src/op/runtimes/local.ts +5 -0
  167. package/src/op/step-output-ref.test.ts +68 -24
  168. package/src/op/step-output-ref.ts +85 -65
  169. package/src/op/types.ts +32 -0
  170. package/src/workspace/agent-cli.ts +2 -0
  171. package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  172. package/src/workspace/conformance/writer.ts +1 -1
  173. package/src/workspace/decide.test.ts +87 -3
  174. package/src/workspace/decide.ts +142 -17
  175. package/src/workspace/lineage-upgrade.test.ts +21 -0
  176. package/src/workspace/lineage-upgrade.ts +19 -11
  177. package/src/workspace/point-answer.schema.json +16 -1
  178. package/src/workspace/points-cli.ts +16 -8
  179. package/src/workspace/points-write.schema.json +39 -4
  180. package/src/workspace/points.schema.json +42 -3
  181. package/src/workspace/reason-codes.ts +2 -1
  182. package/src/workspace/record-assets.test.ts +14 -0
  183. package/src/workspace/records-cli.ts +28 -7
  184. package/src/workspace/records-write-commands.e2e.test.ts +32 -0
  185. package/src/workspace/records-write.ts +2 -3
  186. package/src/workspace/records.schema.json +2 -1
  187. package/src/workspace/records.ts +17 -4
  188. package/src/workspace/trust/gate-seal.test.ts +29 -2
  189. package/src/workspace/trust/gate-seal.ts +46 -2
  190. package/src/workspace/trust/seal.ts +1 -1
  191. package/src/workspace/trust/trust.test.ts +105 -0
  192. package/src/workspace/trust/verify-cli.ts +2 -1
  193. package/src/workspace/trust/verify.ts +106 -13
  194. package/src/workspace/write-scope.test.ts +18 -0
  195. package/src/workspace/write-scope.ts +16 -5
@@ -7,7 +7,9 @@
7
7
  * Each run is traced through a load hook that NODE_OPTIONS hands to the CLI's
8
8
  * node, so the test can say what loaded: none of chant's TypeScript source,
9
9
  * ever, and tsx only for a command that imports TypeScript (here the
10
- * lexicon and the project source).
10
+ * lexicon and the project source). tsx's ESM hooks run on a thread of their
11
+ * own, about 200 MB of the process (#3276), so a workspace read must not
12
+ * register it either.
11
13
  *
12
14
  * End to end because it packs, installs from the registry and spawns the CLI.
13
15
  */
@@ -20,12 +22,14 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest";
20
22
 
21
23
  const coreRoot = resolve(import.meta.dirname, "..", "..");
22
24
  const renderRoot = resolve(coreRoot, "..", "..", "lexicons", "render");
25
+ const referenceDecisions = resolve(coreRoot, "..", "..", "reference-workspace", "decisions");
23
26
  const version = (JSON.parse(readFileSync(join(coreRoot, "package.json"), "utf-8")) as { version: string }).version;
24
27
 
25
28
  let scratch: string;
26
29
  let project: string;
27
30
  let cjsProject: string;
28
31
  let waves: string;
32
+ let workspace: string;
29
33
  let chant: string;
30
34
  let tracer: string;
31
35
 
@@ -126,6 +130,31 @@ beforeAll(() => {
126
130
  writeFileSync(join(waves, ".gitignore"), ".chant/\n");
127
131
  git("add", "-A");
128
132
  git("commit", "-qm", "ignore");
133
+
134
+ // A workspace whose declaration names a .mjs record kind, with a member
135
+ // that has a chant.config.ts, for the workspace reads below (#3276).
136
+ workspace = join(scratch, "workspace");
137
+ mkdirSync(join(workspace, "decisions"), { recursive: true });
138
+ mkdirSync(join(workspace, "app"));
139
+ for (const file of ["decision.kind.mjs", "decision.schema.json", "ref-001-how-the-app-is-deployed.md"]) {
140
+ writeFileSync(join(workspace, "decisions", file), readFileSync(join(referenceDecisions, file)));
141
+ }
142
+ writeFileSync(
143
+ join(workspace, "chant.workspace.json"),
144
+ JSON.stringify({
145
+ name: "e2e",
146
+ schema: 1,
147
+ members: [{ name: "app", dir: "app", kind: "chant" }],
148
+ records: [{ kind: "decisions/decision.kind.mjs" }],
149
+ }),
150
+ );
151
+ writeFileSync(join(workspace, "app", "chant.config.ts"), `export default { lexicons: [] as string[] };\n`);
152
+ symlinkSync(join(project, "node_modules"), join(workspace, "node_modules"), "dir");
153
+ const wsGit = (...args: string[]) => execFileSync("git", ["-c", "user.email=e2e@chant", "-c", "user.name=e2e", ...args], { cwd: workspace, stdio: "ignore" });
154
+ wsGit("init", "-q", "-b", "main");
155
+ writeFileSync(join(workspace, ".gitignore"), "node_modules\n");
156
+ wsGit("add", "-A");
157
+ wsGit("commit", "-qm", "workspace");
129
158
  }, 600_000);
130
159
 
131
160
  afterAll(() => {
@@ -148,6 +177,21 @@ describe("the installed chant CLI (#3878)", () => {
148
177
  expect(r.loaded).toMatchObject({ chantSrc: 0, tsx: 0 });
149
178
  });
150
179
 
180
+ test("workspace ls reads a .mjs record kind from a member with a chant.config.ts without loading tsx", () => {
181
+ const r = run(join(workspace, "app"), ["workspace", "ls", "--json"]);
182
+ expect(r.status, r.stderr).toBe(0);
183
+ const ls = JSON.parse(r.stdout) as { workspace: { records: Array<{ name: string; kind: string | null }> } };
184
+ expect(ls.workspace.records.map((k) => k.kind)).toEqual(["decision"]);
185
+ expect(r.loaded).toMatchObject({ chantSrc: 0, tsx: 0 });
186
+ });
187
+
188
+ test("workspace records reads the declared .mjs kind's records without loading tsx", () => {
189
+ const r = run(join(workspace, "app"), ["workspace", "records", "--json"]);
190
+ expect(r.status, r.stderr).toBe(0);
191
+ expect(r.stdout).toContain("ref-001");
192
+ expect(r.loaded).toMatchObject({ chantSrc: 0, tsx: 0 });
193
+ });
194
+
151
195
  test("build loads the TypeScript lexicon and project through tsx, against the one compiled core", () => {
152
196
  const r = run(project, ["build", "src", "-o", "out/render.json"]);
153
197
  expect(r.status, r.stderr).toBe(0);
@@ -5,6 +5,7 @@
5
5
  * re-exports everything here.
6
6
  */
7
7
  import { discoverOps } from "../../op/discover";
8
+ import { aliasingGate } from "../../op/gate-alias";
8
9
  import type { GateResolutionRecord, PendingGateRecord } from "../../lifecycle/gate-ledger";
9
10
  import {
10
11
  currentGateOrigin, isModelAuthored, sameOriginRefusal, UNATTESTED_APPROVER, type GateOrigin,
@@ -280,7 +281,9 @@ export async function recordGateApproval(
280
281
  // `fan-out` is the op name every `chant components fan-out` gate is recorded
281
282
  // under (../handlers/fan-out.ts). It is a command rather than a declaration,
282
283
  // so there is no `*.op.ts` to find and nothing is wrong when none is there.
283
- if (!ops.has(opName) && opName !== FAN_OUT_GATE_OP && opName !== WORKSPACE_UPGRADE_GATE_OP && opName !== OVERRIDE_GATE_OP) {
284
+ // #3949: a gate that lists this one as an alias, after an Op was restructured.
285
+ const successor = aliasingGate([...ops.values()].map((o) => o.config), opName, gate);
286
+ if (!successor && !ops.has(opName) && opName !== FAN_OUT_GATE_OP && opName !== WORKSPACE_UPGRADE_GATE_OP && opName !== OVERRIDE_GATE_OP) {
284
287
  console.error(formatWarning({
285
288
  message: `Op "${opName}" was not found among discovered *.op.ts declarations — recording the resolution anyway`,
286
289
  }));
@@ -301,6 +304,26 @@ export async function recordGateApproval(
301
304
  return { ok: false };
302
305
  }
303
306
 
307
+ // #3949: an approval under an alias counts toward the new gate only while
308
+ // the new gate has no standing fact of its own. Once it has one, approve
309
+ // the gate the runs now stop at.
310
+ if (successor) {
311
+ const theirs = latestPendingGate((await readGateLedger(successor.op)).pending, successor.gate);
312
+ if (theirs && !isPendingGateExpired(theirs, new Date().toISOString())) {
313
+ console.error(formatError({
314
+ message:
315
+ `Gate "${gate}" on "${opName}" is now gate "${successor.gate}" on "${successor.op}", which lists it as an alias, ` +
316
+ `and "${successor.gate}" has a standing pending fact`,
317
+ hint: `Approve that gate instead: chant approve ${successor.op} ${successor.gate}`,
318
+ }));
319
+ return { ok: false };
320
+ }
321
+ console.error(formatInfo(
322
+ `Gate "${gate}" on "${opName}" is an alias of gate "${successor.gate}" on "${successor.op}": ` +
323
+ `the next run of ${successor.op} counts this approval there while the pending fact it answers stands and the plan is the same.`,
324
+ ));
325
+ }
326
+
304
327
  // #2028: the resolution's link is typed. `--url` wins; otherwise, running
305
328
  // inside the PR/MR job that carries the change is itself the address, the
306
329
  // same env fallback `--actor` uses. `--note` stays free-text prose.
@@ -358,6 +381,14 @@ export async function recordGateApproval(
358
381
  }
359
382
  planDigest = standing.planDigest;
360
383
  }
384
+ // #3947: a gate that resumes recorded a checkpoint with its pending fact.
385
+ // An approval of that fact's plan copies the checkpoint's digest, the way it
386
+ // copies the plan's, so the run that resumes checks the file against what
387
+ // was approved here. An approval of another plan copies none.
388
+ const checkpointDigest =
389
+ standing?.checkpointDigest !== undefined && samePlanDigest(planDigest, standing.planDigest)
390
+ ? standing.checkpointDigest
391
+ : undefined;
361
392
 
362
393
  // chant#2384 — the gate's whole value is that its two halves have different
363
394
  // authors. At a shell they do: a person runs, reads the plan, and approves.
@@ -457,11 +488,11 @@ export async function recordGateApproval(
457
488
  const timestamp = new Date().toISOString();
458
489
  let seal: GateResolutionRecord["seal"];
459
490
  if (opts.sign !== undefined) {
460
- const { resolveSigningKey, sealGateApproval, SealError } = await import("../../workspace/trust/seal");
491
+ const { resolveSigningKey, sealGateApproval, gateSealVersion, SealError } = await import("../../workspace/trust/seal");
461
492
  try {
462
493
  const key = resolveSigningKey(opts.sign, cwd);
463
494
  try {
464
- seal = sealGateApproval(key.file, {
495
+ const sealed = {
465
496
  op: opName,
466
497
  gate,
467
498
  ...(environment !== undefined ? { environment } : {}),
@@ -469,7 +500,10 @@ export async function recordGateApproval(
469
500
  resolvedBy,
470
501
  ...(relayedBy ? { relayedBy } : {}),
471
502
  timestamp,
472
- });
503
+ ...(checkpointDigest !== undefined ? { checkpointDigest } : {}),
504
+ };
505
+ // #3947: a seal over a checkpoint digest is seal version 2.
506
+ seal = { ...sealGateApproval(key.file, sealed), ...(gateSealVersion(sealed) === 2 ? { version: 2 as const } : {}) };
473
507
  } finally {
474
508
  key.cleanup();
475
509
  }
@@ -488,6 +522,7 @@ export async function recordGateApproval(
488
522
  resolvedBy,
489
523
  ...(relayedBy ? { relayedBy } : {}),
490
524
  timestamp,
525
+ ...(checkpointDigest !== undefined ? { checkpointDigest } : {}),
491
526
  seal,
492
527
  });
493
528
  if (why !== null) {
@@ -550,6 +585,7 @@ export async function recordGateApproval(
550
585
  ...(opts.note ? { note: opts.note } : {}),
551
586
  ...(url ? { url } : {}),
552
587
  ...(planDigest !== undefined ? { planDigest } : {}),
588
+ ...(checkpointDigest !== undefined ? { checkpointDigest } : {}),
553
589
  ...(environment !== undefined ? { environment } : {}),
554
590
  origin,
555
591
  ...(refusal && opts.allowSameOrigin ? { sameOriginOverride: true } : {}),
@@ -574,6 +610,12 @@ export async function recordGateApproval(
574
610
  ", and only that plan. A run whose fresh plan differs refuses rather than applying it.",
575
611
  ));
576
612
  }
613
+ if (record.checkpointDigest) {
614
+ console.error(formatInfo(
615
+ `The next run resumes at this gate from run ${standing?.runId ?? "(unknown)"}'s checkpoint ${record.checkpointDigest}, ` +
616
+ "after it plans again. A moved plan or another commit starts it at the first phase.",
617
+ ));
618
+ }
577
619
  if (approval) {
578
620
  for (const line of describeApprovalProgress(approval, record, ledger.resolutions, standingForOrigin, ledger.pending)) {
579
621
  console.error(formatInfo(line));
@@ -929,6 +929,97 @@ describe("runApprove — the resolution names the plan it approves (#2300)", ()
929
929
  });
930
930
  });
931
931
 
932
+ /** #3947 — a gate that resumes recorded a checkpoint, and its approval copies the digest. */
933
+ describe("runApprove — the approval copies the checkpoint a resumed run carries on from (#3947)", () => {
934
+ const CHECKPOINT = `jcs1-sha256:${"c".repeat(64)}`;
935
+ const seedCheckpoint = () =>
936
+ readGateLedgerMock.mockResolvedValue({
937
+ resolutions: [],
938
+ pending: [{
939
+ version: 1, kind: "pending", op: "fountain-apply", gate: "rollout-gate", runId: "local-1",
940
+ timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z",
941
+ planDigest: PLAN_A, checkpointDigest: CHECKPOINT,
942
+ }],
943
+ malformed: 0,
944
+ });
945
+
946
+ test("approving the standing fact copies its checkpoint digest and says the next run resumes", async () => {
947
+ seedCheckpoint();
948
+ appendGateResolutionMock.mockResolvedValue({
949
+ commit: "sha",
950
+ record: {
951
+ version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex",
952
+ timestamp: "2026-01-01T00:00:00.000Z", planDigest: PLAN_A, checkpointDigest: CHECKPOINT,
953
+ },
954
+ });
955
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
956
+
957
+ expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }))).toBe(0);
958
+ expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({ planDigest: PLAN_A, checkpointDigest: CHECKPOINT }));
959
+ expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain(`resumes at this gate from run local-1's checkpoint ${CHECKPOINT}`);
960
+ errSpy.mockRestore();
961
+ });
962
+
963
+ test("an approval of another plan copies no checkpoint", async () => {
964
+ seedCheckpoint();
965
+ const other = `sha256:${"b".repeat(64)}`;
966
+ appendGateResolutionMock.mockResolvedValue({
967
+ commit: "sha",
968
+ record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: other },
969
+ });
970
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
971
+
972
+ expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", plan: other }))).toBe(0);
973
+ expect(appendGateResolutionMock.mock.calls[0][0]).not.toHaveProperty("checkpointDigest");
974
+ errSpy.mockRestore();
975
+ });
976
+ });
977
+
978
+ /** #3949 — once a gate that aliases this one has a standing fact, approve that gate. */
979
+ describe("runApprove — an approval under a gate's alias (#3949)", () => {
980
+ const merged = {
981
+ config: {
982
+ name: "upgrade",
983
+ overview: "upgrade every tier",
984
+ phases: [{ name: "Wave 1", steps: [{ kind: "gate", gate: "approve-tier-1", plan: PLAN_A, aliases: [{ op: "upgrade-tier-1", gate: "approve" }] }] }],
985
+ },
986
+ };
987
+ const fact = (op: string, gate: string) => ({
988
+ version: 1, kind: "pending", op, gate, timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z", planDigest: PLAN_A,
989
+ });
990
+ const ledgers = (byOp: Record<string, unknown[]>) =>
991
+ readGateLedgerMock.mockImplementation(async (op: string) => ({ resolutions: [], pending: byOp[op] ?? [], malformed: 0 }));
992
+
993
+ test("refuses and names the new gate once it has a standing pending fact", async () => {
994
+ discoverOpsMock.mockResolvedValue({ ops: new Map([["upgrade", merged]]), errors: [] });
995
+ ledgers({ "upgrade-tier-1": [fact("upgrade-tier-1", "approve")], upgrade: [fact("upgrade", "approve-tier-1")] });
996
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
997
+
998
+ expect(await runApprove(ctx({ path: "upgrade-tier-1", extraPositional: "approve", actor: "alex" }))).toBe(1);
999
+ const out = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
1000
+ expect(out).toContain('is now gate "approve-tier-1" on "upgrade"');
1001
+ expect(out).toContain("chant approve upgrade approve-tier-1");
1002
+ expect(out).not.toContain("was not found among discovered");
1003
+ expect(appendGateResolutionMock).not.toHaveBeenCalled();
1004
+ errSpy.mockRestore();
1005
+ });
1006
+
1007
+ test("records the approval while the new gate has none, and says where it counts", async () => {
1008
+ discoverOpsMock.mockResolvedValue({ ops: new Map([["upgrade", merged]]), errors: [] });
1009
+ ledgers({ "upgrade-tier-1": [fact("upgrade-tier-1", "approve")] });
1010
+ appendGateResolutionMock.mockResolvedValue({
1011
+ commit: "sha",
1012
+ record: { version: 1, op: "upgrade-tier-1", gate: "approve", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: PLAN_A },
1013
+ });
1014
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
1015
+
1016
+ expect(await runApprove(ctx({ path: "upgrade-tier-1", extraPositional: "approve", actor: "alex" }))).toBe(0);
1017
+ expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({ op: "upgrade-tier-1", gate: "approve", planDigest: PLAN_A }));
1018
+ expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain('is an alias of gate "approve-tier-1" on "upgrade"');
1019
+ errSpy.mockRestore();
1020
+ });
1021
+ });
1022
+
932
1023
  /**
933
1024
  * #2574 — a component's gate ledger holds every environment's pending facts.
934
1025
  * The approval records the environment it answers, and `--env` picks it.
@@ -1003,3 +1003,13 @@ describe("chant workspace check --generated (#2641)", () => {
1003
1003
  expect(() => parseArgs(["workspace", "check", "--generated=yes"])).toThrow();
1004
1004
  });
1005
1005
  });
1006
+
1007
+ describe("chant --help", () => {
1008
+ test("lists every top-level command in the registry", () => {
1009
+ const source = readFileSync(join(import.meta.dirname, "main.ts"), "utf-8");
1010
+ const help = source.slice(source.indexOf("function printHelp("), source.indexOf("Options:", source.indexOf("function printHelp(")));
1011
+ const listed = new Set([...help.matchAll(/^ {2}([a-z][a-z-]*)\b/gm)].map((m) => m[1]));
1012
+ const commands = new Set(commandRegistry.map((def) => def.name.split(" ")[0]));
1013
+ expect([...commands].filter((name) => !listed.has(name))).toEqual([]);
1014
+ });
1015
+ });
package/src/cli/main.ts CHANGED
@@ -167,7 +167,12 @@ Commands:
167
167
  okf emits an OKF v0.2 knowledge bundle — one markdown
168
168
  concept per entity + index.md; -o <dir> writes the
169
169
  bundle tree, otherwise JSON path→content on stdout)
170
- generate Generate typed code from sources chant.config declares
170
+ search "<query>" Answer an estate question with a few matching rows
171
+ instead of a graph dump (kind:, tag:, attr:, ->/<- edge
172
+ terms; --live or --at <ref> with --env <name> reads the
173
+ estate or a recorded snapshot; --show <attr>,
174
+ --check-live, --check-snapshot, --fail-on-drift)
175
+ generate Generate typed code from sources chant.config declares
171
176
  (k8s.crds, helm.charts) into src/generated/<lexicon>/
172
177
  (codegen.outDir); --check exits 1 when the committed
173
178
  output no longer matches its sources, as build does
@@ -402,9 +407,11 @@ Workspace (level 1, #2524):
402
407
  state transitions, new verdicts, new supersessions
403
408
  and changed pins. A session id compares the commits
404
409
  the session opened and closed at
405
- workspace records pin <path>
410
+ workspace records pin <path> [--kind <kind file>]
406
411
  Print the path from the workspace root and the
407
- sha256 of a file, for a decision's evidence pin
412
+ sha256 of a file, for a decision's evidence pin. The
413
+ root is the kind file's workspace with --kind, else
414
+ the workspace above the current directory
408
415
  workspace records new [<kind file>] --from <file|-> [--prefix <prefix>] [--sign [<key file>]] [--dry-run]
409
416
  Write one new record in the kind's directory from
410
417
  the JSON fields given, after validating them as
@@ -559,6 +566,11 @@ Workspace (level 1, #2524):
559
566
  workspace points retract <id> --by <name>... [--note <text>] [--kind <kind file>] [--dry-run]
560
567
  Take an answer back: the question is open for people
561
568
  again, and the answer stays in its retractions
569
+ workspace points withdraw <id> --by <name>... [--note <text>] [--kind <kind file>] [--dry-run]
570
+ Close an open question that no longer applies, such as
571
+ one about a work item its author changed: points --open
572
+ leaves it out, nobody answers it, and asking it again
573
+ opens it again (#2888)
562
574
  workspace pin <path> [--json]
563
575
  Print the integrity value that pins the plugin at
564
576
  <path>, to put in a path pin of chant.workspace.json.
@@ -1226,16 +1238,18 @@ export const commandRegistry: CommandDef[] = [
1226
1238
 
1227
1239
  // Workspace reads (#2524). Imported on first use, so a level-0 command never
1228
1240
  // loads anything under workspace/ (#2525 rule 5, pinned by #2526's goldens).
1229
- { name: "workspace records", handler: async (ctx) => (await import("../workspace/records-cli")).runWorkspaceRecords(ctx) },
1241
+ // #3276 — records, points, ls and status read the declaration and never the
1242
+ // config of the member they run in, so they skip its load (and tsx with it).
1243
+ { name: "workspace records", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/records-cli")).runWorkspaceRecords(ctx) },
1230
1244
  // ws-058 (#2739) — decision points and the questions asked of them; never calls a model.
1231
- { name: "workspace points", handler: async (ctx) => (await import("../workspace/points-cli")).runWorkspacePoints(ctx) },
1245
+ { name: "workspace points", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/points-cli")).runWorkspacePoints(ctx) },
1232
1246
  { name: "workspace init", handler: async (ctx) => (await import("../workspace/init")).runWorkspaceInit(ctx) },
1233
- { name: "workspace ls", handler: async (ctx) => (await import("../workspace/ls")).runWorkspaceLs(ctx) },
1247
+ { name: "workspace ls", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ls")).runWorkspaceLs(ctx) },
1234
1248
  // #2732 — the work lease: claim, renew and release a work item.
1235
1249
  { name: "workspace work", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/work-cli")).runWorkspaceWork(ctx) },
1236
1250
  // #3033 — the agent run record: what each run cost and which commits it made. chant records runs and never starts one.
1237
1251
  { name: "workspace runs", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/runs-cli")).runWorkspaceRuns(ctx) },
1238
- { name: "workspace status", handler: async (ctx) => (await import("../workspace/status")).runWorkspaceStatus(ctx) },
1252
+ { name: "workspace status", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/status")).runWorkspaceStatus(ctx) },
1239
1253
  { name: "workspace lineage", handler: async (ctx) => (await import("../workspace/lineage-cli")).runWorkspaceLineage(ctx) },
1240
1254
  { name: "workspace upgrade", handler: async (ctx) => (await import("../workspace/lineage-upgrade-cli")).runWorkspaceUpgrade(ctx) },
1241
1255
  // #2551 — adopt a lineage, the hash index it matches against, and the versions of a family of workspaces.
@@ -85,7 +85,7 @@ describe("which servers have the workspace tools", () => {
85
85
  const { tools } = (await rpc(server(cwd), "tools/list")) as { tools: { name: string }[] };
86
86
  expect(tools.map((t) => t.name)).toEqual(expect.arrayContaining(names));
87
87
  }
88
- expect(names).toEqual(["workspace-ls", "workspace-status", "workspace-graph", "workspace-changes", "workspace-records", "workspace-points", "workspace-agent", "workspace-work-history", "workspace-runs", "records-new", "records-amend", "records-review", "records-close", "points-answer", "points-retract"]);
88
+ expect(names).toEqual(["workspace-ls", "workspace-status", "workspace-graph", "workspace-changes", "workspace-records", "workspace-points", "workspace-agent", "workspace-work-history", "workspace-runs", "records-new", "records-amend", "records-review", "records-close", "points-answer", "points-retract", "points-withdraw"]);
89
89
  });
90
90
 
91
91
  test("a server outside any workspace, or given none, does not", async () => {
@@ -15,9 +15,9 @@
15
15
  * read to the workspace root's pinned chant included (ws-021), and keeps
16
16
  * anything a command prints away from the protocol on stdout.
17
17
  * - The writes (`records-new`, `records-amend`, `records-review`,
18
- * `records-close`, `points-answer`, `points-retract`) call the functions
19
- * `chant workspace records new|amend|review|close` and `points
20
- * answer|retract` call, and return the
18
+ * `records-close`, `points-answer`, `points-retract`, `points-withdraw`)
19
+ * call the functions `chant workspace records new|amend|review|close` and
20
+ * `points answer|retract|withdraw` call, and return the
21
21
  * same JSON result. They keep every rule
22
22
  * the CLI keeps: the kind's schema, a closed record never changing, an
23
23
  * approved one changing only as its approval rule allows, a dissent needing
@@ -165,7 +165,7 @@ export const workspaceReadTools: ToolDefinition[] = [
165
165
  {
166
166
  name: "workspace-points",
167
167
  description:
168
- "The workspace's decision points and the questions asked of them: chant workspace points --json (points.schema.json, ws-058). A question is open while it is escalated to people, or proposed by a model and not yet confirmed; each lists any model's answer with its confidence and threshold. Returns the document unchanged. chant calls no model to answer this.",
168
+ "The workspace's decision points and the questions asked of them: chant workspace points --json (points.schema.json, ws-058). A question is open while it is escalated to people, or proposed by a model and not yet confirmed, and not withdrawn; each lists any model's answer with its confidence and threshold. Returns the document unchanged. chant calls no model to answer this.",
169
169
  inputSchema: {
170
170
  type: "object",
171
171
  properties: {
@@ -326,6 +326,23 @@ export const workspaceWriteTools: ToolDefinition[] = [
326
326
  required: ["id", "by"],
327
327
  },
328
328
  },
329
+ {
330
+ name: "points-withdraw",
331
+ description:
332
+ "Close an open decision point question that no longer applies, such as one about a work item its author changed, which a run asks about anew: chant workspace points withdraw (#2888). The question keeps its state, with who withdrew it, when and why; workspace-points open leaves it out, and points-answer refuses it. Asking it again opens it again. Withdrawing answers nothing, so it needs no quorum. An answered question is not withdrawn: points-retract takes the answer back. " +
333
+ PROTOCOL,
334
+ inputSchema: {
335
+ type: "object",
336
+ properties: {
337
+ id: { type: "string", description: "The question's id, as workspace-points lists it." },
338
+ by: { type: "array", items: { type: "string" }, description: "Who withdraws the question (--by)." },
339
+ note: { type: "string", description: "Why, such as the question that asks about the subject now (--note)." },
340
+ kind: { type: "string", description: "The answer kind file. Without it, the declared answer kinds." },
341
+ dryRun: dryRunProp,
342
+ },
343
+ required: ["id", "by"],
344
+ },
345
+ },
329
346
  ];
330
347
 
331
348
  /** A tool call that can't be made as given: the client gets it as an error result, and nothing runs. */
@@ -581,6 +598,12 @@ export function createWorkspaceTools(options: WorkspaceToolsOptions): WorkspaceT
581
598
  if (!Array.isArray(by) || by.length === 0 || !by.every((b) => typeof b === "string" && b.trim() !== "")) throw new ToolInputError("by must list each person who takes the answer back");
582
599
  return retractAnswer({ cwd, id: str(params, "id", true)!, by: by as string[], note: str(params, "note"), kind: str(params, "kind"), dryRun: bool(params, "dryRun") });
583
600
  },
601
+ "points-withdraw": async (params) => {
602
+ const { withdrawQuestion } = await import("../../workspace/decide");
603
+ const by = params.by;
604
+ if (!Array.isArray(by) || by.length === 0 || !by.every((b) => typeof b === "string" && b.trim() !== "")) throw new ToolInputError("by must name who withdraws the question");
605
+ return withdrawQuestion({ cwd, id: str(params, "id", true)!, by: by as string[], note: str(params, "note"), kind: str(params, "kind"), dryRun: bool(params, "dryRun") });
606
+ },
584
607
  "records-close": async (params) => {
585
608
  const w = await write();
586
609
  const { closeRecord, RECORDS_CLOSE_SCHEMA_ID } = await import("../../workspace/records-close");
@@ -66,6 +66,28 @@ export const EFFECT_RECEIPT_ENTITY_TYPE = "Chant::EffectReceipt";
66
66
  */
67
67
  export const EXISTENCE_EXPECTATION = "chant.effect-receipt:exists";
68
68
 
69
+ const OUTPUTS_SEPARATOR = "+outputs=";
70
+
71
+ /**
72
+ * A receipt expectation with the digest of the step outputs its effect read
73
+ * appended (#3948). The effect step writes this value, so the next run fires
74
+ * again when one of those outputs changed, though the declared inputs did not.
75
+ */
76
+ export function withStepOutputs(expectation: string, outputs: string): string {
77
+ return `${expectation}${OUTPUTS_SEPARATOR}${outputs}`;
78
+ }
79
+
80
+ /**
81
+ * The part of a stored receipt value that the declared inputs determine: the
82
+ * value without any step-output digest {@link withStepOutputs} appended. The
83
+ * plan and `receiptStaleness` compare this part, since step outputs exist only
84
+ * while the Op runs.
85
+ */
86
+ export function declaredExpectation(value: string): string {
87
+ const at = value.indexOf(OUTPUTS_SEPARATOR);
88
+ return at < 0 ? value : value.slice(0, at);
89
+ }
90
+
69
91
  /**
70
92
  * An effect receipt declaration — a Declarable, so discovery collects it,
71
93
  * `chant list` shows it, and (unlike a secret declaration) serialization
@@ -72,6 +72,8 @@ export {
72
72
  GATE_SEAL_NAMESPACE,
73
73
  gateSealPayload,
74
74
  gateSealRefusal,
75
+ gateSealVersion,
76
+ sealedFieldsOf,
75
77
  sshKeyFingerprint,
76
78
  verifySshSignatureWith,
77
79
  type GateSigners,
@@ -176,12 +176,19 @@ export function resetLexiconModules(): void {
176
176
  * Import the module a path-declared lexicon names. Returns `undefined` when
177
177
  * `name` is package-backed, so a caller falls through to its package import.
178
178
  * An import failure is thrown with the declared path in the message.
179
+ *
180
+ * `typeScript: false` imports it with plain node, without registering tsx
181
+ * (#3276): tsx's ESM hooks run on a thread of their own, which costs the
182
+ * compiled CLI about 200 MB, so a caller whose module is JavaScript says so.
179
183
  */
180
- export async function importLexiconModule(name: string): Promise<Record<string, unknown> | undefined> {
184
+ export async function importLexiconModule(
185
+ name: string,
186
+ options: { typeScript?: boolean } = {},
187
+ ): Promise<Record<string, unknown> | undefined> {
181
188
  const path = modulePaths.get(name);
182
189
  if (path === undefined) return undefined;
183
190
  try {
184
- ensureTypeScriptLoader();
191
+ if (options.typeScript !== false) ensureTypeScriptLoader();
185
192
  return esModuleNamespace((await import(path)) as Record<string, unknown>);
186
193
  } catch (err) {
187
194
  throw new Error(
@@ -48,6 +48,13 @@
48
48
  * - `override`: an apply cites an override that no line holds.
49
49
  * - `malformed-line`: a line that is not a gate record. Its content is lost
50
50
  * to the trail, so it is said.
51
+ * - `carried-without-source`: an approval carried from a gate's alias
52
+ * (#3949, `carriedFrom`) whose cited line is not in the cited op's
53
+ * ledger. Checked only when that ledger is passed in.
54
+ *
55
+ * A carried approval's request is the pending fact of the name it was
56
+ * carried from, in that op's ledger. When that ledger is not passed in,
57
+ * neither gap is looked for, and the entry's `carriedFrom` says where to look.
51
58
  *
52
59
  * ## Sources and ids
53
60
  *
@@ -211,13 +218,15 @@ export interface GateAuditEntry {
211
218
  subject?: "approval" | "override";
212
219
  /** An applied entry: the approval entry it ran under, if one is held. An override-spent entry: the override entry. */
213
220
  under?: string;
221
+ /** An approval a run carried from one of the gate's aliases (#3949): the op, gate and time of the line it came from. */
222
+ carriedFrom?: { op: string; gate: string; at: string };
214
223
  /** Everything else the line carries, such as `members`, `via` or a product's own fields. */
215
224
  detail: Record<string, unknown>;
216
225
  source: GateAuditSource;
217
226
  }
218
227
 
219
228
  export interface GateAuditGap {
220
- kind: "approval-without-request" | "unsealed" | "approval" | "override" | "malformed-line";
229
+ kind: "approval-without-request" | "unsealed" | "approval" | "override" | "malformed-line" | "carried-without-source";
221
230
  op?: string;
222
231
  gate?: string;
223
232
  digest?: string;
@@ -284,7 +293,7 @@ type Row = Record<string, unknown>;
284
293
 
285
294
  const KNOWN = new Set([
286
295
  "version", "kind", "op", "gate", "resolvedBy", "relayedBy", "timestamp", "note", "url", "planDigest", "environment",
287
- "approver", "seal", "review", "runId", "expiresAt", "description", "wave", "heldBy", "approvalMode", "rules", "pr", "head", "reviewers",
296
+ "approver", "seal", "review", "runId", "expiresAt", "description", "wave", "heldBy", "approvalMode", "rules", "pr", "head", "reviewers", "carriedFrom",
288
297
  ]);
289
298
 
290
299
  const waveOf = (v: unknown): { number: number; name: string; op?: string } | undefined => {
@@ -335,6 +344,11 @@ function approverOf(r: Row): GateAuditEntry["approver"] {
335
344
  return { kind: a.kind, ...(roles ? { roles } : {}) };
336
345
  }
337
346
 
347
+ const carriedOf = (v: unknown): GateAuditEntry["carriedFrom"] => {
348
+ const c = v as { op?: unknown; gate?: unknown; timestamp?: unknown } | null;
349
+ return c && typeof c === "object" && typeof c.op === "string" && typeof c.gate === "string" && typeof c.timestamp === "string" ? { op: c.op, gate: c.gate, at: c.timestamp } : undefined;
350
+ };
351
+
338
352
  const drop = <T extends object>(o: T): T => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;
339
353
 
340
354
  const idOf = (...parts: unknown[]): string => contentDigest(canonicalJson(parts));
@@ -375,6 +389,19 @@ export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntr
375
389
  const entries: GateAuditEntry[] = [];
376
390
  const gaps: GateAuditGap[] = [];
377
391
 
392
+ // #3949: every ledger's requests and approvals by op, for an approval carried from another op's gate.
393
+ const byOp = new Map<string, { requests: Seen[]; approvals: Row[] }>();
394
+ for (const ledger of input.ledgers) {
395
+ const held = byOp.get(ledger.op) ?? { requests: [], approvals: [] };
396
+ byOp.set(ledger.op, held);
397
+ for (const line of ledger.lines) {
398
+ const r = rowOf(line);
399
+ if (!r || line.removed || typeof r.gate !== "string" || typeof r.timestamp !== "string") continue;
400
+ if (r.kind === "pending") held.requests.push({ op: ledger.op, gate: r.gate, at: time(r.timestamp), ...(str(r.planDigest) ? { digest: str(r.planDigest) } : {}) });
401
+ else held.approvals.push(r);
402
+ }
403
+ }
404
+
378
405
  for (const ledger of input.ledgers) {
379
406
  const path = ledger.path ?? `_gates/${ledger.op}.jsonl`;
380
407
  const parsed: { line: GateLedgerLine; n: number; r: Row; pending: boolean }[] = [];
@@ -453,12 +480,22 @@ export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntr
453
480
  at: String(r.timestamp),
454
481
  by: String(r.resolvedBy),
455
482
  seal,
456
- ...drop({ sealed, signer, committedBy: line.added?.by, relayedBy: str(r.relayedBy), approver: approverOf(r), note: str(r.note), url: str(r.url), review: reviewOf(r), rules }),
483
+ ...drop({ sealed, signer, committedBy: line.added?.by, relayedBy: str(r.relayedBy), approver: approverOf(r), note: str(r.note), url: str(r.url), review: reviewOf(r), rules, carriedFrom: carriedOf(r.carriedFrom) }),
457
484
  detail: extra,
458
485
  });
459
486
  entries.push(entry);
460
487
 
461
- if (!override) {
488
+ const carried = entry.carriedFrom;
489
+ if (!override && carried) {
490
+ const cited = byOp.get(carried.op);
491
+ const from = `${carried.op}/${carried.gate}`;
492
+ if (cited && !cited.approvals.some((a) => a.gate === carried.gate && time(String(a.timestamp)) === time(carried.at) && a.resolvedBy === r.resolvedBy)) {
493
+ gaps.push({ kind: "carried-without-source", op: ledger.op, gate, ...(digest ? { digest } : {}), detail: `${path} holds an approval of ${gate} by ${entry.by} carried from ${from} at ${carried.at}, and _gates/${carried.op}.jsonl holds no such approval` });
494
+ }
495
+ if (cited && !cited.requests.some((q) => q.gate === carried.gate && q.at <= time(carried.at) && compatible(q.digest, digest))) {
496
+ gaps.push({ kind: "approval-without-request", op: ledger.op, gate, ...(digest ? { digest } : {}), detail: `${path} holds an approval of ${gate} by ${entry.by} carried from ${from} at ${carried.at} with no pending fact of ${from}${digest ? " and digest" : ""} before it` });
497
+ }
498
+ } else if (!override) {
462
499
  const at = time(entry.at);
463
500
  const env = str(r.environment);
464
501
  const asked = requests.some((q) => q.gate === gate && q.at <= at && compatible(q.digest, digest) && (q.environment === undefined || env === undefined || q.environment === env));