@intentius/chant 0.62.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/cli/handlers/operator.d.ts +13 -0
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/cli/handlers/run.d.ts.map +1 -1
  4. package/dist/cli/main.d.ts.map +1 -1
  5. package/dist/cli/registry.d.ts +2 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/components/cli-support.d.ts +3 -0
  8. package/dist/components/cli-support.d.ts.map +1 -1
  9. package/dist/components/driver-output.d.ts.map +1 -1
  10. package/dist/components/driver.d.ts +12 -0
  11. package/dist/components/driver.d.ts.map +1 -1
  12. package/dist/fold/fold.d.ts.map +1 -1
  13. package/dist/fold/subset.d.ts +10 -0
  14. package/dist/fold/subset.d.ts.map +1 -1
  15. package/dist/lifecycle/gate-ledger.d.ts +61 -0
  16. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  17. package/dist/lifecycle/index.d.ts +1 -0
  18. package/dist/lifecycle/index.d.ts.map +1 -1
  19. package/dist/lifecycle/plan-digest.d.ts +33 -0
  20. package/dist/lifecycle/plan-digest.d.ts.map +1 -0
  21. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  22. package/dist/op/activities/lexicon-upgrade.d.ts +14 -2
  23. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  24. package/dist/op/activities/lifecycle.d.ts +27 -0
  25. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  26. package/dist/op/activities/reconcile.d.ts +196 -27
  27. package/dist/op/activities/reconcile.d.ts.map +1 -1
  28. package/dist/op/builders.d.ts +6 -0
  29. package/dist/op/builders.d.ts.map +1 -1
  30. package/dist/op/composites/apply-op.d.ts +6 -0
  31. package/dist/op/composites/apply-op.d.ts.map +1 -1
  32. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  33. package/dist/op/gate-summary.d.ts +16 -0
  34. package/dist/op/gate-summary.d.ts.map +1 -1
  35. package/dist/op/gate.d.ts +104 -13
  36. package/dist/op/gate.d.ts.map +1 -1
  37. package/dist/op/index.d.ts +3 -2
  38. package/dist/op/index.d.ts.map +1 -1
  39. package/dist/op/local-executor.d.ts +17 -0
  40. package/dist/op/local-executor.d.ts.map +1 -1
  41. package/dist/op/local-output.d.ts.map +1 -1
  42. package/dist/op/op-ir.d.ts +8 -1
  43. package/dist/op/op-ir.d.ts.map +1 -1
  44. package/dist/op/runtime.d.ts +2 -0
  45. package/dist/op/runtime.d.ts.map +1 -1
  46. package/dist/op/types.d.ts +19 -0
  47. package/dist/op/types.d.ts.map +1 -1
  48. package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
  49. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
  50. package/dist/terraform/graph.d.ts +18 -2
  51. package/dist/terraform/graph.d.ts.map +1 -1
  52. package/dist/terraform/parse.d.ts.map +1 -1
  53. package/dist/terraform/types.d.ts +7 -0
  54. package/dist/terraform/types.d.ts.map +1 -1
  55. package/package.json +1 -1
  56. package/src/cli/handlers/operator.test.ts +130 -0
  57. package/src/cli/handlers/operator.ts +56 -2
  58. package/src/cli/handlers/run.ts +19 -0
  59. package/src/cli/main.ts +2 -0
  60. package/src/cli/registry.ts +2 -0
  61. package/src/components/cli-support.ts +29 -4
  62. package/src/components/driver-output.ts +10 -0
  63. package/src/components/driver.test.ts +31 -0
  64. package/src/components/driver.ts +54 -8
  65. package/src/discovery/fold-import.test.ts +55 -0
  66. package/src/fold/fold.test.ts +152 -0
  67. package/src/fold/fold.ts +102 -2
  68. package/src/fold/subset-doc-parity.test.ts +35 -1
  69. package/src/fold/subset.ts +10 -0
  70. package/src/lifecycle/gate-ledger.test.ts +133 -1
  71. package/src/lifecycle/gate-ledger.ts +108 -0
  72. package/src/lifecycle/index.ts +1 -0
  73. package/src/lifecycle/plan-digest.test.ts +49 -0
  74. package/src/lifecycle/plan-digest.ts +86 -0
  75. package/src/lifecycle/run-ledger.ts +1 -0
  76. package/src/op/activities/lexicon-upgrade.test.ts +24 -12
  77. package/src/op/activities/lexicon-upgrade.ts +19 -3
  78. package/src/op/activities/lifecycle.ts +51 -2
  79. package/src/op/activities/reconcile.test.ts +512 -26
  80. package/src/op/activities/reconcile.ts +307 -34
  81. package/src/op/builders.ts +7 -1
  82. package/src/op/composites/apply-op.ts +16 -0
  83. package/src/op/composites/composites.test.ts +15 -2
  84. package/src/op/composites/reconcile-op.test.ts +18 -0
  85. package/src/op/composites/reconcile-op.ts +7 -1
  86. package/src/op/gate-summary.test.ts +33 -0
  87. package/src/op/gate-summary.ts +31 -0
  88. package/src/op/gate.test.ts +111 -1
  89. package/src/op/gate.ts +181 -23
  90. package/src/op/index.ts +5 -2
  91. package/src/op/local-executor.test.ts +226 -3
  92. package/src/op/local-executor.ts +61 -12
  93. package/src/op/local-output.test.ts +38 -0
  94. package/src/op/local-output.ts +24 -1
  95. package/src/op/op-ir.test.ts +22 -0
  96. package/src/op/op-ir.ts +9 -0
  97. package/src/op/runtime.ts +2 -0
  98. package/src/op/types.ts +19 -0
  99. package/src/terraform/__fixtures__/build-graph.ts +42 -0
  100. package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
  101. package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
  102. package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
  103. package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
  104. package/src/terraform/graph.test.ts +148 -1
  105. package/src/terraform/graph.ts +144 -6
  106. package/src/terraform/parse.ts +4 -1
  107. package/src/terraform/types.ts +7 -0
