@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
@@ -441,3 +441,40 @@ describe("applied approvals on chant/lifecycle (#3784)", () => {
441
441
  });
442
442
  });
443
443
  });
444
+
445
+ describe("gate ledger newline termination (#3849)", () => {
446
+ test("every append ends the file in a newline, with no blank or merged lines", async () => {
447
+ await withTestDir(async (dir) => {
448
+ await initRepo(dir);
449
+ await appendPendingGate({ op: "nl-op", gate: "g1", planDigest: "sha256:" + "a".repeat(64) } as never, { cwd: dir });
450
+ const one = await readBlobFromPath("_gates", "nl-op.jsonl", { cwd: dir });
451
+ expect(one!.endsWith("\n")).toBe(true);
452
+ await appendGateResolution(
453
+ { op: "nl-op", gate: "g1", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
454
+ { cwd: dir },
455
+ );
456
+ const raw = (await readBlobFromPath("_gates", "nl-op.jsonl", { cwd: dir }))!;
457
+ expect(raw.endsWith("\n")).toBe(true);
458
+ expect(raw.endsWith("\n\n")).toBe(false);
459
+ const lines = raw.slice(0, -1).split("\n");
460
+ expect(lines).toHaveLength(2);
461
+ for (const l of lines) expect(() => JSON.parse(l)).not.toThrow();
462
+ const { malformed } = await readGateLedger("nl-op", { cwd: dir });
463
+ expect(malformed).toBe(0);
464
+ });
465
+ });
466
+
467
+ test("a legacy ledger whose last line has no newline still reads and appends cleanly", async () => {
468
+ await withTestDir(async (dir) => {
469
+ await initRepo(dir);
470
+ await writeBlobToPath("_gates", "legacy.jsonl", JSON.stringify({ kind: "x" }), "seed", { cwd: dir });
471
+ await appendGateResolution(
472
+ { op: "legacy", gate: "g", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
473
+ { cwd: dir },
474
+ );
475
+ const raw = (await readBlobFromPath("_gates", "legacy.jsonl", { cwd: dir }))!;
476
+ expect(raw.endsWith("\n")).toBe(true);
477
+ expect(raw.split("\n").filter(Boolean)).toHaveLength(2);
478
+ });
479
+ });
480
+ });
@@ -367,7 +367,7 @@ async function appendGateLine(
367
367
  try {
368
368
  const priorSha = await readPathSha(DIR, filename(record.op), opts);
369
369
  const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
370
- const content = existing ? `${existing.replace(/\n$/, "")}\n${json}` : json;
370
+ const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${json}\n`;
371
371
  return await writeBlobToPath(DIR, filename(record.op), content, message, {
372
372
  ...opts,
373
373
  expectPriorPathSha: priorSha,
@@ -485,7 +485,7 @@ export async function appendReleaseRecordLine(
485
485
  try {
486
486
  const priorSha = await readPathSha(environment, filename, opts);
487
487
  const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
488
- const content = existing ? `${existing.replace(/\n$/, "")}\n${recordJson}` : recordJson;
488
+ const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${recordJson}\n`;
489
489
  return await writeBlobToPath(environment, filename, content, "Release record", {
490
490
  ...opts,
491
491
  expectPriorPathSha: priorSha,
@@ -55,7 +55,7 @@ export async function recordOverridesApplied(records: readonly AppliedOverrideRe
55
55
  try {
56
56
  const priorSha = await readPathSha(APPLIED_DIR, APPLIED_FILE, opts);
57
57
  const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
58
- const content = existing ? `${existing.replace(/\n$/, "")}\n${lines}` : lines;
58
+ const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${lines}\n`;
59
59
  return await writeBlobToPath(APPLIED_DIR, APPLIED_FILE, content, `Applied under override: ${records.map((r) => r.gate).join(", ")}`, {
60
60
  ...opts,
61
61
  expectPriorPathSha: priorSha,
@@ -160,7 +160,7 @@ export async function appendRunRecord(
160
160
  try {
161
161
  const priorSha = await readPathSha(record.env, filename, opts);
162
162
  const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
163
- const content = existing ? `${existing.replace(/\n$/, "")}\n${json}` : json;
163
+ const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${json}\n`;
164
164
  const commit = await writeBlobToPath(record.env, filename, content, `Op run record: ${record.op}`, {
165
165
  ...opts,
166
166
  expectPriorPathSha: priorSha,
@@ -408,7 +408,7 @@ async function appendLeaseHistory(record: LeaseHistoryRecord, opts: { cwd: strin
408
408
  try {
409
409
  const priorSha = await readPathSha(LEASES_DIR, file, opts);
410
410
  const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
411
- const content = existing ? `${existing.replace(/\n$/, "")}\n${json}` : json;
411
+ const content = `${existing ? `${existing.replace(/\n$/, "")}\n` : ""}${json}\n`;
412
412
  commit = await writeBlobToPath(LEASES_DIR, file, content, `Work lease ${record.event}: ${record.item} (${record.holder})`, {
413
413
  ...opts,
414
414
  expectPriorPathSha: priorSha,
@@ -7,7 +7,7 @@ import { waveSetDigest } from "../gated-waves";
7
7
  import { sealGateApproval } from "../workspace/trust/seal";
8
8
  import { TestRepo, hasSshKeygen } from "../workspace/trust/test-repo";
9
9
  import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
10
- import { memoryGateLedgerPort } from "./gate";
10
+ import { gitGateLedgerPort, memoryGateLedgerPort } from "./gate";
11
11
  import { githubReviews, forgejoReviews, gitlabReviews, type MergedReview, type ReviewSource } from "./gate-review";
12
12
  import type { OpWavesSpec } from "./op-waves";
13
13
  import { recordOpWaveHeadPlans, runOpWave, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
@@ -223,3 +223,55 @@ describe.skipIf(!hasSshKeygen)("a sealed wave (#3684)", () => {
223
223
  expect(await run([approval("alice", alice.file)])).toMatchObject({ exitCode: 0, applied: ["prod"], decision: { status: "approved", approvedBy: "alice" } });
224
224
  });
225
225
  });
226
+
227
+ describe.skipIf(!hasSshKeygen)("identity.gates is read at the wave's --base (#3841)", () => {
228
+ const repo = new TestRepo("3841-base");
229
+ const alice = repo.key("alice");
230
+ afterAll(() => rmSync(join(repo.dir, ".."), { recursive: true, force: true }));
231
+ const GATE = "migrations-wave-1";
232
+ const waves = (approval: "ledger" | "sealed"): OpWavesSpec => ({ ...spec("sealed"), waves: [{ name: "prod", runs: [{ target: "prod" }], approval }] });
233
+ const workspace = (gates?: Record<string, object>) => JSON.stringify({ name: "acme", schema: 1, members: [], ...(gates ? { identity: { gates } } : {}) });
234
+
235
+ // Two commits on main: --base names the first, and main (the base a bare read falls back to) is the second.
236
+ function commits(atBase: string, atMain: string, approval: "ledger" | "sealed"): string {
237
+ repo.write("waves.json", JSON.stringify(waves(approval)));
238
+ repo.write(".chant/allowed_signers", `alice ${alice.pub}\n`);
239
+ repo.write("chant.workspace.json", atBase);
240
+ const base = repo.commit(`base (${approval})`);
241
+ repo.write("chant.workspace.json", atMain);
242
+ repo.commit(`main (${approval})`);
243
+ return base;
244
+ }
245
+
246
+ const plan = digest("a");
247
+ const set = waveSetDigest([{ member: "prod", planDigest: plan }]);
248
+ const approval = (by: string, keyFile?: string): GateResolutionRecord => {
249
+ const a = { op: "migrations", gate: GATE, resolvedBy: by, timestamp: "2026-10-10T00:00:00Z", planDigest: set };
250
+ return { version: 1, ...a, ...(keyFile ? { seal: sealGateApproval(keyFile, a) } : {}) };
251
+ };
252
+ const run = async (approvalMode: "ledger" | "sealed", base: string, resolutions: GateResolutionRecord[]) => {
253
+ const h = harness(plan, repo.dir);
254
+ const lines: string[] = [];
255
+ // The memory ledger, with the real port's identity.gates rule.
256
+ const gates = { ...memoryGateLedgerPort({ resolutions }), approvalRule: gitGateLedgerPort({ cwd: repo.dir }).approvalRule };
257
+ const result = await runOpWave({ spec: waves(approvalMode), specFile: "waves.json", wave: 1, base, cwd: repo.dir, exec: h.exec, gates, now: "2026-10-10T01:00:00Z", log: (l) => lines.push(l) });
258
+ return { result, lines };
259
+ };
260
+
261
+ test("a gate --base seals holds the wave, though main does not seal it, and the log says why", async () => {
262
+ const base = commits(workspace({ [GATE]: {} }), workspace(), "ledger");
263
+ expect(repo.head()).not.toBe(base);
264
+ const { result, lines } = await run("ledger", base, [approval("alice")]);
265
+ expect(result).toMatchObject({ exitCode: 3, applied: [], decision: { status: "waiting", base } });
266
+ expect(lines.join("\n")).toMatch(/the approval by alice at 2026-10-10T00:00:00Z does not count: .*not signed/);
267
+ // A seal by a signer at base passes both.
268
+ expect((await run("ledger", base, [approval("alice", alice.file)])).result).toMatchObject({ exitCode: 0, applied: ["prod"] });
269
+ });
270
+
271
+ test("a rule main adds does not judge a wave whose --base has none", async () => {
272
+ // At main the gate needs a class no pinned package supplies, which nothing passes.
273
+ const base = commits(workspace(), workspace({ [GATE]: { class: "operator" } }), "sealed");
274
+ const { result } = await run("sealed", base, [approval("alice", alice.file)]);
275
+ expect(result).toMatchObject({ exitCode: 0, applied: ["prod"], decision: { status: "approved", approvedBy: "alice" } });
276
+ });
277
+ });
package/src/op/gate.ts CHANGED
@@ -90,9 +90,11 @@ export interface GateLedgerPort {
90
90
  /**
91
91
  * The rule a workspace sets for approvals of `gate` (#3163, ws-080), or
92
92
  * null when none applies: the declaration's `identity.gates`, read at base.
93
+ * `at.base` names that commit, as an op wave reads every rule at its
94
+ * `--base` (#3841); absent, origin/HEAD, else `main`, else `master`.
93
95
  * Absent, every approval is judged as before.
94
96
  */
95
- approvalRule?(gate: string): Promise<GateApprovalRule | null>;
97
+ approvalRule?(gate: string, at?: { base?: string }): Promise<GateApprovalRule | null>;
96
98
  /** The approvals a wave applied under (#3784), from `_gates/<op>/applied.jsonl`. Absent on a port that never spends approvals. */
97
99
  readApplied?(op: string): Promise<AppliedGateRecord[]>;
98
100
  /** Record that a wave is about to apply under an approval (#3784), which spends it. */
@@ -132,10 +134,10 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
132
134
  const { resolutions, pending } = await readGateLedger(op, opts);
133
135
  return { resolutions, pending };
134
136
  },
135
- async approvalRule(gate) {
137
+ async approvalRule(gate, at) {
136
138
  // Loaded on demand: the rule lives in the workspace modules, which import the Op modules.
137
139
  const { gateAdmission } = await import("../workspace/identity");
138
- return gateAdmission(opts?.cwd ?? process.cwd(), gate);
140
+ return gateAdmission(opts?.cwd ?? process.cwd(), gate, at?.base);
139
141
  },
140
142
  async appendResolution(input) {
141
143
  const { record } = await appendGateResolution(input, opts);
@@ -355,8 +357,20 @@ export type GateCheck =
355
357
  mismatch?: GateDigestMismatch;
356
358
  /** Present on a gate with a quorum (#2508): who has approved this plan so far. */
357
359
  quorum?: GateQuorumProgress;
360
+ /**
361
+ * Approvals of this gate and plan, newer than the standing pending fact,
362
+ * that the port's approval rule did not count, and why (#3841), oldest
363
+ * first. Absent when there are none.
364
+ */
365
+ refused?: GateRefusedApproval[];
358
366
  };
359
367
 
368
+ /** An approval the port's {@link GateLedgerPort.approvalRule} did not count, and why. */
369
+ export interface GateRefusedApproval {
370
+ resolution: GateResolutionRecord;
371
+ reason: string;
372
+ }
373
+
360
374
  /** How a recorded approval reads: its own `approver`, or for a record written before #2508, a human unless a model-authored channel wrote it. */
361
375
  export function approverOf(record: GateResolutionRecord): GateApprover {
362
376
  if (record.approver) return record.approver;
@@ -527,16 +541,24 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
527
541
  const pending = env === undefined ? ledger.pending : ledger.pending.filter((p) => p.environment === env);
528
542
  // #3163: a gate the workspace's identity.gates names counts only signed approvals from its class.
529
543
  const rule = (await port.approvalRule?.(input.gate)) ?? null;
544
+ const standing = latestPendingGate(pending, input.gate);
545
+ const since = standing?.timestamp ?? EPOCH;
546
+ // #3841: an approval of this plan the rule refuses is named, so a wait says why.
547
+ const refused: GateRefusedApproval[] = [];
530
548
  const resolutions = (
531
549
  env === undefined
532
550
  ? ledger.resolutions
533
551
  : ledger.resolutions.filter((r) => r.environment === undefined || r.environment === env)
534
552
  )
535
- .filter((r) => rule === null || r.gate !== input.gate || rule.refuses(r) === null)
536
- .filter((r) => input.spent === undefined || r.gate !== input.gate || !isApprovalSpent(input.spent, r));
537
-
538
- const standing = latestPendingGate(pending, input.gate);
539
- const since = standing?.timestamp ?? EPOCH;
553
+ .filter((r) => input.spent === undefined || r.gate !== input.gate || !isApprovalSpent(input.spent, r))
554
+ .filter((r) => {
555
+ if (rule === null || r.gate !== input.gate) return true;
556
+ const reason = rule.refuses(r);
557
+ if (reason === null) return true;
558
+ if (samePlanDigest(r.planDigest, input.planDigest) && new Date(r.timestamp).getTime() >= new Date(since).getTime()) refused.push({ resolution: r, reason });
559
+ return false;
560
+ });
561
+ refused.sort((a, b) => new Date(a.resolution.timestamp).getTime() - new Date(b.resolution.timestamp).getTime());
540
562
 
541
563
  let mismatched: GateResolutionRecord | undefined;
542
564
  let quorum: GateQuorumProgress | undefined;
@@ -579,7 +601,7 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
579
601
  ...(env !== undefined ? { environment: env } : {}),
580
602
  }
581
603
  : undefined;
582
- const asMismatch = { ...(mismatch ? { mismatch } : {}), ...(quorum ? { quorum } : {}) };
604
+ const asMismatch = { ...(mismatch ? { mismatch } : {}), ...(quorum ? { quorum } : {}), ...(refused.length > 0 ? { refused } : {}) };
583
605
 
584
606
  // A standing fact only stands for the plan it was recorded against. When
585
607
  // the plan has moved, re-recording is what gives `chant approve` (which
@@ -7,7 +7,8 @@
7
7
  * `refused`. A wave whose plan, apply or stage hook failed (exit 1) sends
8
8
  * `failed`. A wave the product's policy refused (exit 7, #3821) sends
9
9
  * `refused` with the runs it denied, and one whose apply was held by another
10
- * run (exit 5) sends `refused` with status `held`; one that stood down for a
10
+ * run (exit 5) sends `refused` with status `held`, and one whose decision
11
+ * point refused it (exit 8, #3836) sends `refused` with status `point`; one that stood down for a
11
12
  * newer run (exit 6) sends nothing. A share job of a waiting wave sends nothing, since its deciding
12
13
  * job already did, unless a hook in the share job itself held it (#3762). Delivery is `../notify.ts`: it never throws, and the job's exit code
13
14
  * is what the wave decided.
@@ -58,6 +59,17 @@ export function opWaveNotifyFields(spec: OpWavesSpec, k: number, outcome: OpWave
58
59
  : {}),
59
60
  };
60
61
  }
62
+ if (result.exitCode === 8 && decision?.refused?.reason === "point") {
63
+ // #3836: the gate's decision point was answered with an answer it does not pass on.
64
+ return {
65
+ ...base,
66
+ event: "refused",
67
+ status: "point",
68
+ summary: `${label} refused: decision point ${decision.point?.name ?? ""} answered ${JSON.stringify(decision.point?.answer?.answer ?? null)}`,
69
+ targets: decision.refused.runs,
70
+ ...(decision.refused.message ? { reason: decision.refused.message } : {}),
71
+ };
72
+ }
61
73
  if (result.exitCode === 7 && decision?.refused) {
62
74
  // #3821: the product's policy denied a run, and no override counts for it.
63
75
  return {
@@ -0,0 +1,254 @@
1
+ /**
2
+ * A wave gate that asks a workspace decision point (#3836), against a stub
3
+ * asker and an in-memory gate ledger: the point is read at base, an open
4
+ * question waits (exit 3) with a pending fact citing it and the `points
5
+ * answer` line as `approve`, a passing answer for the set digest lets the
6
+ * wave apply under a resolution that cites it, any other answer refuses the
7
+ * wave (exit 8), and the waiting pending fact is what `chant run resume`
8
+ * resumes once the question is answered (#3835).
9
+ */
10
+
11
+ import { afterEach, describe, expect, test, vi } from "vitest";
12
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ import { assertOpWavesSpec, type OpWavesSpec } from "./op-waves";
16
+ import { runOpWave, type OpWaveDecision, type OpWaveExec, type OpWaveShowAtBase } from "./op-waves-run";
17
+ import { memoryGateLedgerPort } from "./gate";
18
+ import { waveSetDigest } from "../gated-waves";
19
+ import { pointGateResumable, type GatePointAsker, type GatePointQuestion, type GatePointRequest } from "../point-decision";
20
+ import { resumeStanding } from "../cli/handlers/gate-resume";
21
+ import { opWaveNotifyFields } from "./op-waves-notify";
22
+
23
+ const digest = (n: string) => `sha256:${n.repeat(64)}`;
24
+ const NOW = "2026-10-11T10:00:00.000Z";
25
+
26
+ const spec: OpWavesSpec = {
27
+ name: "tf-apply",
28
+ op: "apply",
29
+ plan: ["tool", "plan", "{target}", "--out", "{plan}"],
30
+ apply: ["tool", "apply", "{target}", "--plan", "{plan}"],
31
+ point: { name: "prod-apply", timeout: "48h" },
32
+ waves: [
33
+ { name: "prod", runs: [{ target: "db" }, { target: "net" }], gate: "always" },
34
+ { name: "wide", runs: [{ target: "a" }, { target: "b" }], gate: "always", shares: 2 },
35
+ ],
36
+ };
37
+
38
+ /** A stub asker: each question's state comes from `answers`, by the plan digest it was asked about. */
39
+ function stubAsker(answers: Map<string, Partial<GatePointQuestion>> = new Map()): GatePointAsker & { asked: GatePointRequest[] } {
40
+ const asked: GatePointRequest[] = [];
41
+ return {
42
+ asked,
43
+ async ask(request) {
44
+ asked.push(request);
45
+ const id = `${request.point}-${(request.facts.planDigest ?? "none").slice(7, 19)}`;
46
+ return {
47
+ id,
48
+ point: request.point,
49
+ path: `.chant/answers/${id}.md`,
50
+ state: "escalated",
51
+ answer: null,
52
+ decider: "quorum",
53
+ answeredBy: [],
54
+ subject: request.subject,
55
+ steward: null,
56
+ ...(answers.get(request.facts.planDigest ?? "") ?? {}),
57
+ };
58
+ },
59
+ };
60
+ }
61
+
62
+ function harness(atBase: OpWavesSpec | null = spec) {
63
+ const cwd = mkdtempSync(join(tmpdir(), "op-waves-point-"));
64
+ const calls: string[] = [];
65
+ const exec: OpWaveExec = (argv, dir) => {
66
+ calls.push(argv.join(" "));
67
+ if (argv[1] === "plan") writeFileSync(join(dir, argv[4]!), JSON.stringify({ planDigest: digest(argv[2]!.slice(0, 1)) }));
68
+ return 0;
69
+ };
70
+ const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: atBase === null ? null : JSON.stringify(atBase) });
71
+ return { cwd, calls, exec, show, applied: () => calls.filter((c) => c.startsWith("tool apply")), done: () => rmSync(cwd, { recursive: true, force: true }) };
72
+ }
73
+
74
+ const prodSet = waveSetDigest([
75
+ { member: "db", planDigest: digest("d") },
76
+ { member: "net", planDigest: digest("n") },
77
+ ]);
78
+
79
+ afterEach(() => vi.unstubAllEnvs());
80
+
81
+ describe("a wave gate that asks a decision point (#3836)", () => {
82
+ test("the spec takes a point on the spec or a wave, and refuses a bad one", () => {
83
+ expect(() => assertOpWavesSpec(spec)).not.toThrow();
84
+ expect(() => assertOpWavesSpec({ ...spec, point: "prod-apply" })).not.toThrow();
85
+ expect(() => assertOpWavesSpec({ ...spec, point: { name: "Prod Apply" } })).toThrow(/decision point's name/);
86
+ expect(() => assertOpWavesSpec({ ...spec, point: { name: "prod-apply", timeout: "soon" } })).toThrow(/point.timeout/);
87
+ expect(() => assertOpWavesSpec({ ...spec, waves: [{ ...spec.waves[0]!, point: { name: "prod-apply", inputs: { "gate.planDigest": "x" } } }] })).toThrow(/wave "prod".*passes itself/);
88
+ });
89
+
90
+ test("a wave waits on the point's question, it is answered yes, and the wave applies under a resolution that cites the answer", async () => {
91
+ const h = harness();
92
+ const gates = memoryGateLedgerPort();
93
+ const answers = new Map<string, Partial<GatePointQuestion>>();
94
+ const asker = stubAsker(answers);
95
+
96
+ const waiting = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, points: asker, now: NOW, log: () => {} });
97
+ expect(waiting.exitCode).toBe(3);
98
+ expect(asker.asked[0]).toMatchObject({ point: "prod-apply", facts: { component: "tf-apply", name: "tf-apply-wave-1", planDigest: prodSet }, subject: "gate:tf-apply/tf-apply-wave-1" });
99
+ const id = asker.asked[0]!.point + "-" + prodSet.slice(7, 19);
100
+ expect(waiting.decision).toMatchObject({
101
+ status: "waiting",
102
+ digest: prodSet,
103
+ point: { name: "prod-apply", question: { id, state: "escalated" } },
104
+ approve: `chant workspace points answer ${id} --answer <answer> --by <your name>`,
105
+ pendingFact: { gate: "tf-apply-wave-1", planDigest: prodSet, answer: { point: "prod-apply", id } },
106
+ });
107
+ // The pending fact stands for 48h, as the point's timeout says.
108
+ expect(gates.appended[0]!.expiresAt).toBe("2026-10-13T10:00:00.000Z");
109
+ expect(h.applied()).toEqual([]);
110
+ expect(opWaveNotifyFields(spec, 1, { result: waiting })).toMatchObject({ event: "waiting", approve: { command: expect.stringContaining("points answer") } });
111
+
112
+ // A `chant approve` of the digest does not pass a gate that asks a point.
113
+ const approvedByHand = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-apply", gate: "tf-apply-wave-1", resolvedBy: "mallory", timestamp: NOW, planDigest: prodSet }] });
114
+ expect((await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: approvedByHand, points: stubAsker(), now: NOW, log: () => {} })).exitCode).toBe(3);
115
+
116
+ answers.set(prodSet, { state: "answered", answer: true, answeredBy: ["alice"] });
117
+ const passed = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, points: asker, now: NOW, log: () => {} });
118
+ expect(passed).toMatchObject({ exitCode: 0, applied: ["db", "net"], decision: { status: "approved", via: "point", approvedBy: "alice", point: { answer: { id, answer: true } } } });
119
+ expect(gates.resolved).toHaveLength(1);
120
+ expect(gates.resolved[0]).toMatchObject({ gate: "tf-apply-wave-1", planDigest: prodSet, answer: { point: "prod-apply", id, answer: true } });
121
+
122
+ // Asked again for the same plan, the resolution already stands: none is written twice.
123
+ await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, points: asker, now: NOW, log: () => {} });
124
+ expect(gates.resolved).toHaveLength(1);
125
+ h.done();
126
+ });
127
+
128
+ test("each plan is its own question: a yes for one set digest does not pass another", async () => {
129
+ const h = harness();
130
+ const other = digest("z");
131
+ const asker = stubAsker(new Map([[other, { state: "answered", answer: true, answeredBy: ["alice"] }]]));
132
+ const result = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), points: asker, now: NOW, log: () => {} });
133
+ expect(result.exitCode).toBe(3);
134
+ h.done();
135
+ });
136
+
137
+ test("an answer the gate does not pass on refuses the wave with exit 8, and nothing applies", async () => {
138
+ const h = harness();
139
+ const asker = stubAsker(new Map([[prodSet, { state: "answered", answer: false, answeredBy: ["bob"] }]]));
140
+ const logs: string[] = [];
141
+ const refused = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), points: asker, now: NOW, log: (l) => logs.push(l) });
142
+ expect(refused).toMatchObject({
143
+ exitCode: 8,
144
+ applied: [],
145
+ decision: { status: "refused", refused: { reason: "point", runs: ["db", "net"] }, point: { answer: { answer: false } } },
146
+ });
147
+ expect(refused.decision!.refused!.message).toMatch(/answered false .* passes only on true/);
148
+ expect(h.applied()).toEqual([]);
149
+ expect(opWaveNotifyFields(spec, 1, { result: refused })).toMatchObject({ event: "refused", status: "point", targets: ["db", "net"] });
150
+ h.done();
151
+ });
152
+
153
+ test("a wide wave's shares refuse with exit 8 when the deciding job's point said no", async () => {
154
+ const h = harness();
155
+ const set = waveSetDigest([
156
+ { member: "a", planDigest: digest("a") },
157
+ { member: "b", planDigest: digest("b") },
158
+ ]);
159
+ const asker = stubAsker(new Map([[set, { state: "answered", answer: false, answeredBy: ["bob"] }]]));
160
+ const decided = await runOpWave({ spec, specFile: "waves.json", wave: 2, decide: true, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), points: asker, now: NOW, log: () => {} });
161
+ expect(decided.exitCode).toBe(8);
162
+ const record = JSON.parse(readFileSync(join(h.cwd, ".chant/op-waves/tf-apply/wave-2.json"), "utf-8")) as OpWaveDecision;
163
+ expect(record.refused?.reason).toBe("point");
164
+ const share = await runOpWave({ spec, specFile: "waves.json", wave: 2, share: 1, cwd: h.cwd, exec: h.exec, show: h.show, log: () => {} });
165
+ expect(share.exitCode).toBe(8);
166
+ expect(h.applied()).toEqual([]);
167
+ h.done();
168
+ });
169
+
170
+ test("the point is read at base: a change can neither add one nor drop one for its own gate", async () => {
171
+ const bare: OpWavesSpec = { ...spec, point: undefined };
172
+ // The change adds a point, the base has none: the ledger decides.
173
+ const added = harness(bare);
174
+ const ledgerApproved = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-apply", gate: "tf-apply-wave-1", resolvedBy: "alice", timestamp: NOW, planDigest: prodSet }] });
175
+ const asker = stubAsker();
176
+ const logs: string[] = [];
177
+ const viaLedger = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: added.cwd, exec: added.exec, show: added.show, gates: ledgerApproved, points: asker, now: NOW, log: (l) => logs.push(l) });
178
+ expect(viaLedger).toMatchObject({ exitCode: 0, decision: { status: "approved", via: "ledger" } });
179
+ expect(asker.asked).toHaveLength(0);
180
+ expect(logs.some((l) => l.includes("the one at base holds"))).toBe(true);
181
+ added.done();
182
+
183
+ // The change drops the point the base has: the point still decides.
184
+ const dropped = harness(spec);
185
+ const still = await runOpWave({ spec: bare, specFile: "waves.json", wave: 1, cwd: dropped.cwd, exec: dropped.exec, show: dropped.show, gates: ledgerApproved, points: asker, now: NOW, log: () => {} });
186
+ expect(still.exitCode).toBe(3);
187
+ expect(asker.asked).toHaveLength(1);
188
+ dropped.done();
189
+
190
+ // A wave's own point wins over the spec's.
191
+ const own: OpWavesSpec = { ...spec, waves: [{ ...spec.waves[0]!, point: { name: "db-apply", pass: ["apply"] } }, spec.waves[1]!] };
192
+ const mine = harness(own);
193
+ const ownAsker = stubAsker(new Map([[prodSet, { state: "answered", answer: "apply", answeredBy: ["carol"] }]]));
194
+ expect(await runOpWave({ spec: own, specFile: "waves.json", wave: 1, cwd: mine.cwd, exec: mine.exec, show: mine.show, gates: memoryGateLedgerPort(), points: ownAsker, now: NOW, log: () => {} })).toMatchObject({
195
+ exitCode: 0,
196
+ decision: { via: "point", approvedBy: "carol" },
197
+ });
198
+ expect(ownAsker.asked[0]!.point).toBe("db-apply");
199
+ mine.done();
200
+
201
+ // A point at base no gate could ask fails the wave rather than fall back to the ledger.
202
+ const broken = harness({ ...spec, point: { name: "Not A Point" } });
203
+ const failed = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: broken.cwd, exec: broken.exec, show: broken.show, gates: ledgerApproved, points: asker, now: NOW, log: () => {} });
204
+ expect(failed.exitCode).toBe(1);
205
+ expect(broken.calls).toEqual([]);
206
+ broken.done();
207
+ });
208
+
209
+ test("a policy that lets the wave through asks nothing", async () => {
210
+ const free: OpWavesSpec = { ...spec, waves: [{ ...spec.waves[0]!, gate: "never" }, spec.waves[1]!] };
211
+ const h = harness(free);
212
+ const asker = stubAsker();
213
+ expect(await runOpWave({ spec: free, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates: memoryGateLedgerPort(), points: asker, log: () => {} })).toMatchObject({
214
+ exitCode: 0,
215
+ decision: { status: "not-required" },
216
+ });
217
+ expect(asker.asked).toHaveLength(0);
218
+ h.done();
219
+ });
220
+
221
+ test("resume picks up the waiting wave once its question is answered (#3835)", async () => {
222
+ vi.stubEnv("GITLAB_CI", "");
223
+ vi.stubEnv("GITHUB_ACTIONS", "true");
224
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
225
+ vi.stubEnv("GITHUB_RUN_ID", "4242");
226
+ vi.stubEnv("GITHUB_RUN_ATTEMPT", "1");
227
+ vi.stubEnv("FORGEJO_ACTIONS", "");
228
+ vi.stubEnv("GITEA_ACTIONS", "");
229
+ const h = harness();
230
+ const gates = memoryGateLedgerPort();
231
+ const waiting = await runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, points: stubAsker(), now: NOW, log: () => {} });
232
+ expect(waiting.exitCode).toBe(3);
233
+ const fact = gates.appended[0]!;
234
+ expect(fact.resume).toMatchObject({ forge: "github", repo: "acme/infra", run: "4242" });
235
+ const id = fact.answer!.id;
236
+
237
+ const open = { id, state: "escalated" as const, answer: null, answeredBy: [], answeredOn: null };
238
+ expect(pointGateResumable(fact, open)).toMatchObject({ resumable: false, why: "open" });
239
+ expect(await resumeStanding(fact, [], { questions: new Map([[id, open]]), dryRun: true })).toMatchObject({ status: "not-approved" });
240
+
241
+ const no = { ...open, state: "answered" as const, answer: false, answeredBy: ["bob"], answeredOn: NOW };
242
+ expect(pointGateResumable(fact, no)).toMatchObject({ resumable: false, why: "refused" });
243
+
244
+ const yes = { ...open, state: "answered" as const, answer: true, answeredBy: ["alice"], answeredOn: NOW };
245
+ expect(await resumeStanding(fact, [], { questions: new Map([[id, yes]]), dryRun: true })).toMatchObject({
246
+ op: "tf-apply",
247
+ gate: "tf-apply-wave-1",
248
+ status: "skipped",
249
+ approvedBy: "alice",
250
+ message: expect.stringContaining("would resume github run 4242"),
251
+ });
252
+ h.done();
253
+ });
254
+ });