@intentius/chant 0.124.7 → 0.124.9

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.
@@ -37,6 +37,15 @@
37
37
  * {@link runOpWaveApplyHooks}, and their outcomes come back through
38
38
  * `CHANT_HOOKS_RESULT`. A non-wave apply reads the same hooks at base with
39
39
  * {@link opWaveHooksAtBase}.
40
+ * - Point (#3836): with `point` at base (the wave's, else the spec's), a wave
41
+ * that needs an approval asks that decision point through
42
+ * `evaluatePointGate` (`../point-decision.ts`) with `gate.planDigest` set to
43
+ * its set digest: an open question waits (exit 3, the pending fact cites
44
+ * it, `approve` is the `points answer` line), an answer the gate passes on
45
+ * records the resolution that cites it and the wave goes on, and any other
46
+ * answer refuses the wave (`refused.reason: "point"`, exit 8). Once the
47
+ * question is answered, `chant run resume --op <name>` starts the job again
48
+ * (`pointGateResumable`, #3835).
40
49
  */
41
50
 
42
51
  import { execFileSync, spawnSync } from "node:child_process";
@@ -44,8 +53,10 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
44
53
  import { dirname, isAbsolute, join, posix } from "node:path";
45
54
  import { waveSetDigest, type WaveMember } from "../gated-waves";
46
55
  import type { ReviewApproval } from "../gate-decision";
47
- import { approveCommand, evaluateGate, gitGateLedgerPort, type GateDigestMismatch, type GateLedgerPort } from "./gate";
48
- import type { PendingGateRecord } from "../lifecycle/gate-ledger";
56
+ import { approveCommand, evaluateGate, gitGateLedgerPort, type GateCheck, type GateDigestMismatch, type GateLedgerPort } from "./gate";
57
+ import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger";
58
+ import type { GatePointAsker } from "../point-decision";
59
+ import type { WaitingPoint } from "./steward-points";
49
60
  import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
50
61
  import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
51
62
  import type { TrustPolicy } from "../workspace/trust/policy";
@@ -60,6 +71,8 @@ import {
60
71
  opWaveApplyHooksRunBy,
61
72
  opWaveGateName,
62
73
  opWaveHooksAt,
74
+ opWavePointAt,
75
+ opWavePointProblems,
63
76
  opWaveRecordPath,
64
77
  opWaveShare,
65
78
  type GateApprovalSource,
@@ -68,6 +81,7 @@ import {
68
81
  type OpWaveGatePolicy,
69
82
  type OpWaveHook,
70
83
  type OpWaveHookMoment,
84
+ type OpWavePoint,
71
85
  type OpWaveRun,
72
86
  type OpWavesSpec,
73
87
  } from "./op-waves";
@@ -169,8 +183,14 @@ export interface OpWaveDecision {
169
183
  */
170
184
  status: "approved" | "not-required" | "waiting" | "refused";
171
185
  approvedBy?: string;
172
- /** How the approval arrived: a resolution on the ledger, or the merged pull request's review (#3684). */
173
- via?: "ledger" | "pr-review";
186
+ /** How the approval arrived: a resolution on the ledger, the merged pull request's review (#3684), or a decision point's answer (#3836). */
187
+ via?: "ledger" | "pr-review" | "point";
188
+ /**
189
+ * The decision point the gate asks, as read at base (#3836), and what it
190
+ * said: the open `question` while the wave waits, the `answer` once it is
191
+ * answered (the one that passed, or the one that refused the wave).
192
+ */
193
+ point?: OpWavePoint & { question?: WaitingPoint; answer?: GateAnswerRef };
174
194
  /** The review that approved the wave, or why the review did not, under `pr-review`. */
175
195
  review?: { pr?: number; head?: string; approvers?: string[]; url?: string; reason?: string };
176
196
  /** The command that approves this digest, when waiting. */
@@ -208,8 +228,19 @@ export interface OpWaveDecision {
208
228
  waitingSince?: string;
209
229
  /** While waiting: the newest approval of this gate for another digest, which the plans moved away from. */
210
230
  mismatch?: GateDigestMismatch;
211
- /** Why a `refused` wave applies nothing (#3821): the runs the policy denied, and whether an override stands for an earlier plan or other rules. */
212
- refused?: { reason: "policy" | "override-moved"; runs: string[] };
231
+ /**
232
+ * Why a `refused` wave applies nothing: the runs the policy denied, and
233
+ * whether an override stands for an earlier plan or other rules (#3821,
234
+ * exit 7); or `point`, the gate's decision point answered with an answer
235
+ * it does not pass on (#3836, exit 8), with every run and the `message`.
236
+ */
237
+ refused?: {
238
+ reason: "policy" | "override-moved" | "point";
239
+ runs: string[];
240
+ message?: string;
241
+ /** Where the plans file said its overriders come from (#3848), when it did. */
242
+ overridersSource?: string;
243
+ };
213
244
  /**
214
245
  * A hook held the wave on another gate (#3821), such as a state migration
215
246
  * behind its own: that gate, its digest, and how it came out. While it
@@ -267,6 +298,20 @@ export interface OpWaveHeldOn {
267
298
  waitingSince?: string;
268
299
  recorded?: boolean;
269
300
  mismatch?: GateDigestMismatch;
301
+ /**
302
+ * The gates the same hook call held the wave on before this one, each
303
+ * approved, oldest first (#3847): its approved re-runs asked for this one.
304
+ */
305
+ after?: OpWaveHeldOnApproval[];
306
+ }
307
+
308
+ /** An approved hold on another gate (#3847), as `CHANT_HOLD_APPROVED` and `CHANT_HOLDS_APPROVED` carry it. */
309
+ export interface OpWaveHeldOnApproval {
310
+ op: string;
311
+ gate: string;
312
+ digest: string;
313
+ approvedBy: string;
314
+ approvedAt: string;
270
315
  }
271
316
 
272
317
  /** One hook's run (#3762). */
@@ -426,6 +471,10 @@ export interface OpWaveBaseRules {
426
471
  applyHooksRunBy: OpWaveApplyHooksRunner;
427
472
  /** The spec at base sets `neverOverMcp` (#3821). */
428
473
  neverOverMcp: boolean;
474
+ /** The decision point the wave's gate asks, as read at base (#3836): the wave's, else the spec's. */
475
+ point?: OpWavePoint;
476
+ /** Set when the point at base cannot be asked: the wave fails rather than fall back to the ledger (#3836). */
477
+ pointRefused?: string;
429
478
  }
430
479
 
431
480
  const noHooks = (): Record<OpWaveHookMoment, OpWaveHook[]> =>
@@ -457,12 +506,14 @@ export function readOpWavePolicy(
457
506
  // The spend rule is the spec's, so it holds for a wave the base does not have yet.
458
507
  const spend = (spec as { approvals?: { spend?: unknown } }).approvals?.spend === true;
459
508
  const neverOverMcp = (spec as { neverOverMcp?: unknown }).neverOverMcp === true;
509
+ // #3836: the gate's decision point, the spec's for a wave the base does not have yet.
510
+ const asks = pointAtBase(spec, name, `${specFile} at ${sha.slice(0, 12)}`);
460
511
  const wave = spec.waves.find((w) => w.name === name);
461
- if (!wave) return { ...absent, spend, neverOverMcp };
512
+ if (!wave) return { ...absent, spend, neverOverMcp, ...asks };
462
513
  const policy = wave.gate ?? "always";
463
514
  // An approval mode this chant does not know reads as sealed: the strictest, so an unknown rule never loosens a gate.
464
515
  const approval = wave.approval === undefined ? "ledger" : GATE_APPROVAL_SOURCES.includes(wave.approval) ? wave.approval : "sealed";
465
- const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant", neverOverMcp };
516
+ const rules: OpWaveBaseRules = { policy: OP_WAVE_GATE_POLICIES.includes(policy) ? policy : "always", approval, source: "base", base: sha, hooks: noHooks(), spend, applyHooksRunBy: "chant", neverOverMcp, ...asks };
466
517
  try {
467
518
  assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}`, spec.hooks);
468
519
  assertOpWaveHooks(`${specFile} at ${sha.slice(0, 12)}, wave "${name}"`, wave.hooks);
@@ -475,6 +526,15 @@ export function readOpWavePolicy(
475
526
  return rules;
476
527
  }
477
528
 
529
+ /** The point wave `name` asks at base (#3836), or why it cannot be asked. */
530
+ function pointAtBase(spec: OpWavesSpec, name: string, where: string): Pick<OpWaveBaseRules, "point" | "pointRefused"> {
531
+ const raw = spec.waves.find((w) => w.name === name)?.point ?? spec.point;
532
+ if (raw === undefined) return {};
533
+ const problems = opWavePointProblems(raw);
534
+ if (problems.length > 0) return { pointRefused: `the decision point at base cannot be asked (${where}: ${problems.join("; ")})` };
535
+ return { point: opWavePointAt(spec, name)! };
536
+ }
537
+
478
538
  /** Fill one argv template. A placeholder whose value is not given is left as it is. */
479
539
  export function opWaveArgv(
480
540
  template: readonly string[],
@@ -533,6 +593,8 @@ export interface RunOpWaveOptions {
533
593
  trust?: (base: string) => TrustPolicy;
534
594
  /** The signers at base, for a sealed override of a policy denial (#3821). Default: `gateSignersAt` from `@intentius/chant/gate-decision`. */
535
595
  signers?: (base: string) => GateSigners;
596
+ /** How a gate with a `point` asks it (#3836). Default: `workspaceGatePointAsker` over `cwd`. A tool that bundles chant passes `chantGatePointAsker`. */
597
+ points?: GatePointAsker;
536
598
  }
537
599
 
538
600
  export interface RunOpWaveResult {
@@ -541,7 +603,8 @@ export interface RunOpWaveResult {
541
603
  * approval, 4 a plan moved since the decision, 5 the apply applied nothing
542
604
  * because another run holds what it changes, 6 the apply stood down for a
543
605
  * newer run, 7 the product's policy denied a run and no override counts
544
- * (5, 6 and 7: #3821).
606
+ * (5, 6 and 7: #3821), 8 the gate's decision point was answered with an
607
+ * answer it does not pass on (#3836).
545
608
  */
546
609
  exitCode: number;
547
610
  /** Set when the apply applied nothing and stood down or was held by another run (#3821): not a failure. */
@@ -690,6 +753,10 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
690
753
  return m;
691
754
  });
692
755
  const overriders = Array.isArray(parsed) ? undefined : (parsed as { overriders?: unknown }).overriders;
756
+ const overridersSource = Array.isArray(parsed) ? undefined : (parsed as { overridersSource?: unknown }).overridersSource;
757
+ if (overridersSource !== undefined && (typeof overridersSource !== "string" || overridersSource.trim() === "")) {
758
+ throw new Error(`the plans file of wave ${k} (${plans}): \`overridersSource\` must be a label for where the overriders come from, such as "policy.override in terragucci.yml"`);
759
+ }
693
760
  if (overriders !== undefined && (!Array.isArray(overriders) || !overriders.every((o) => typeof o === "string"))) {
694
761
  throw new Error(`the plans file of wave ${k} (${plans}): \`overriders\` must be a list of principals`);
695
762
  }
@@ -697,6 +764,7 @@ function planRuns(at: { spec: OpWavesSpec; k: number; wave: OpWave; cwd: string;
697
764
  members,
698
765
  ...(pending ? { pending } : {}),
699
766
  ...(overriders ? { overriders: overriders as string[] } : {}),
767
+ ...(overriders && overridersSource ? { overridersSource: overridersSource as string } : {}),
700
768
  ...(extras ? { extras } : {}),
701
769
  ...(count !== undefined ? { waves: count as number } : {}),
702
770
  };
@@ -708,6 +776,8 @@ interface PlannedRuns {
708
776
  pending?: Record<string, unknown>;
709
777
  /** The plans file's overriders (#3821). */
710
778
  overriders?: string[];
779
+ /** A label for where those overriders come from, used in refusals (#3848). */
780
+ overridersSource?: string;
711
781
  /** Digest members that are not runs, such as the wave's cost (#3821). */
712
782
  extras?: WaveMember[];
713
783
  /** How many waves the product found (#3821), for a `rest` wave. */
@@ -870,7 +940,9 @@ const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], memb
870
940
  * Run one moment's hooks in the wave job (#3762), recording each outcome on
871
941
  * the result. A hook that held the wave on another gate (#3821) has that
872
942
  * gate settled here: approved, the hook runs again with
873
- * `CHANT_HOLD_APPROVED` and must pass; otherwise the wave waits on it.
943
+ * `CHANT_HOLD_APPROVED`; otherwise the wave waits on it. The re-run passes,
944
+ * or holds the wave on a further gate (#3847), settled the same way, up to
945
+ * {@link OP_WAVE_MAX_CHAINED_HOLDS} gates per hook call.
874
946
  */
875
947
  async function runHooks(
876
948
  ctx: WaveContext,
@@ -887,35 +959,74 @@ async function runHooks(
887
959
  ctx.result.hooks.push(...out.outcomes);
888
960
  const back: HooksOutcome = { holds: out.holds, ...(out.failed ? { failed: out.failed } : {}) };
889
961
  if (out.failed) return back;
890
- for (const hold of out.otherGates) {
891
- const held = await settleOtherGate(ctx, hold);
892
- if (held.status === "waiting") {
893
- back.waitingOn ??= held;
894
- continue;
895
- }
896
- const approvedEnv = { ...hold.env, CHANT_HOLD_APPROVED: JSON.stringify({ op: held.op, gate: held.gate, digest: held.digest, approvedBy: held.approvedBy, approvedAt: held.approvedAt }) };
897
- let code: number;
898
- try {
899
- code = ctx.exec(hold.argv, ctx.cwd, approvedEnv);
900
- } catch (err) {
901
- ctx.log(`${hold.label} did not run: ${err instanceof Error ? err.message : String(err)}`);
902
- code = 127;
903
- }
904
- ctx.result.hooks.push({ moment, name: hold.name, ...(hold.target ? { target: hold.target } : {}), exit: code, outcome: code === 0 ? "passed" : "failed" });
905
- if (code !== 0) {
906
- back.failed = `${hold.label} exited ${code} after ${held.approvedBy} approved ${held.gate}`;
907
- ctx.log(back.failed);
908
- return back;
962
+ for (const first of out.otherGates) {
963
+ // #3847: an approved re-run may hold the wave on a further gate (a second state migration that plans against the first one's states), settled in turn.
964
+ let hold = first;
965
+ const approved: OpWaveHeldOnApproval[] = [];
966
+ for (;;) {
967
+ const held = await settleOtherGate(ctx, hold);
968
+ if (held.status === "waiting") {
969
+ if (approved.length > 0) held.after = approved;
970
+ back.waitingOn ??= held;
971
+ break;
972
+ }
973
+ const now: OpWaveHeldOnApproval = { op: held.op, gate: held.gate, digest: held.digest, approvedBy: held.approvedBy!, approvedAt: held.approvedAt! };
974
+ approved.push(now);
975
+ const approvedEnv = {
976
+ ...hold.env,
977
+ CHANT_HOLD_APPROVED: JSON.stringify(now),
978
+ // Every hold this hook call was approved through so far, oldest first (#3847).
979
+ CHANT_HOLDS_APPROVED: JSON.stringify(approved),
980
+ };
981
+ const holdPath = join(ctx.cwd, hold.env.CHANT_HOLD!);
982
+ rmSync(holdPath, { force: true });
983
+ let code: number;
984
+ try {
985
+ code = ctx.exec(hold.argv, ctx.cwd, approvedEnv);
986
+ } catch (err) {
987
+ ctx.log(`${hold.label} did not run: ${err instanceof Error ? err.message : String(err)}`);
988
+ code = 127;
989
+ }
990
+ const target = hold.target ? { target: hold.target } : {};
991
+ if (code === 0) {
992
+ ctx.result.hooks.push({ moment, name: hold.name, ...target, exit: code, outcome: "passed" });
993
+ ctx.log(`${hold.label}: ${held.approvedBy} approved ${held.op} / ${held.gate} at ${held.digest}, and the hook ran again and passed`);
994
+ break;
995
+ }
996
+ const next = readHoldRequest(holdPath, ctx.log, hold.label);
997
+ const problem = !next
998
+ ? `${hold.label} exited ${code} after ${held.approvedBy} approved ${held.gate}`
999
+ : approved.some((a) => a.op === (next.op ?? ctx.spec.name) && a.gate === next.gate && a.digest === next.digest)
1000
+ ? `${hold.label} exited ${code} after ${held.approvedBy} approved ${held.gate}, and holds the wave again on ${next.op ?? ctx.spec.name} / ${next.gate} at ${next.digest}, which is already approved`
1001
+ : approved.length >= OP_WAVE_MAX_CHAINED_HOLDS
1002
+ ? `${hold.label} held the wave on ${approved.length} gates in turn and asks for one more; a hook call holds on at most ${OP_WAVE_MAX_CHAINED_HOLDS}`
1003
+ : undefined;
1004
+ if (problem) {
1005
+ ctx.result.hooks.push({ moment, name: hold.name, ...target, exit: code, outcome: "failed" });
1006
+ back.failed = problem;
1007
+ ctx.log(back.failed);
1008
+ return back;
1009
+ }
1010
+ ctx.result.hooks.push({ moment, name: hold.name, ...target, exit: code, outcome: "held" });
1011
+ ctx.log(`${hold.label}: ${held.approvedBy} approved ${held.op} / ${held.gate}; the hook ran again, exited ${code} and holds wave ${ctx.k} (${ctx.wave.name}) on gate ${next!.op ?? ctx.spec.name} / ${next!.gate}, digest ${next!.digest}`);
1012
+ hold = { ...hold, request: next! };
909
1013
  }
910
- ctx.log(`${hold.label}: ${held.approvedBy} approved ${held.op} / ${held.gate} at ${held.digest}, and the hook ran again and passed`);
911
1014
  }
912
1015
  return back;
913
1016
  }
914
1017
 
915
- /** The gate ledger a wave decides by: the job's port, with the signers at base as the rule when `approval` is `sealed`. */
1018
+ /** How many gates one hook call holds a wave on in turn (#3847): the first hold and the holds its approved re-runs ask for. */
1019
+ export const OP_WAVE_MAX_CHAINED_HOLDS = 10;
1020
+
1021
+ /**
1022
+ * The gate ledger a wave decides by: the job's port, its `identity.gates`
1023
+ * rule read at the wave's base (#3841), and the signers at base as a second
1024
+ * rule when `approval` is `sealed`.
1025
+ */
916
1026
  async function gatePort(options: RunOpWaveOptions, cwd: string, gate: string, approval: GateApprovalSource, base: string): Promise<{ ledger: GateLedgerPort; port: GateLedgerPort }> {
917
1027
  const ledger = options.gates ?? gitGateLedgerPort({ cwd });
918
- if (approval !== "sealed") return { ledger, port: ledger };
1028
+ const atBase = (g: string) => ledger.approvalRule?.(g, { base }) ?? Promise.resolve(null);
1029
+ if (approval !== "sealed") return { ledger, port: ledger.approvalRule ? { ...ledger, approvalRule: atBase } : ledger };
919
1030
  // #3684: only an approval sealed by a signer listed at base counts.
920
1031
  const review = await import("./gate-review");
921
1032
  const sealed = review.sealedApprovalRule(gate, (options.trust ?? ((b) => review.trustPolicyAt(cwd, b)))(base));
@@ -924,13 +1035,19 @@ async function gatePort(options: RunOpWaveOptions, cwd: string, gate: string, ap
924
1035
  port: {
925
1036
  ...ledger,
926
1037
  async approvalRule(g) {
927
- const own = (await ledger.approvalRule?.(g)) ?? null;
1038
+ const own = await atBase(g);
928
1039
  return g === gate ? review.bothRules(own, sealed) : own;
929
1040
  },
930
1041
  },
931
1042
  };
932
1043
  }
933
1044
 
1045
+ /** Log each approval of the digest a rule did not count (#3841), so a wait says why. */
1046
+ function logRefused(log: (line: string) => void, label: string, check: GateCheck): void {
1047
+ if (check.satisfied) return;
1048
+ for (const { resolution: r, reason } of check.refused ?? []) log(`${label}: the approval by ${r.resolvedBy} at ${r.timestamp} does not count: ${reason}`);
1049
+ }
1050
+
934
1051
  /**
935
1052
  * Settle a hold on another gate (#3821): an approval of that gate and digest
936
1053
  * under the wave's approval mode (`pr-review` counts the ledger alone, since
@@ -954,6 +1071,7 @@ async function settleOtherGate(ctx: WaveContext, hold: OtherGateHold): Promise<O
954
1071
  ...(neverOverMcp ? { neverOverMcp: true } : {}),
955
1072
  });
956
1073
  const base = { op, gate: hold.request.gate, digest: hold.request.digest, hook: hold.label };
1074
+ logRefused(log, `${op} / ${hold.request.gate}`, check);
957
1075
  if (check.satisfied) return { ...base, status: "approved", approvedBy: check.resolution.resolvedBy, approvedAt: check.resolution.timestamp };
958
1076
  const approve = approveCommand(op, hold.request.gate, undefined, hold.request.digest, mode === "sealed");
959
1077
  log(`wave ${k} (${wave.name}) waits on ${op} / ${hold.request.gate}, which ${hold.label} holds it on: nobody approved ${hold.request.digest}. Approve: ${approve}`);
@@ -1171,7 +1289,7 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1171
1289
  ctx.gateRule = { approval: decision.approval, base: decision.base, neverOverMcp: decision.neverOverMcp === true };
1172
1290
  if (decision.status === "waiting" || decision.status === "refused") {
1173
1291
  log(`wave ${k} (${wave.name}) is ${decision.status === "refused" ? "refused by its policy" : `waiting on ${decision.heldOn?.gate ?? decision.gate}`}; nothing applied. ${decision.approve ?? ""}`.trim());
1174
- return { ...result, exitCode: decision.status === "refused" ? 7 : 3 };
1292
+ return { ...result, exitCode: decision.status === "refused" ? refusedExit(decision) : 3 };
1175
1293
  }
1176
1294
  // The share runs the hooks its deciding job read at base, around its own slice.
1177
1295
  if (wave.discover) {
@@ -1213,6 +1331,12 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1213
1331
  const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
1214
1332
  ctx.gateRule = { approval, base, neverOverMcp };
1215
1333
  noteHooksAtBase(ctx, rules);
1334
+ // #3836: a point at base that cannot be asked fails the wave; the ledger never stands in for it.
1335
+ if (rules.pointRefused) return failed(ctx, `wave ${k} (${wave.name}): ${rules.pointRefused}, so nothing in it was planned or applied`);
1336
+ const own = opWavePointAt(spec, wave.name);
1337
+ if (JSON.stringify(own) !== JSON.stringify(rules.point)) {
1338
+ log(`wave ${k} (${wave.name}): its gate's decision point is read at ${base.slice(0, 12)} (${rules.point?.name ?? "none"}), and this change's differs (${own?.name ?? "none"}); the one at base holds`);
1339
+ }
1216
1340
  const holds: string[] = [];
1217
1341
  if (rules.hooksRefused) {
1218
1342
  holds.push(rules.hooksRefused);
@@ -1237,11 +1361,12 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1237
1361
  ...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
1238
1362
  ...(spec.digest?.over === "changing" ? { digestOver: "changing" as const } : {}),
1239
1363
  ...(neverOverMcp ? { neverOverMcp: true as const } : {}),
1364
+ ...(rules.point ? { point: { ...rules.point } } : {}),
1240
1365
  };
1241
1366
  const before = await runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
1242
1367
  if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
1243
1368
  if (before.waitingOn) return decided(ctx, decision, recordPath, before.waitingOn);
1244
- const { members, pending, overriders, extras, waves: found } = planRuns(ctx, wave.runs, wave.discover === true);
1369
+ const { members, pending, overriders, overridersSource, extras, waves: found } = planRuns(ctx, wave.runs, wave.discover === true);
1245
1370
  if (wave.discover) {
1246
1371
  wave = { ...wave, runs: members.map((m) => ({ target: m.member, ...(m.op !== spec.op ? { op: m.op } : {}) })) };
1247
1372
  ctx.wave = wave;
@@ -1267,7 +1392,7 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1267
1392
  for (const m of members) if (m.denied && byRun.get(m.member)) m.override = byRun.get(m.member);
1268
1393
  if (prior.overridesSpentAt) decision.overridesSpentAt = prior.overridesSpentAt;
1269
1394
  }
1270
- if (members.some((m) => m.denied && !m.override)) await settleDenials(ctx, decision, overriders);
1395
+ if (members.some((m) => m.denied && !m.override)) await settleDenials(ctx, decision, overriders, overridersSource);
1271
1396
  if (kept && prior.status === "approved" && decision.status !== "refused") {
1272
1397
  // #3821: planned again after the apply asked to, to the same digest: the approval this job found still holds, and is not spent twice.
1273
1398
  for (const key of ["via", "approvedBy", "approvedAt", "review", "spentAt"] as const) if (prior[key] !== undefined) (decision as unknown as Record<string, unknown>)[key] = prior[key];
@@ -1314,6 +1439,10 @@ async function decided(ctx: WaveContext, decision: OpWaveDecision, recordPath: s
1314
1439
  log(`Nothing in wave ${k} or after it ran. ${decision.heldOn ? `It waits on ${decision.heldOn.op} / ${decision.heldOn.gate}.` : "Read the plans, then"} approve: ${decision.approve} (with --resume, the approval also starts this job again)`);
1315
1440
  return { ...result, exitCode: 3 };
1316
1441
  }
1442
+ if (decision.status === "refused" && decision.refused?.reason === "point") {
1443
+ log(`Nothing in wave ${k} (${wave.name}) or after it ran: ${decision.refused.message}`);
1444
+ return { ...result, exitCode: 8 };
1445
+ }
1317
1446
  if (decision.status === "refused") {
1318
1447
  log(`Nothing in wave ${k} (${wave.name}) or after it ran: the policy denied ${decision.refused?.runs.join(", ")}, and no override counts for ${decision.refused?.runs.length === 1 ? "it" : "them"}.`);
1319
1448
  return { ...result, exitCode: 7 };
@@ -1381,7 +1510,7 @@ function relativeTo(cwd: string, path: string): string {
1381
1510
  * decision is `refused`, and each such run gets a pending fact on the
1382
1511
  * override ledger unless one stands.
1383
1512
  */
1384
- async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed: string[] | undefined): Promise<void> {
1513
+ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed: string[] | undefined, source?: string): Promise<void> {
1385
1514
  const { spec, k, wave, cwd, log, options } = ctx;
1386
1515
  const og = await import("../override-gate");
1387
1516
  const port = options.gates ?? gitGateLedgerPort({ cwd });
@@ -1403,7 +1532,7 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
1403
1532
  planDigest: m.planDigest,
1404
1533
  rules: m.denied.rules,
1405
1534
  overriders,
1406
- overridersPath: listed ? "the overriders the plans file names" : path,
1535
+ overridersPath: listed ? (source ?? "the overriders the plans file names") : path,
1407
1536
  mode: sealed ? "sealed" : "ledger",
1408
1537
  ...(signers ? { signers } : {}),
1409
1538
  now,
@@ -1439,7 +1568,7 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
1439
1568
  }
1440
1569
  if (still.length > 0) {
1441
1570
  decision.status = "refused";
1442
- decision.refused = { reason: moved ? "override-moved" : "policy", runs: still.sort() };
1571
+ decision.refused = { reason: moved ? "override-moved" : "policy", runs: still.sort(), ...(listed && source ? { overridersSource: source } : {}) };
1443
1572
  }
1444
1573
  }
1445
1574
 
@@ -1471,6 +1600,11 @@ async function spendOverrides(ctx: WaveContext, decision: OpWaveDecision): Promi
1471
1600
  return true;
1472
1601
  }
1473
1602
 
1603
+ /** The exit code of a refused wave: 8 when its decision point refused it (#3836), 7 when the product's policy did (#3821). */
1604
+ function refusedExit(decision: OpWaveDecision): number {
1605
+ return decision.refused?.reason === "point" ? 8 : 7;
1606
+ }
1607
+
1474
1608
  function failed(ctx: WaveContext, line: string): RunOpWaveResult {
1475
1609
  ctx.log(line);
1476
1610
  ctx.result.exitCode = 1;
@@ -1483,6 +1617,7 @@ function failed(ctx: WaveContext, line: string): RunOpWaveResult {
1483
1617
  * asks for one, or when a hook holds the wave (#3762).
1484
1618
  */
1485
1619
  async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
1620
+ if (decision.point) return settlePointGate(options, ctx, decision, decision.point);
1486
1621
  const { spec, k, wave, cwd } = ctx;
1487
1622
  const { gate, digest, approval, base, policy, members } = decision;
1488
1623
  const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
@@ -1507,6 +1642,7 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
1507
1642
  decision.approvedAt = check.resolution.timestamp;
1508
1643
  return;
1509
1644
  }
1645
+ logRefused(ctx.log, `wave ${k} (${wave.name})`, check);
1510
1646
  // #3821: what the wait is, for the after-gate hooks and the record.
1511
1647
  decision.pendingFact = check.pending;
1512
1648
  decision.recorded = check.recorded;
@@ -1535,6 +1671,54 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
1535
1671
  decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
1536
1672
  }
1537
1673
 
1674
+ /**
1675
+ * Decide a gate that asks a decision point (#3836), as an Op gate with a
1676
+ * `point` is decided (#3170): ask it with `gate.planDigest` set to the set
1677
+ * digest, then wait on an open question, pass on an answer the gate passes
1678
+ * on, or refuse the wave on any other. The wave's `approval` mode and spent
1679
+ * approvals do not apply: the point's deciders and quorum decide, and a
1680
+ * `chant approve` of the digest does not pass it.
1681
+ */
1682
+ async function settlePointGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision, point: NonNullable<OpWaveDecision["point"]>): Promise<void> {
1683
+ const { spec, k, wave, cwd, log } = ctx;
1684
+ const { gate, digest, policy, members } = decision;
1685
+ if (decision.approval !== "ledger") log(`wave ${k} (${wave.name}): its gate asks decision point ${point.name}, so its approval mode (${decision.approval}) does not apply`);
1686
+ const { evaluatePointGate } = await import("../point-decision");
1687
+ const asker = options.points ?? (await import("./gate-point-run")).workspaceGatePointAsker(cwd);
1688
+ const held = decision.heldBy?.length ? `, held by ${decision.heldBy.join(", ")}` : "";
1689
+ const check = await evaluatePointGate(options.gates ?? gitGateLedgerPort({ cwd }), asker, {
1690
+ op: spec.name,
1691
+ gate,
1692
+ point: { name: point.name, ...(point.pass ? { pass: point.pass } : {}), ...(point.store ? { store: point.store } : {}) },
1693
+ ...(point.inputs ? { inputs: point.inputs } : {}),
1694
+ description: (typeof decision.pending?.description === "string" ? decision.pending.description : undefined) ?? `wave ${k} (${wave.name}): ${members.length} run${members.length === 1 ? "" : "s"}, gate ${policy}, decision point ${point.name}${held}`,
1695
+ ...(point.timeout ? { timeout: point.timeout } : {}),
1696
+ planDigest: digest,
1697
+ ...(options.now ? { now: options.now } : {}),
1698
+ });
1699
+ if (check.satisfied) {
1700
+ decision.status = "approved";
1701
+ decision.via = "point";
1702
+ decision.approvedBy = check.resolution.resolvedBy;
1703
+ decision.approvedAt = check.resolution.timestamp;
1704
+ point.answer = check.answer;
1705
+ return;
1706
+ }
1707
+ if ("refused" in check) {
1708
+ decision.status = "refused";
1709
+ decision.refused = { reason: "point", runs: members.map((m) => m.member), message: check.refused };
1710
+ point.answer = check.answer;
1711
+ return;
1712
+ }
1713
+ decision.status = "waiting";
1714
+ decision.pendingFact = check.pending;
1715
+ decision.recorded = check.recorded;
1716
+ decision.waitingSince = check.pending.timestamp;
1717
+ point.question = check.waiting;
1718
+ decision.approve = `chant workspace points answer ${check.waiting.id} --answer <answer> --by <your name>`;
1719
+ log(`wave ${k} (${wave.name}) asks decision point ${point.name} about ${digest}: question ${check.waiting.id} is ${check.waiting.state}`);
1720
+ }
1721
+
1538
1722
  /** Write the decision record, with every hook's outcome so far (#3804). */
1539
1723
  function writeRecord(recordPath: string, decision: OpWaveDecision, result: RunOpWaveResult): void {
1540
1724
  if (result.hooks.length > 0) decision.outcomes = [...result.hooks];
@@ -1557,6 +1741,13 @@ async function settleHolds(
1557
1741
  decision.heldBy = [...(decision.heldBy ?? []), ...holds];
1558
1742
  await settleGate(options, ctx, decision);
1559
1743
  if (recordPath) writeRecord(recordPath, decision, result);
1744
+ if (decision.status === "refused") {
1745
+ // #3836: the gate's decision point refused the held wave.
1746
+ if (recordPath) await runGateHooks(ctx, decision, recordPath);
1747
+ log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}; nothing applied: ${decision.refused?.message ?? "refused"}`);
1748
+ result.exitCode = refusedExit(decision);
1749
+ return false;
1750
+ }
1560
1751
  if (decision.status === "waiting") {
1561
1752
  // #3821: the gate decided again, and the wave now waits: the after-gate hooks hear so.
1562
1753
  if (recordPath) await runGateHooks(ctx, decision, recordPath);
@@ -1786,7 +1977,7 @@ async function applyStage(
1786
1977
  return;
1787
1978
  }
1788
1979
  if (waiting) {
1789
- result.exitCode = 3;
1980
+ result.exitCode = decision.status === "refused" ? refusedExit(decision) : 3;
1790
1981
  return;
1791
1982
  }
1792
1983
  if (result.failed.length > 0) {
@@ -788,8 +788,8 @@ describe("gates and holds a product's wave needs (#3821)", () => {
788
788
  const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(opts.atBase ?? tg) });
789
789
  return { cwd, calls, envs, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
790
790
  }
791
- const run = (h: ReturnType<typeof harness>, gates = memoryGateLedgerPort(), spec: OpWavesSpec = tg) =>
792
- runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00.000Z", log: () => {} });
791
+ const run = (h: ReturnType<typeof harness>, gates = memoryGateLedgerPort(), spec: OpWavesSpec = tg, log: (m: string) => void = () => {}) =>
792
+ runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00.000Z", log });
793
793
 
794
794
  test("the spec checks after.gate hooks and override", () => {
795
795
  expect(() => assertOpWavesSpec(tg)).not.toThrow();
@@ -862,6 +862,21 @@ describe("gates and holds a product's wave needs (#3821)", () => {
862
862
  other.done();
863
863
  });
864
864
 
865
+ test("a plans file's overridersSource names the list in the refusal and the decision record (#3848)", async () => {
866
+ const mk = (extra: Record<string, unknown>) => harness({ vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2") } }, { extra });
867
+ const logs: string[] = [];
868
+ const h = mk({ overriders: ["github:dave"], overridersSource: "policy.override in terragucci.yml" });
869
+ const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "policy-override", gate: "vpc", resolvedBy: "github:carol", timestamp: "2026-10-10T00:00:00Z", planDigest: (await import("../override-gate")).overrideDigest("vpc", digest("1"), ["no-public-s3"]), note: "ok" }] });
870
+ const result = await run(h, gates, tg, (m) => logs.push(m));
871
+ expect(result.decision!.refused).toMatchObject({ reason: "policy", overridersSource: "policy.override in terragucci.yml" });
872
+ expect(logs.join("\n")).toContain("github:carol is not listed in policy.override in terragucci.yml at base");
873
+ h.done();
874
+ const d = mk({ overriders: ["github:dave"] });
875
+ const r2 = await run(d, memoryGateLedgerPort());
876
+ expect(r2.decision!.refused).not.toHaveProperty("overridersSource");
877
+ d.done();
878
+ });
879
+
865
880
  test("a hook holds the wave on another gate: it waits on that gate's digest, then runs again once approved", async () => {
866
881
  const migrate = { name: "migrate", command: ["migrate", "{runs}"], onFailure: "approve" as const };
867
882
  const spec: OpWavesSpec = { ...tg, hooks: { before: { plan: [migrate] }, after: tg.hooks!.after } };
@@ -891,6 +906,84 @@ describe("gates and holds a product's wave needs (#3821)", () => {
891
906
  expect(result.hooks.filter((o) => o.name === "migrate").map((o) => o.outcome)).toEqual(["held", "passed"]);
892
907
  again.done();
893
908
  });
909
+
910
+ test("an approved re-run holds the wave on a further gate: two migrations wait, then apply in turn (#3847)", async () => {
911
+ const migrate = { name: "migrate", command: ["migrate", "{runs}"], onFailure: "approve" as const };
912
+ const spec: OpWavesSpec = { ...tg, hooks: { before: { plan: [migrate] }, after: tg.hooks!.after } };
913
+ // The first migration holds on its gate; once approved it applies, and the second (whose digest is known only now) holds on its own.
914
+ const applied: string[] = [];
915
+ const hook = (argv: string[], env: Record<string, string>, dir: string) => {
916
+ if (argv[0] !== "migrate") return 0;
917
+ const done = env.CHANT_HOLDS_APPROVED ? (JSON.parse(env.CHANT_HOLDS_APPROVED) as Array<{ gate: string }>).map((a) => a.gate) : [];
918
+ for (const gate of done) if (!applied.includes(gate)) applied.push(gate);
919
+ const next = ["split-queues", "move-topics"].find((g) => !done.includes(g));
920
+ if (!next) return 0;
921
+ writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: next, digest: digest(next === "split-queues" ? "m" : "n") }));
922
+ return 3;
923
+ };
924
+ const plans = { vpc: { planDigest: digest("1"), empty: true }, dns: { planDigest: digest("2"), empty: true } };
925
+ const first = { version: 1 as const, op: "tf-migrate", gate: "split-queues", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: digest("m") };
926
+
927
+ // Only the first is approved: it applies, and the wave waits on the second (exit 3), naming it.
928
+ const h = harness(plans, { hook, atBase: spec });
929
+ const gates = memoryGateLedgerPort({ resolutions: [first] });
930
+ const waiting = await run(h, gates, spec);
931
+ expect(waiting.exitCode).toBe(3);
932
+ expect(waiting.decision).toMatchObject({
933
+ status: "waiting",
934
+ approve: `chant approve tf-migrate move-topics --plan ${digest("n")}`,
935
+ heldOn: { op: "tf-migrate", gate: "move-topics", digest: digest("n"), status: "waiting", after: [{ gate: "split-queues", digest: digest("m"), approvedBy: "alice" }] },
936
+ });
937
+ expect(gates.appended).toEqual([expect.objectContaining({ op: "tf-migrate", gate: "move-topics", planDigest: digest("n") })]);
938
+ expect(applied).toEqual(["split-queues"]);
939
+ expect(h.calls.some((c) => c.startsWith("tg plan"))).toBe(false);
940
+ expect(waiting.hooks.filter((o) => o.name === "migrate").map((o) => o.outcome)).toEqual(["held", "held"]);
941
+ h.done();
942
+
943
+ // Both approved: the hook runs three times, the last with both approvals, and the wave applies.
944
+ applied.length = 0;
945
+ const again = harness(plans, { hook, atBase: spec });
946
+ const both = memoryGateLedgerPort({ resolutions: [first, { ...first, gate: "move-topics", resolvedBy: "bob", planDigest: digest("n") }] });
947
+ const result = await run(again, both, spec);
948
+ expect(result.exitCode).toBe(0);
949
+ expect(applied).toEqual(["split-queues", "move-topics"]);
950
+ const reruns = again.envs.filter((_, i) => again.calls[i]!.startsWith("migrate"));
951
+ expect(reruns).toHaveLength(3);
952
+ expect(JSON.parse(reruns[2]!.CHANT_HOLD_APPROVED!)).toMatchObject({ gate: "move-topics", approvedBy: "bob" });
953
+ expect(JSON.parse(reruns[2]!.CHANT_HOLDS_APPROVED!).map((a: { gate: string }) => a.gate)).toEqual(["split-queues", "move-topics"]);
954
+ expect(result.hooks.filter((o) => o.name === "migrate").map((o) => o.outcome)).toEqual(["held", "held", "passed"]);
955
+ again.done();
956
+ });
957
+
958
+ test("an approved re-run that holds again on the same gate and digest, or past the bound, fails the wave (#3847)", async () => {
959
+ const migrate = { name: "migrate", command: ["migrate", "{runs}"], onFailure: "approve" as const };
960
+ const spec: OpWavesSpec = { ...tg, hooks: { before: { plan: [migrate] }, after: tg.hooks!.after } };
961
+ const plans = { vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") } };
962
+ const same = (argv: string[], env: Record<string, string>, dir: string) => {
963
+ if (argv[0] !== "migrate") return 0;
964
+ writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: "split-queues", digest: digest("m") }));
965
+ return 3;
966
+ };
967
+ const h = harness(plans, { hook: same, atBase: spec });
968
+ const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-migrate", gate: "split-queues", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: digest("m") }] });
969
+ expect((await run(h, gates, spec)).exitCode).toBe(1);
970
+ expect(h.calls.filter((c) => c.startsWith("migrate"))).toHaveLength(2);
971
+ h.done();
972
+
973
+ const { OP_WAVE_MAX_CHAINED_HOLDS } = await import("./op-waves-run");
974
+ let n = 0;
975
+ const endless = (argv: string[], env: Record<string, string>, dir: string) => {
976
+ if (argv[0] !== "migrate") return 0;
977
+ writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: `step-${n}`, digest: digest("m") }));
978
+ n++;
979
+ return 3;
980
+ };
981
+ const many = harness(plans, { hook: endless, atBase: spec });
982
+ const all = memoryGateLedgerPort({ resolutions: Array.from({ length: 20 }, (_, i) => ({ version: 1 as const, op: "tf-migrate", gate: `step-${i}`, resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: digest("m") })) });
983
+ expect((await run(many, all, spec)).exitCode).toBe(1);
984
+ expect(many.calls.filter((c) => c.startsWith("migrate"))).toHaveLength(OP_WAVE_MAX_CHAINED_HOLDS + 1);
985
+ many.done();
986
+ });
894
987
  });
895
988
 
896
989
  describe("discovered waves, a cost member and applies that apply nothing (#3821)", () => {