@@ -28,6 +28,39 @@ describe("gatedRunSummaryMarkdown (#2243)", () => {
28
28
  expect(md).toContain("chant approve app-apply approve-app-apply --approver <you>");
29
29
  expect(md).toContain("_gates/app-apply.jsonl");
30
30
  });
31
+
32
+ // #2310: `recordGateApproval`'s push was already reported (#2309); the
33
+ // gate's own `appendPending` push was not. The GitHub/Forgejo/Gitea step
34
+ // summary is exactly the surface an operator working from a different
35
+ // checkout would open, so it is where the gap mattered most.
36
+ test("says so when this run's own append never reached the remote", () => {
37
+ const md = gatedRunSummaryMarkdown({
38
+ ...summary,
39
+ pushed: false,
40
+ pushWarning: "chant/lifecycle remote branch has moved since this run started",
41
+ });
42
+ expect(md).toContain("was not pushed to the remote");
43
+ expect(md).toContain("chant/lifecycle remote branch has moved since this run started");
44
+ expect(md).toContain("cannot see it to approve it");
45
+ });
46
+
47
+ test("says nothing extra when the push landed or nothing was pushed this run", () => {
48
+ const md = gatedRunSummaryMarkdown(summary);
49
+ expect(md).not.toContain("was not pushed");
50
+ });
51
+
52
+ // #2300 — the block is where a CI approver reads what they are approving,
53
+ // so it names the plan and says the approval does not carry to the next one.
54
+ test("names the plan the approval binds to, when the gate binds one", () => {
55
+ const digest = `sha256:${"d".repeat(64)}`;
56
+ const md = gatedRunSummaryMarkdown({ ...summary, planDigest: digest });
57
+ expect(md).toContain(digest);
58
+ expect(md).toContain("and not the next run");
59
+ });
60
+
61
+ test("a gate that binds no plan says nothing about one", () => {
62
+ expect(gatedRunSummaryMarkdown(summary)).not.toContain("and not the next run");
63
+ });
31
64
  });
32
65
 
