@intentius/chant 0.124.8 → 0.124.10

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 (36) hide show
  1. package/dist/lifecycle/gate-audit.d.ts +6 -0
  2. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  3. package/dist/op/gate.d.ts +16 -1
  4. package/dist/op/gate.d.ts.map +1 -1
  5. package/dist/op/op-waves-notify.d.ts +2 -1
  6. package/dist/op/op-waves-notify.d.ts.map +1 -1
  7. package/dist/op/op-waves-run.d.ts +57 -7
  8. package/dist/op/op-waves-run.d.ts.map +1 -1
  9. package/dist/op/op-waves.d.ts +42 -0
  10. package/dist/op/op-waves.d.ts.map +1 -1
  11. package/dist/workspace/identity.d.ts +3 -2
  12. package/dist/workspace/identity.d.ts.map +1 -1
  13. package/dist/workspace/write-scope.d.ts +3 -2
  14. package/dist/workspace/write-scope.d.ts.map +1 -1
  15. package/package.json +1 -1
  16. package/src/codegen/publish-order.test.ts +20 -4
  17. package/src/components/pr-run.ts +1 -1
  18. package/src/lifecycle/converge-ledger.ts +1 -1
  19. package/src/lifecycle/gate-audit.test.ts +6 -0
  20. package/src/lifecycle/gate-audit.ts +8 -2
  21. package/src/lifecycle/gate-ledger.test.ts +37 -0
  22. package/src/lifecycle/gate-ledger.ts +1 -1
  23. package/src/lifecycle/git.ts +1 -1
  24. package/src/lifecycle/override-ledger.ts +1 -1
  25. package/src/lifecycle/run-ledger.ts +1 -1
  26. package/src/lifecycle/work-lease.ts +1 -1
  27. package/src/op/gate-review.test.ts +53 -1
  28. package/src/op/gate.ts +31 -9
  29. package/src/op/op-waves-notify.ts +13 -1
  30. package/src/op/op-waves-point.test.ts +254 -0
  31. package/src/op/op-waves-run.ts +234 -42
  32. package/src/op/op-waves.test.ts +100 -3
  33. package/src/op/op-waves.ts +68 -0
  34. package/src/workspace/identity.test.ts +4 -1
  35. package/src/workspace/identity.ts +4 -3
  36. package/src/workspace/write-scope.ts +4 -3
@@ -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
@@ -950,10 +1067,12 @@ async function settleOtherGate(ctx: WaveContext, hold: OtherGateHold): Promise<O
950
1067
  planDigest: hold.request.digest,
951
1068
  description: hold.request.description ?? `wave ${k} (${wave.name}) of ${spec.name} is held on it by ${hold.label}`,
952
1069
  now,
953
- ...(hold.request.pending ? { fields: hold.request.pending } : {}),
1070
+ // #3839: the wave a hold belongs to, as a field, whatever the hook's own description says.
1071
+ fields: { ...hold.request.pending, wave: { number: k, name: wave.name, op: spec.name } },
954
1072
  ...(neverOverMcp ? { neverOverMcp: true } : {}),
955
1073
  });
956
1074
  const base = { op, gate: hold.request.gate, digest: hold.request.digest, hook: hold.label };
1075
+ logRefused(log, `${op} / ${hold.request.gate}`, check);
957
1076
  if (check.satisfied) return { ...base, status: "approved", approvedBy: check.resolution.resolvedBy, approvedAt: check.resolution.timestamp };
958
1077
  const approve = approveCommand(op, hold.request.gate, undefined, hold.request.digest, mode === "sealed");
959
1078
  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 +1290,7 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1171
1290
  ctx.gateRule = { approval: decision.approval, base: decision.base, neverOverMcp: decision.neverOverMcp === true };
1172
1291
  if (decision.status === "waiting" || decision.status === "refused") {
1173
1292
  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 };
1293
+ return { ...result, exitCode: decision.status === "refused" ? refusedExit(decision) : 3 };
1175
1294
  }
1176
1295
  // The share runs the hooks its deciding job read at base, around its own slice.