33
66
  describe("writeGatedRunSummary surfaces (#2243, #2256)", () => {
@@ -34,6 +34,22 @@ export interface GatedRunSummary {
34
34
  expiresAt?: string;
35
35
  /** The approval surface the run resolved, when it knew one. */
36
36
  url?: string;
37
+ /**
38
+ * False when this run's own append reached only the local chant/lifecycle
39
+ * branch, not the remote (#2310) — the reason lives in `pushWarning`.
40
+ * Absent when this run left an already-standing pending fact alone, or when
41
+ * the push landed.
42
+ */
43
+ pushed?: boolean;
44
+ /** Set when `pushed` is false. */
45
+ pushWarning?: string;
46
+ /**
47
+ * The plan the pending fact was recorded against (#2300), when the gate
48
+ * binds one. Shown because it is what the `chant approve` line below
49
+ * approves — and because a reader who comes back to a stale summary needs
50
+ * to see that the digest has moved on.
51
+ */
52
+ planDigest?: string;
37
53
  }
38
54
 
39
55
  /**
@@ -51,6 +67,13 @@ export function gatedRunSummaryMarkdown(summary: GatedRunSummary): string {
51
67
  if (summary.description) {
52
68
  lines.push(summary.description, "");
53
69
  }
70
+ if (summary.planDigest) {
71
+ lines.push(
72
+ `This approves one plan, \`${summary.planDigest}\`, and not the next run (#2300). ` +
73
+ "Change the configuration after approving and the next run refuses rather than applying.",
74
+ "",
75
+ );
76
+ }
54
77
  lines.push(
55
78
  "Approve it, then re-run this workflow:",
56
79
  "",
@@ -62,6 +85,14 @@ export function gatedRunSummaryMarkdown(summary: GatedRunSummary): string {
62
85
  );
63
86
  if (summary.expiresAt) lines.push(`Expires: ${summary.expiresAt}`);
64
87
  if (summary.url) lines.push(`Approve at: ${summary.url}`);
88
+ if (summary.pushed === false) {
89
+ lines.push(
90
+ "",
91
+ `**This pending fact was not pushed to the remote** (${summary.pushWarning ?? "no reason given"}). ` +
92
+ "It exists only in this job's checkout. An operator working from a clone of the remote cannot " +
93
+ "see it to approve it until it reaches \`chant/lifecycle\` there.",
94
+ );
95
+ }
65
96
  lines.push("");
66
97
  return lines.join("\n");
67
98
  }
@@ -16,7 +16,7 @@ import { writeFileSync } from "node:fs";
16
16
  import { mkdir, rm } from "node:fs/promises";
17
17
  import { join } from "node:path";
18
18
  import { tmpdir } from "node:os";
19
- import { evaluateGate, gitGateLedgerPort } from "./gate";
19
+ import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
20
20
  import { appendGateResolution, readGateLedger } from "../lifecycle/gate-ledger";
21
21
  import {
22
22
  requireLifecycleLedger,
@@ -500,5 +500,115 @@ describe("op/gate — a CI checkout that never fetched the ledger (#2303)", () =
500
500
  });
501
501
  expect(check.satisfied).toBe(false);
502
502
  await expect(requireLifecycleLedger({ cwd: dir })).resolves.toBeUndefined();
503
+ // #2310: no remote to have failed against, but still worth saying —
504
+ // nothing this run just recorded left the checkout.
505
+ if (check.satisfied) throw new Error("unreachable");
506
+ expect(check.recorded).toBe(true);
507
+ expect(check.pushed).toBe(false);
508
+ expect(check.pushWarning).toMatch(/no remote/i);
509
+ });
510
+ });
511
+
512
+ /**
513
+ * #2310 — `recordGateApproval`'s push (`../cli/handlers/operator.ts`) was
514
+ * already reported by #2309's review; `evaluateGate`'s own `appendPending`
515
+ * push, right below it in the same file, still swallowed the rejection into
516
+ * `undefined` and let a gated run print as if the pending fact had reached
517
+ * everyone. It had not: an operator working from a clone of the remote
518
+ * cannot see a pending fact that never got there, and the run gave no hint
519
+ * that this was why their `chant approve` found nothing to resolve.
520
+ *
521
+ * Before this fix, `gitGateLedgerPort({ cwd }).appendPending(...)`'s
522
+ * resolved value was the bare `PendingGateRecord` — there was no `pushed`
523
+ * field to assert on at all, so the rejection above was genuinely invisible
524
+ * to any caller. That is the "red" state: this suite fails to typecheck
525
+ * against the old `Promise<PendingGateRecord>` return type, and a rejected
526
+ * push at `gate.ts:71` produced no failure text anywhere.
527
+ */
528
+ describe("op/gate — a rejected ledger push is reported, not swallowed (#2310)", () => {
529
+ test("gitGateLedgerPort's appendPending reports pushed:false and the rejection reason", async () => {
530
+ const { remote, author } = await clonePair();
531
+ const other = tmp("other");
532
+ git(["clone", "-q", remote, other], tmpdir());
533
+ git(["config", "user.email", "other@chant.dev"], other);
534
+ git(["config", "user.name", "Other"], other);
535
+
536
+ const port = gitGateLedgerPort({ cwd: author });
537
+
538
+ // `author` records and pushes the branch's first pending fact — this
539
+ // both creates `chant/lifecycle` on the remote and pins `author`'s own
540
+ // remote-tracking ref to the commit it just pushed.
541
+ const first = await port.appendPending({
542
+ op: "live-apply",
543
+ gate: "approve-live-apply",
544
+ timestamp: "2026-09-09T05:00:00.000Z",
545
+ expiresAt: "2026-09-10T05:00:00.000Z",
546
+ });
547
+ expect(first.pushed).toBe(true);
548
+ expect(first.pushWarning).toBeUndefined();
549
+
550
+ // `other` fetches that commit, appends a second pending fact on top of
551
+ // it, and pushes — moving the remote on behind `author`'s back. `author`
552
+ // never re-fetches, so its remote-tracking ref still names the first
553
+ // commit alone.
554
+ git(["fetch", "-q", "origin", "chant/lifecycle:chant/lifecycle"], other);
555
+ const otherPort = gitGateLedgerPort({ cwd: other });
556
+ const second = await otherPort.appendPending({
557
+ op: "live-apply",
558
+ gate: "approve-other-gate",
559
+ timestamp: "2026-09-09T05:05:00.000Z",
560
+ expiresAt: "2026-09-10T05:05:00.000Z",
561
+ });
562
+ expect(second.pushed).toBe(true);
563
+
564
+ // `author` appends its own third fact on the tip it still knows (the
565
+ // first commit) and pushes against a lease the remote has since moved
566
+ // past — the same `StaleLifecycleBranchError` `chant approve` could hit
567
+ // (#2310's issue).
568
+ const third = await port.appendPending({
569
+ op: "live-apply",
570
+ gate: "approve-third-gate",
571
+ timestamp: "2026-09-09T05:10:00.000Z",
572
+ expiresAt: "2026-09-10T05:10:00.000Z",
573
+ });
574
+ expect(third.pushed).toBe(false);
575
+ expect(third.pushWarning).toBeTruthy();
576
+ expect(third.pushWarning).toMatch(/chant\/lifecycle remote branch has moved|stale|rejected|non-fast-forward/i);
577
+ // The append itself still landed locally — a correct local answer, per
578
+ // the #2309 author's reasoning; only the report was missing.
579
+ expect(third.record.gate).toBe("approve-third-gate");
580
+ });
581
+
582
+ test("evaluateGate carries the rejection onto the gate check it returns, recorded but not pushed", async () => {
583
+ // A minimal port whose read side is inert and whose write side reports
584
+ // the exact rejection a stale `chant/lifecycle` lease produces — the
585
+ // shape `evaluateGate` has to propagate regardless of which port
586
+ // implementation is behind it.
587
+ const rejectingPort = {
588
+ async read() {
589
+ return { resolutions: [], pending: [] };
590
+ },
591
+ async appendPending(input: Parameters<GateLedgerPort["appendPending"]>[0]) {
592
+ return {
593
+ record: { version: 1 as const, kind: "pending" as const, ...input },
594
+ pushed: false,
595
+ pushWarning:
596
+ "chant/lifecycle remote branch has moved since this run started — another snapshot was pushed concurrently.",
597
+ };
598
+ },
599
+ };
600
+
601
+ const check = await evaluateGate(rejectingPort, {
602
+ op: "live-apply",
603
+ gate: "approve-live-apply",
604
+ now: "2026-09-09T05:00:00.000Z",
605
+ });
606
+ if (check.satisfied) throw new Error("unreachable — nothing resolved this gate");
607
+ expect(check.recorded).toBe(true);
608
+ expect(check.pushed).toBe(false);
609
+ expect(check.pushWarning).toContain("another snapshot was pushed concurrently");
610
+ // The pending fact is still the one that was recorded — gating is still
611
+ // correct, only silent about reaching the remote.
612
+ expect(check.pending.gate).toBe("approve-live-apply");
503
613
  });
504
614
  });
package/src/op/gate.ts CHANGED
@@ -19,6 +19,14 @@
19
19
  * pre-authorize a future run's gate that has since been recorded pending; a
20
20
  * resolution from last month does not answer the fact this run just wrote.
21
21
  *
22
+ * Since #2300 a gate can also bind a plan. `GateCheckInput.planDigest` is
23
+ * what this run's Plan phase produced (`../lifecycle/plan-digest.ts`), and a
24
+ * resolution counts only when it was recorded for that same plan — recency
25
+ * demoted from the criterion to the tiebreak. Approve, edit the root, re-run,
26
+ * and the gate refuses by name instead of applying something no approver saw,
27
+ * which is what INTENTIUS/choudoufu#1026 measured it doing. A gate with no
28
+ * `planDigest` decides exactly as it did before.
29
+ *
22
30
  * Ledger access goes through {@link GateLedgerPort} rather than straight to
23
31
  * git, so a test (and the operator's own in-memory paths) can drive the
24
32
  * decision without an orphan branch on disk.
@@ -28,7 +36,7 @@ import {
28
36
  appendPendingGate,
29
37
  isPendingGateExpired,
30
38
  latestPendingGate,
31
- latestResolutionSince,
39
+ latestResolutionForPlan,
32
40
  readGateLedger,
33
41
  resolveApprovalUrl,
34
42
  DEFAULT_GATE_EXPIRY,
@@ -36,21 +44,46 @@ import {
36
44
  type PendingGateInput,
37
45
  type PendingGateRecord,
38
46
  } from "../lifecycle/gate-ledger";
47
+ import { describePlanDigest } from "../lifecycle/plan-digest";
39
48
  import { pushLifecycle, requireLifecycleLedger } from "../lifecycle/git";
40
49
  import { parseDuration } from "./duration";
41
50
 
51
+ /**
52
+ * What appending a pending fact learned about reaching the remote.
53
+ *
54
+ * The append to the local `chant/lifecycle` branch always lands — that half
55
+ * was fixed by #2309's fetch-before-append. This is the other half: whether
56
+ * the push that follows it did, and why not when it didn't, in one line a
57
+ * renderer can show directly (#2310).
58
+ */
59
+ export interface PendingGatePush {
60
+ record: PendingGateRecord;
61
+ /**
62
+ * True when the push reached the remote. False when there was no remote
63
+ * configured, or the push was rejected — `pushWarning` says which.
64
+ */
65
+ pushed: boolean;
66
+ /** Set when `pushed` is false. */
67
+ pushWarning?: string;
68
+ }
69
+
42
70
  /** The gate ledger, as the two executors need it: read both kinds of line, append a pending fact. */
43
71
  export interface GateLedgerPort {
44
72
  read(op: string): Promise<{ resolutions: GateResolutionRecord[]; pending: PendingGateRecord[] }>;
45
- appendPending(input: PendingGateInput): Promise<PendingGateRecord>;
73
+ appendPending(input: PendingGateInput): Promise<PendingGatePush>;
46
74
  }
47
75
 
48
76
  /**
49
- * The real port: the `chant/lifecycle` orphan branch. Pushes best-effort after
50
- * appending, the same two-step-collapsed-into-one shape `chant approve` uses
51
- * (`../cli/handlers/operator.ts` calls `pushLifecycle().catch(...)` right after
52
- * its append) — a pending fact that only ever reaches the local branch is still
53
- * a correct local answer, so a missing remote never fails the run.
77
+ * The real port: the `chant/lifecycle` orphan branch.
78
+ *
79
+ * Appending is always local-first and always lands (#2309's fetch-before-
80
+ * append). The push that follows is reported rather than swallowed (#2310):
81
+ * a pending fact that never reaches the remote is still a correct local
82
+ * answer — the run is right to gate, and does — but an operator elsewhere
83
+ * cannot approve a gate whose pending record they cannot see, so the caller
84
+ * gets `pushed: false` and a reason instead of silence. Mirrors the shape
85
+ * `chant approve` reports through (`../cli/handlers/operator.ts`'s
86
+ * `reportedPush`, #2309 review).
54
87
  */
55
88
  export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
56
89
  return {
@@ -68,8 +101,23 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
68
101
  },
69
102
  async appendPending(input) {
70
103
  const { record } = await appendPendingGate(input, opts);
71
- await pushLifecycle(opts).catch(() => undefined);
72
- return record;
104
+ try {
105
+ const pushed = await pushLifecycle(opts);
106
+ return pushed
107
+ ? { record, pushed }
108
+ : {
109
+ record,
110
+ pushed,
111
+ pushWarning:
112
+ "no remote is configured for chant/lifecycle — the pending fact was recorded locally only",
113
+ };
114
+ } catch (err) {
115
+ return {
116
+ record,
117
+ pushed: false,
118
+ pushWarning: err instanceof Error ? err.message : String(err),
119
+ };
120
+ }
73
121
  },
74
122
  };
75
123
  }
@@ -90,7 +138,8 @@ export function memoryGateLedgerPort(
90
138
  const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
91
139
  pending.push(record);
92
140
  appended.push(record);
93
- return record;
141
+ // No remote in an in-memory ledger — there is nothing to fail to reach.
142
+ return { record, pushed: true };
94
143
  },
95
144
  };
96
145
  }
@@ -106,14 +155,82 @@ export interface GateCheckInput {
106
155
  timeout?: string;
107
156
  /** Identifies the run that reached the gate, when the caller has one. */
108
157
  runId?: string;
158
+ /**
159
+ * The plan this run reached the gate with (#2300) — `computePlanDigest`'s
160
+ * output (`../lifecycle/plan-digest.ts`), from the Plan phase that ran a
161
+ * moment ago.
162
+ *
163
+ * Supplying it makes the gate plan-bound: only a resolution recorded for
164
+ * this exact digest satisfies it, and one recorded for a different plan (or
165
+ * for no plan at all) is reported as a {@link GateDigestMismatch} instead.
166
+ * Omitting it keeps the pre-#2300 rule, where the newest resolution since
167
+ * the standing pending fact satisfies the gate whatever has changed since.
168
+ */
169
+ planDigest?: string;
109
170
  /** ISO-8601 "now" — supplied by the caller, so the decision is deterministic under test. */
110
171
  now?: string;
111
172
  }
112
173
 
174
+ /**
175
+ * A standing resolution that answers this gate but not this plan (#2300) —
176
+ * what a refusal names.
177
+ */
178
+ export interface GateDigestMismatch {
179
+ /** The plan that was approved. `undefined` for a resolution written before #2300, which recorded no plan at all. */
180
+ approved?: string;
181
+ /** The plan this run's Plan phase produced. */
182
+ planned: string;
183
+ /** Who recorded the approval that does not apply here. */
184
+ resolvedBy: string;
185
+ /** When they recorded it. */
186
+ timestamp: string;
187
+ }
188
+
189
+ /**
190
+ * The refusal line for a {@link GateDigestMismatch}: which plan was approved,
191
+ * which was planned, and what closes the gap. One function so the executor's
192
+ * step record, the human render and the CI summary say the same thing.
193
+ *
194
+ * The two cases get different prose because they are different facts. A
195
+ * resolution for another plan means something changed between the approval
196
+ * and this run. A resolution with no plan on it means nothing is known to
197
+ * have changed — the record simply never said what it approved, which is
198
+ * every record written before #2300.
199
+ */
200
+ export function describeGateMismatch(op: string, gate: string, mismatch: GateDigestMismatch): string {
201
+ const why =
202
+ mismatch.approved === undefined
203
+ ? "That resolution predates plan-bound gates (#2300) and records no plan at all, so it cannot " +
204
+ "answer for this one. Approving again binds it:"
205
+ : "The configuration or the live system changed between that approval and this plan, so it needs " +
206
+ "a fresh one:";
207
+ return (
208
+ `Gate "${gate}" is approved, but not for this plan. ` +
209
+ `approved: ${describePlanDigest(mismatch.approved)} (by ${mismatch.resolvedBy} at ${mismatch.timestamp}); ` +
210
+ `planned: ${mismatch.planned}. ` +
211
+ `${why} ${approveCommand(op, gate)}`
212
+ );
213
+ }
214
+
113
215
  /** Either the gate is answered, or it is a standing fact. */