1177
1296
  if (wave.discover) {
@@ -1213,6 +1332,12 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1213
1332
  const neverOverMcp = rules.neverOverMcp || spec.neverOverMcp === true;
1214
1333
  ctx.gateRule = { approval, base, neverOverMcp };
1215
1334
  noteHooksAtBase(ctx, rules);
1335
+ // #3836: a point at base that cannot be asked fails the wave; the ledger never stands in for it.
1336
+ if (rules.pointRefused) return failed(ctx, `wave ${k} (${wave.name}): ${rules.pointRefused}, so nothing in it was planned or applied`);
1337
+ const own = opWavePointAt(spec, wave.name);
1338
+ if (JSON.stringify(own) !== JSON.stringify(rules.point)) {
1339
+ 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`);
1340
+ }
1216
1341
  const holds: string[] = [];
1217
1342
  if (rules.hooksRefused) {
1218
1343
  holds.push(rules.hooksRefused);
@@ -1237,11 +1362,12 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1237
1362
  ...(rules.applyHooksRunBy === "apply" ? { applyHooksRunBy: "apply" as const } : {}),
1238
1363
  ...(spec.digest?.over === "changing" ? { digestOver: "changing" as const } : {}),
1239
1364
  ...(neverOverMcp ? { neverOverMcp: true as const } : {}),
1365
+ ...(rules.point ? { point: { ...rules.point } } : {}),
1240
1366
  };
1241
1367
  const before = await runHooks(ctx, "before-plan", rules.hooks["before-plan"], wave.runs);
1242
1368
  if (before.failed) return failed(ctx, `a hook before the plan failed, so nothing in wave ${k} was planned or applied`);
1243
1369
  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);
1370
+ const { members, pending, overriders, overridersSource, extras, waves: found } = planRuns(ctx, wave.runs, wave.discover === true);
1245
1371
  if (wave.discover) {
1246
1372
  wave = { ...wave, runs: members.map((m) => ({ target: m.member, ...(m.op !== spec.op ? { op: m.op } : {}) })) };
1247
1373
  ctx.wave = wave;
@@ -1267,7 +1393,7 @@ async function runOneWave(options: RunOpWaveOptions, k: number, prior: OpWaveDec
1267
1393
  for (const m of members) if (m.denied && byRun.get(m.member)) m.override = byRun.get(m.member);
1268
1394
  if (prior.overridesSpentAt) decision.overridesSpentAt = prior.overridesSpentAt;
1269
1395
  }
1270
- if (members.some((m) => m.denied && !m.override)) await settleDenials(ctx, decision, overriders);
1396
+ if (members.some((m) => m.denied && !m.override)) await settleDenials(ctx, decision, overriders, overridersSource);
1271
1397
  if (kept && prior.status === "approved" && decision.status !== "refused") {
1272
1398
  // #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
1399
  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 +1440,10 @@ async function decided(ctx: WaveContext, decision: OpWaveDecision, recordPath: s
1314
1440
  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
1441
  return { ...result, exitCode: 3 };
1316
1442
  }
1443
+ if (decision.status === "refused" && decision.refused?.reason === "point") {
1444
+ log(`Nothing in wave ${k} (${wave.name}) or after it ran: ${decision.refused.message}`);
1445
+ return { ...result, exitCode: 8 };
1446
+ }
1317
1447
  if (decision.status === "refused") {
1318
1448
  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
1449
  return { ...result, exitCode: 7 };
@@ -1381,7 +1511,7 @@ function relativeTo(cwd: string, path: string): string {
1381
1511
  * decision is `refused`, and each such run gets a pending fact on the
1382
1512
  * override ledger unless one stands.
1383
1513
  */
1384
- async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed: string[] | undefined): Promise<void> {
1514
+ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed: string[] | undefined, source?: string): Promise<void> {
1385
1515
  const { spec, k, wave, cwd, log, options } = ctx;
1386
1516
  const og = await import("../override-gate");
1387
1517
  const port = options.gates ?? gitGateLedgerPort({ cwd });
@@ -1403,7 +1533,7 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
1403
1533
  planDigest: m.planDigest,
1404
1534
  rules: m.denied.rules,
1405
1535
  overriders,
1406
- overridersPath: listed ? "the overriders the plans file names" : path,
1536
+ overridersPath: listed ? (source ?? "the overriders the plans file names") : path,
1407
1537
  mode: sealed ? "sealed" : "ledger",
1408
1538
  ...(signers ? { signers } : {}),
1409
1539
  now,
@@ -1430,7 +1560,7 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
1430
1560
  description: m.denied.reason ?? `wave ${k} (${wave.name}) of ${spec.name}: ${m.member}: plan ${m.planDigest} denied by ${og.sortedRules(m.denied.rules).join(", ")}`,
1431
1561
  });
1432
1562
  try {
1433
- await port.appendPending({ ...fact, members: [{ member: m.member, planDigest: m.planDigest }] } as typeof fact);
1563
+ await port.appendPending({ ...fact, members: [{ member: m.member, planDigest: m.planDigest }], wave: { number: k, name: wave.name, op: spec.name } } as typeof fact);
1434
1564
  } catch (err) {
1435
1565
  log(`${m.member}: the denial was not recorded for an override: ${err instanceof Error ? err.message : String(err)}`);
1436
1566
  }
@@ -1439,7 +1569,7 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
1439
1569
  }
1440
1570
  if (still.length > 0) {
1441
1571
  decision.status = "refused";
1442
- decision.refused = { reason: moved ? "override-moved" : "policy", runs: still.sort() };
1572
+ decision.refused = { reason: moved ? "override-moved" : "policy", runs: still.sort(), ...(listed && source ? { overridersSource: source } : {}) };
1443
1573
  }
1444
1574
  }
1445
1575
 
@@ -1471,6 +1601,11 @@ async function spendOverrides(ctx: WaveContext, decision: OpWaveDecision): Promi
1471
1601
  return true;
1472
1602
  }
1473
1603
 
1604
+ /** The exit code of a refused wave: 8 when its decision point refused it (#3836), 7 when the product's policy did (#3821). */
1605
+ function refusedExit(decision: OpWaveDecision): number {
1606
+ return decision.refused?.reason === "point" ? 8 : 7;
1607
+ }
1608
+
1474
1609
  function failed(ctx: WaveContext, line: string): RunOpWaveResult {
1475
1610
  ctx.log(line);
1476
1611
  ctx.result.exitCode = 1;
@@ -1483,6 +1618,7 @@ function failed(ctx: WaveContext, line: string): RunOpWaveResult {
1483
1618
  * asks for one, or when a hook holds the wave (#3762).
1484
1619
  */
1485
1620
  async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<void> {
1621
+ if (decision.point) return settlePointGate(options, ctx, decision, decision.point);
1486
1622
  const { spec, k, wave, cwd } = ctx;
1487
1623
  const { gate, digest, approval, base, policy, members } = decision;
1488
1624
  const { ledger, port } = await gatePort(options, cwd, gate, approval, base);
@@ -1507,6 +1643,7 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
1507
1643
  decision.approvedAt = check.resolution.timestamp;
1508
1644
  return;
1509
1645
  }
1646
+ logRefused(ctx.log, `wave ${k} (${wave.name})`, check);
1510
1647
  // #3821: what the wait is, for the after-gate hooks and the record.
1511
1648
  decision.pendingFact = check.pending;
1512
1649
  decision.recorded = check.recorded;
@@ -1535,6 +1672,54 @@ async function settleGate(options: RunOpWaveOptions, ctx: WaveContext, decision:
1535
1672
  decision.approve = approveCommand(spec.name, gate, undefined, digest, approval === "sealed");
1536
1673
  }
1537
1674
 
1675
+ /**
1676
+ * Decide a gate that asks a decision point (#3836), as an Op gate with a
1677
+ * `point` is decided (#3170): ask it with `gate.planDigest` set to the set
1678
+ * digest, then wait on an open question, pass on an answer the gate passes
1679
+ * on, or refuse the wave on any other. The wave's `approval` mode and spent
1680
+ * approvals do not apply: the point's deciders and quorum decide, and a
1681
+ * `chant approve` of the digest does not pass it.
1682
+ */
1683
+ async function settlePointGate(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision, point: NonNullable<OpWaveDecision["point"]>): Promise<void> {
1684
+ const { spec, k, wave, cwd, log } = ctx;
1685
+ const { gate, digest, policy, members } = decision;
1686
+ 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`);
1687
+ const { evaluatePointGate } = await import("../point-decision");
1688
+ const asker = options.points ?? (await import("./gate-point-run")).workspaceGatePointAsker(cwd);
1689
+ const held = decision.heldBy?.length ? `, held by ${decision.heldBy.join(", ")}` : "";
1690
+ const check = await evaluatePointGate(options.gates ?? gitGateLedgerPort({ cwd }), asker, {
1691
+ op: spec.name,
1692
+ gate,
1693
+ point: { name: point.name, ...(point.pass ? { pass: point.pass } : {}), ...(point.store ? { store: point.store } : {}) },
1694
+ ...(point.inputs ? { inputs: point.inputs } : {}),
1695
+ 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}`,
1696
+ ...(point.timeout ? { timeout: point.timeout } : {}),
1697
+ planDigest: digest,
1698
+ ...(options.now ? { now: options.now } : {}),
1699
+ });
1700
+ if (check.satisfied) {
1701
+ decision.status = "approved";
1702
+ decision.via = "point";
1703
+ decision.approvedBy = check.resolution.resolvedBy;
1704
+ decision.approvedAt = check.resolution.timestamp;
1705
+ point.answer = check.answer;
1706
+ return;
1707
+ }
1708
+ if ("refused" in check) {
1709
+ decision.status = "refused";
1710
+ decision.refused = { reason: "point", runs: members.map((m) => m.member), message: check.refused };
1711
+ point.answer = check.answer;
1712
+ return;
1713
+ }
1714
+ decision.status = "waiting";
1715
+ decision.pendingFact = check.pending;
1716
+ decision.recorded = check.recorded;
1717
+ decision.waitingSince = check.pending.timestamp;
1718
+ point.question = check.waiting;
1719
+ decision.approve = `chant workspace points answer ${check.waiting.id} --answer <answer> --by <your name>`;
1720
+ log(`wave ${k} (${wave.name}) asks decision point ${point.name} about ${digest}: question ${check.waiting.id} is ${check.waiting.state}`);
1721
+ }
1722
+
1538
1723
  /** Write the decision record, with every hook's outcome so far (#3804). */
1539
1724
  function writeRecord(recordPath: string, decision: OpWaveDecision, result: RunOpWaveResult): void {
1540
1725
  if (result.hooks.length > 0) decision.outcomes = [...result.hooks];
@@ -1557,6 +1742,13 @@ async function settleHolds(
1557
1742
  decision.heldBy = [...(decision.heldBy ?? []), ...holds];
1558
1743
  await settleGate(options, ctx, decision);
1559
1744
  if (recordPath) writeRecord(recordPath, decision, result);
1745
+ if (decision.status === "refused") {
1746
+ // #3836: the gate's decision point refused the held wave.
1747
+ if (recordPath) await runGateHooks(ctx, decision, recordPath);
1748
+ log(`wave ${k} (${wave.name}) is held by ${holds.join(", ")}; nothing applied: ${decision.refused?.message ?? "refused"}`);
1749
+ result.exitCode = refusedExit(decision);
1750
+ return false;
1751
+ }
1560
1752
  if (decision.status === "waiting") {
1561
1753
  // #3821: the gate decided again, and the wave now waits: the after-gate hooks hear so.
1562
1754
  if (recordPath) await runGateHooks(ctx, decision, recordPath);
@@ -1786,7 +1978,7 @@ async function applyStage(
1786
1978
  return;
1787
1979
  }
1788
1980
  if (waiting) {
1789
- result.exitCode = 3;
1981
+ result.exitCode = decision.status === "refused" ? refusedExit(decision) : 3;
1790
1982
  return;
1791
1983
  }
1792
1984
  if (result.failed.length > 0) {