114
216
  export type GateCheck =
115
217
  | { satisfied: true; resolution: GateResolutionRecord }
116
- | { satisfied: false; pending: PendingGateRecord; recorded: boolean };
218
+ | {
219
+ satisfied: false;
220
+ pending: PendingGateRecord;
221
+ recorded: boolean;
222
+ /**
223
+ * Whether this run's own append reached the remote — set only when
224
+ * `recorded` is true. A gate left standing from an earlier run pushed
225
+ * (or didn't) on that run; this one wrote nothing, so it has nothing
226
+ * new to report (#2310).
227
+ */
228
+ pushed?: boolean;
229
+ /** Set when `pushed` is false. */
230
+ pushWarning?: string;
231
+ /** Present when a resolution stands for this gate but for another plan (#2300). */
232
+ mismatch?: GateDigestMismatch;
233
+ };
117
234
 
118
235
  /** The beginning of time — the anchor for a gate that has never been recorded pending, so any resolution for it counts. */
119
236
  const EPOCH = new Date(0).toISOString();
@@ -122,28 +239,61 @@ const EPOCH = new Date(0).toISOString();
122
239
  * Decide a gate against the ledger, recording the pending fact when it isn't
123
240
  * answered.
124
241
  *
125
- * - A resolution newer than the newest pending fact satisfies the gate.
126
- * - Otherwise, a pending fact that hasn't expired stands as it is: the run
127
- * ends `gated` and the ledger is left alone, so an operator ticking every
128
- * minute against an unapproved gate does not append a line a minute.
129
- * - Otherwise (no pending fact, or the newest one has expired) a fresh pending
130
- * fact is appended and the run ends `gated`. `recorded` says which of the
131
- * two happened, so a renderer can tell "just recorded" from "still standing".
242
+ * - A resolution recorded for this run's own plan, newer than the newest
243
+ * pending fact, satisfies the gate. On a gate that binds no plan
244
+ * (`input.planDigest` absent) the plan half of that drops out and the rule
245
+ * is the pre-#2300 one: any resolution newer than the pending fact.
246
+ * - A resolution that stands for the gate but for a different plan does not
247
+ * satisfy it (#2300). The run is gated, `mismatch` names both plans, and a
248
+ * fresh pending fact is recorded for the plan that actually ran — so
249
+ * `chant approve` has something current to approve and the loop closes in
250
+ * one more command rather than needing the digest typed out.
251
+ * - Otherwise, a pending fact that hasn't expired *and was recorded for this
252
+ * same plan* stands as it is: the run ends `gated` and the ledger is left
253
+ * alone, so an operator ticking every minute against an unapproved gate
254
+ * does not append a line a minute. A pending fact for a different plan is
255
+ * stale in the way that matters and is replaced.
256
+ * - Otherwise (no pending fact, or the newest one has expired, or it is for
257
+ * another plan) a fresh pending fact is appended and the run ends `gated`.
258
+ * `recorded` says which of the two happened, so a renderer can tell "just
259
+ * recorded" from "still standing".
132
260
  */
133
261
  export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput): Promise<GateCheck> {
134
262
  const now = input.now ?? new Date().toISOString();
135
263
  const { resolutions, pending } = await port.read(input.op);
136
264
 
137
265
  const standing = latestPendingGate(pending, input.gate);
138
- const resolution = latestResolutionSince(resolutions, input.gate, standing?.timestamp ?? EPOCH);
266
+ const { resolution, mismatched } = latestResolutionForPlan(
267
+ resolutions,
268
+ input.gate,
269
+ standing?.timestamp ?? EPOCH,
270
+ input.planDigest,
271
+ );
139
272
  if (resolution) return { satisfied: true, resolution };
140
273
 
141
- if (standing && !isPendingGateExpired(standing, now)) {
142
- return { satisfied: false, pending: standing, recorded: false };
274
+ // `input.planDigest` is defined whenever `mismatched` is — `latestResolutionForPlan`
275
+ // returns a mismatch only on the plan-bound path.
276
+ const mismatch: GateDigestMismatch | undefined = mismatched
277
+ ? {
278
+ ...(mismatched.planDigest !== undefined ? { approved: mismatched.planDigest } : {}),
279
+ planned: input.planDigest!,
280
+ resolvedBy: mismatched.resolvedBy,
281
+ timestamp: mismatched.timestamp,
282
+ }
283
+ : undefined;
284
+ const asMismatch = mismatch ? { mismatch } : {};
285
+
286
+ // A standing fact only stands for the plan it was recorded against. When
287
+ // the plan has moved, re-recording is what gives `chant approve` (which
288
+ // defaults to the newest pending fact's digest) the current plan to
289
+ // approve; leaving the old fact standing would make the common path
290
+ // approve a plan that is no longer the one being run.
291
+ if (standing && !isPendingGateExpired(standing, now) && standing.planDigest === input.planDigest) {
292
+ return { satisfied: false, pending: standing, recorded: false, ...asMismatch };
143
293
  }
144
294
 
145
295
  const url = resolveApprovalUrl();
146
- const record = await port.appendPending({
296
+ const { record, pushed, pushWarning } = await port.appendPending({
147
297
  op: input.op,
148
298
  gate: input.gate,
149
299
  timestamp: now,
@@ -153,8 +303,16 @@ export async function evaluateGate(port: GateLedgerPort, input: GateCheckInput):
153
303
  ...(input.description ? { description: input.description } : {}),
154
304
  ...(input.runId ? { runId: input.runId } : {}),
155
305
  ...(url ? { url } : {}),
306
+ ...(input.planDigest !== undefined ? { planDigest: input.planDigest } : {}),
156
307
  });
157
- return { satisfied: false, pending: record, recorded: true };
308
+ return {
309
+ satisfied: false,
310
+ pending: record,
311
+ recorded: true,
312
+ pushed,
313
+ ...(pushWarning ? { pushWarning } : {}),
314
+ ...asMismatch,
315
+ };
158
316
  }
159
317
 
160
318
  /** The line every renderer prints to say how a pending gate is cleared. */
package/src/op/index.ts CHANGED
@@ -50,8 +50,11 @@ export type { ActivityProfile, ActivityProfileName } from "./activity-profiles";
50
50
  export { NonRetryableActivityError, nonRetryableFailure } from "./activity-failure";
51
51
  export { runOpLocally, parseDuration, OpRunFailure } from "./local-executor";
52
52
  export type { StepRecord, OpRunResult, RunOpOptions } from "./local-executor";
53
- export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand } from "./gate";
54
- export type { GateLedgerPort, GateCheck, GateCheckInput } from "./gate";
53
+ export { evaluateGate, gitGateLedgerPort, memoryGateLedgerPort, approveCommand, describeGateMismatch } from "./gate";
54
+ export type { GateLedgerPort, GateCheck, GateCheckInput, PendingGatePush, GateDigestMismatch } from "./gate";
55
+ export {
56
+ computePlanDigest, isPlanDigest, describePlanDigest, PLAN_DIGEST_ALGORITHM,
57
+ } from "../lifecycle/plan-digest";
55
58
  export { gateName, usesDeprecatedGateKey, DEPRECATED_GATE_KEY_WARNING } from "./gate-name";
56
59
  export type { GateNamed } from "./gate-name";
57
60
  export { createLocalOpRuntime } from "./runtimes/local";