@intentius/chant 0.61.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 (122) hide show
  1. package/dist/cli/handlers/operator.d.ts +13 -18
  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/lexicon.d.ts +51 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +61 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/lifecycle/git.d.ts +117 -0
  20. package/dist/lifecycle/git.d.ts.map +1 -1
  21. package/dist/lifecycle/index.d.ts +1 -0
  22. package/dist/lifecycle/index.d.ts.map +1 -1
  23. package/dist/lifecycle/plan-digest.d.ts +33 -0
  24. package/dist/lifecycle/plan-digest.d.ts.map +1 -0
  25. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  26. package/dist/op/activities/lexicon-upgrade.d.ts +33 -3
  27. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  28. package/dist/op/activities/lifecycle.d.ts +27 -0
  29. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  30. package/dist/op/activities/reconcile.d.ts +394 -14
  31. package/dist/op/activities/reconcile.d.ts.map +1 -1
  32. package/dist/op/builders.d.ts +6 -0
  33. package/dist/op/builders.d.ts.map +1 -1
  34. package/dist/op/composites/apply-op.d.ts +6 -0
  35. package/dist/op/composites/apply-op.d.ts.map +1 -1
  36. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  37. package/dist/op/gate-summary.d.ts +16 -0
  38. package/dist/op/gate-summary.d.ts.map +1 -1
  39. package/dist/op/gate.d.ts +104 -13
  40. package/dist/op/gate.d.ts.map +1 -1
  41. package/dist/op/index.d.ts +3 -2
  42. package/dist/op/index.d.ts.map +1 -1
  43. package/dist/op/local-executor.d.ts +34 -2
  44. package/dist/op/local-executor.d.ts.map +1 -1
  45. package/dist/op/local-output.d.ts.map +1 -1
  46. package/dist/op/op-ir.d.ts +8 -1
  47. package/dist/op/op-ir.d.ts.map +1 -1
  48. package/dist/op/operator.d.ts.map +1 -1
  49. package/dist/op/runtime.d.ts +2 -0
  50. package/dist/op/runtime.d.ts.map +1 -1
  51. package/dist/op/runtimes/local.d.ts.map +1 -1
  52. package/dist/op/types.d.ts +19 -0
  53. package/dist/op/types.d.ts.map +1 -1
  54. package/dist/runtime-adapter.d.ts +8 -0
  55. package/dist/runtime-adapter.d.ts.map +1 -1
  56. package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
  57. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
  58. package/dist/terraform/graph.d.ts +18 -2
  59. package/dist/terraform/graph.d.ts.map +1 -1
  60. package/dist/terraform/parse.d.ts.map +1 -1
  61. package/dist/terraform/types.d.ts +7 -0
  62. package/dist/terraform/types.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/cli/handlers/operator.test.ts +232 -1
  65. package/src/cli/handlers/operator.ts +130 -6
  66. package/src/cli/handlers/run.ts +19 -0
  67. package/src/cli/main.ts +2 -0
  68. package/src/cli/registry.ts +2 -0
  69. package/src/components/cli-support.ts +29 -4
  70. package/src/components/driver-output.ts +10 -0
  71. package/src/components/driver.test.ts +31 -0
  72. package/src/components/driver.ts +54 -8
  73. package/src/discovery/fold-import.test.ts +55 -0
  74. package/src/fold/fold.test.ts +152 -0
  75. package/src/fold/fold.ts +102 -2
  76. package/src/fold/subset-doc-parity.test.ts +35 -1
  77. package/src/fold/subset.ts +10 -0
  78. package/src/lexicon.ts +51 -0
  79. package/src/lifecycle/gate-ledger.test.ts +133 -1
  80. package/src/lifecycle/gate-ledger.ts +108 -0
  81. package/src/lifecycle/git.test.ts +49 -5
  82. package/src/lifecycle/git.ts +312 -11
  83. package/src/lifecycle/index.ts +1 -0
  84. package/src/lifecycle/plan-digest.test.ts +49 -0
  85. package/src/lifecycle/plan-digest.ts +86 -0
  86. package/src/lifecycle/run-ledger.ts +1 -0
  87. package/src/op/activities/lexicon-upgrade.test.ts +134 -39
  88. package/src/op/activities/lexicon-upgrade.ts +74 -12
  89. package/src/op/activities/lifecycle.ts +51 -2
  90. package/src/op/activities/reconcile.test.ts +1013 -1
  91. package/src/op/activities/reconcile.ts +721 -25
  92. package/src/op/builders.ts +7 -1
  93. package/src/op/composites/apply-op.ts +16 -0
  94. package/src/op/composites/composites.test.ts +15 -2
  95. package/src/op/composites/reconcile-op.test.ts +18 -0
  96. package/src/op/composites/reconcile-op.ts +7 -1
  97. package/src/op/gate-summary.test.ts +33 -0
  98. package/src/op/gate-summary.ts +31 -0
  99. package/src/op/gate.test.ts +614 -0
  100. package/src/op/gate.ts +190 -24
  101. package/src/op/index.ts +5 -2
  102. package/src/op/local-executor.test.ts +340 -2
  103. package/src/op/local-executor.ts +190 -32
  104. package/src/op/local-output.test.ts +38 -0
  105. package/src/op/local-output.ts +24 -1
  106. package/src/op/op-ir.test.ts +22 -0
  107. package/src/op/op-ir.ts +9 -0
  108. package/src/op/operator.test.ts +20 -0
  109. package/src/op/operator.ts +39 -1
  110. package/src/op/runtime.ts +2 -0
  111. package/src/op/runtimes/local.ts +11 -0
  112. package/src/op/types.ts +19 -0
  113. package/src/runtime-adapter.ts +17 -3
  114. package/src/terraform/__fixtures__/build-graph.ts +42 -0
  115. package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
  116. package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
  117. package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
  118. package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
  119. package/src/terraform/graph.test.ts +148 -1
  120. package/src/terraform/graph.ts +144 -6
  121. package/src/terraform/parse.ts +4 -1
  122. package/src/terraform/types.ts +7 -0
@@ -16,6 +16,7 @@ const readGateResolutionsMock = vi.fn();
16
16
  const readGateLedgerMock = vi.fn();
17
17
  const readRunLedgerMock = vi.fn();
18
18
  const pushLifecycleMock = vi.fn();
19
+ const requireLifecycleLedgerMock = vi.fn();
19
20
 
20
21
  vi.mock("../../op/operator", async () => {
21
22
  const actual = await vi.importActual<typeof import("../../op/operator")>("../../op/operator");
@@ -59,7 +60,13 @@ vi.mock("../../lifecycle/gate-ledger", async () => {
59
60
  });
60
61
  vi.mock("../../lifecycle/git", async () => {
61
62
  const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
62
- return { ...actual, pushLifecycle: (...args: unknown[]) => pushLifecycleMock(...args) };
63
+ return {
64
+ ...actual,
65
+ pushLifecycle: (...args: unknown[]) => pushLifecycleMock(...args),
66
+ // Mocked, not merely defaulted: the real one shells to `git fetch`, and
67
+ // these tests run in the chant checkout itself (#2303).
68
+ requireLifecycleLedger: (...args: unknown[]) => requireLifecycleLedgerMock(...args),
69
+ };
63
70
  });
64
71
 
65
72
  // Imported after the mocks above are registered.
@@ -80,6 +87,7 @@ beforeEach(() => {
80
87
  loadProfilesMock.mockResolvedValue({});
81
88
  discoverOpsMock.mockResolvedValue({ ops: new Map([["fountain-apply", {}]]), errors: [] });
82
89
  pushLifecycleMock.mockResolvedValue(true);
90
+ requireLifecycleLedgerMock.mockResolvedValue(undefined);
83
91
  readGateResolutionsMock.mockResolvedValue({ records: [], malformed: 0 });
84
92
  readGateLedgerMock.mockResolvedValue({ resolutions: [], pending: [], malformed: 0 });
85
93
  });
@@ -341,6 +349,29 @@ describe("runOperatorStatus", () => {
341
349
  });
342
350
  });
343
351
 
352
+ /**
353
+ * A digest shaped the way `computePlanDigest` produces them (#2300).
354
+ */
355
+ const PLAN_A = `sha256:${"a".repeat(64)}`;
356
+
357
+ /**
358
+ * The standing pending fact `chant approve` reads the plan off since #2300.
359
+ * Every approve test seeds one, because a gate with nothing pending has no
360
+ * plan to approve and the command refuses rather than recording an approval
361
+ * of whatever runs next — which is its own test below.
362
+ */
363
+ function seedPending(op: string, gate: string, planDigest: string | undefined): void {
364
+ readGateLedgerMock.mockResolvedValue({
365
+ resolutions: [],
366
+ pending: [{
367
+ version: 1, kind: "pending", op, gate,
368
+ timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z",
369
+ ...(planDigest !== undefined ? { planDigest } : {}),
370
+ }],
371
+ malformed: 0,
372
+ });
373
+ }
374
+
344
375
  describe("runApprove", () => {
345
376
  test("requires both <op> and <gate>", async () => {
346
377
  const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
@@ -350,6 +381,7 @@ describe("runApprove", () => {
350
381
  });
351
382
 
352
383
  test("appends a gate-resolution record and pushes, resolving --actor over env fallbacks", async () => {
384
+ seedPending("fountain-apply", "rollout-gate", PLAN_A);
353
385
  appendGateResolutionMock.mockResolvedValue({
354
386
  commit: "sha",
355
387
  record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
@@ -372,6 +404,7 @@ describe("runApprove", () => {
372
404
  test("--approver wins over --actor and over the CI/shell identity", async () => {
373
405
  vi.stubEnv("GITHUB_ACTOR", "ci-bot");
374
406
  vi.stubEnv("USER", "alex");
407
+ seedPending("deploy-gated", "approve-deploy", PLAN_A);
375
408
  appendGateResolutionMock.mockResolvedValue({
376
409
  commit: "sha",
377
410
  record: { version: 1, op: "deploy-gated", gate: "approve-deploy", resolvedBy: "you", timestamp: "2026-01-01T00:00:00.000Z" },
@@ -396,6 +429,7 @@ describe("runApprove", () => {
396
429
  vi.stubEnv("GITHUB_REF_NAME", "");
397
430
  vi.stubEnv("GITHUB_REPOSITORY", "");
398
431
  vi.stubEnv("CI_MERGE_REQUEST_PROJECT_URL", "");
432
+ seedPending("fountain-apply", "rollout-gate", PLAN_A);
399
433
  appendGateResolutionMock.mockResolvedValue({
400
434
  commit: "sha",
401
435
  record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", url: "https://github.com/org/repo/pull/9" },
@@ -419,6 +453,7 @@ describe("runApprove", () => {
419
453
  vi.stubEnv("GITHUB_SERVER_URL", "https://github.com");
420
454
  vi.stubEnv("GITHUB_REPOSITORY", "INTENTIUS/chant");
421
455
  vi.stubEnv("GITHUB_REF_NAME", "2028/merge");
456
+ seedPending("fountain-apply", "g", PLAN_A);
422
457
  appendGateResolutionMock.mockResolvedValue({
423
458
  commit: "sha",
424
459
  record: { version: 1, op: "fountain-apply", gate: "g", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
@@ -480,6 +515,7 @@ describe("runApprove", () => {
480
515
 
481
516
  test("warns (but still records) when the op isn't among discovered *.op.ts declarations", async () => {
482
517
  discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
518
+ seedPending("unknown-op", "g", PLAN_A);
483
519
  appendGateResolutionMock.mockResolvedValue({
484
520
  commit: "sha",
485
521
  record: { version: 1, op: "unknown-op", gate: "g", resolvedBy: "unknown", timestamp: "2026-01-01T00:00:00.000Z" },
@@ -726,3 +762,198 @@ describe("runOperatorLog", () => {
726
762
  errSpy.mockRestore();
727
763
  });
728
764
  });
765
+
766
+ /**
767
+ * #2303 finding 2, at the handler. The mechanism is proved against real git
768
+ * in `op/gate.test.ts`; what these hold is that `chant approve` actually goes
769
+ * through the guard, and stops rather than writing when it refuses — the
770
+ * append must not be reached, because reaching it is what discarded the
771
+ * pending fact in the first place.
772
+ */
773
+ /**
774
+ * #2300 — an approval is for a plan. `chant approve` records the plan it
775
+ * approves, so the resolution says what was reviewed rather than only who
776
+ * reviewed and when.
777
+ */
778
+ describe("runApprove — the resolution names the plan it approves (#2300)", () => {
779
+ test("by default it approves the standing pending fact's plan, so the common path stays one command", async () => {
780
+ seedPending("fountain-apply", "rollout-gate", PLAN_A);
781
+ appendGateResolutionMock.mockResolvedValue({
782
+ commit: "sha",
783
+ record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: PLAN_A },
784
+ });
785
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
786
+
787
+ expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }))).toBe(0);
788
+ expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({ planDigest: PLAN_A }));
789
+ expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain(`approves the plan ${PLAN_A}`);
790
+ errSpy.mockRestore();
791
+ });
792
+
793
+ test("--plan names one explicitly, and wins over the standing fact's", async () => {
794
+ const other = `sha256:${"b".repeat(64)}`;
795
+ seedPending("fountain-apply", "rollout-gate", PLAN_A);
796
+ appendGateResolutionMock.mockResolvedValue({
797
+ commit: "sha",
798
+ record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: other },
799
+ });
800
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
801
+
802
+ expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", plan: other }))).toBe(0);
803
+ expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({ planDigest: other }));
804
+ errSpy.mockRestore();
805
+ });
806
+
807
+ // With nothing pending there is no plan on the ledger to approve, and
808
+ // recording a resolution anyway is exactly how an approval came to mean
809
+ // "the next run" (INTENTIUS/choudoufu#1026).
810
+ test("with no pending record it refuses, writes nothing, and names the fix", async () => {
811
+ readGateLedgerMock.mockResolvedValue({ resolutions: [], pending: [], malformed: 0 });
812
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
813
+
814
+ const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }));
815
+
816
+ expect(code).toBe(1);
817
+ expect(appendGateResolutionMock).not.toHaveBeenCalled();
818
+ expect(pushLifecycleMock).not.toHaveBeenCalled();
819
+ const out = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
820
+ expect(out).toContain("has no pending fact, so there is no plan to approve");
821
+ expect(out).toContain("chant run fountain-apply");
822
+ expect(out).toContain("--plan <digest>");
823
+ errSpy.mockRestore();
824
+ });
825
+
826
+ // ...but `--plan` is the escape hatch, so approving a plan whose digest you
827
+ // already hold does not need a run to have recorded a pending fact first.
828
+ test("--plan approves with nothing pending", async () => {
829
+ readGateLedgerMock.mockResolvedValue({ resolutions: [], pending: [], malformed: 0 });
830
+ appendGateResolutionMock.mockResolvedValue({
831
+ commit: "sha",
832
+ record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: PLAN_A },
833
+ });
834
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
835
+
836
+ expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", plan: PLAN_A }))).toBe(0);
837
+ expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({ planDigest: PLAN_A }));
838
+ errSpy.mockRestore();
839
+ });
840
+
841
+ test("a --plan that is not a digest is refused before anything is written", async () => {
842
+ seedPending("fountain-apply", "rollout-gate", PLAN_A);
843
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
844
+
845
+ const code = await runApprove(ctx({
846
+ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", plan: "chant.tfplan",
847
+ }));
848
+
849
+ expect(code).toBe(1);
850
+ expect(appendGateResolutionMock).not.toHaveBeenCalled();
851
+ expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain("--plan must be a plan digest");
852
+ errSpy.mockRestore();
853
+ });
854
+
855
+ // A gate that binds no plan records no digest on its pending fact, so the
856
+ // resolution carries none either — and nothing refuses, because nothing
857
+ // ever claimed to bind a plan there.
858
+ test("a gate that binds no plan approves as it always did", async () => {
859
+ seedPending("fountain-apply", "rollout-gate", undefined);
860
+ appendGateResolutionMock.mockResolvedValue({
861
+ commit: "sha",
862
+ record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
863
+ });
864
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
865
+
866
+ expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }))).toBe(0);
867
+ expect(appendGateResolutionMock.mock.calls[0][0]).not.toHaveProperty("planDigest");
868
+ errSpy.mockRestore();
869
+ });
870
+ });
871
+
872
+ describe("runApprove — the ledger branch is read before it is appended to (#2303)", () => {
873
+ test("reads the branch before recording the resolution", async () => {
874
+ seedPending("fountain-apply", "rollout-gate", PLAN_A);
875
+ appendGateResolutionMock.mockResolvedValue({
876
+ commit: "sha",
877
+ record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
878
+ });
879
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
880
+
881
+ expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }))).toBe(0);
882
+
883
+ expect(requireLifecycleLedgerMock).toHaveBeenCalled();
884
+ expect(requireLifecycleLedgerMock.mock.invocationCallOrder[0]).toBeLessThan(
885
+ appendGateResolutionMock.mock.invocationCallOrder[0],
886
+ );
887
+ errSpy.mockRestore();
888
+ });
889
+
890
+ test("refuses by name, and writes nothing, when the branch cannot be read", async () => {
891
+ requireLifecycleLedgerMock.mockRejectedValue(
892
+ new Error(
893
+ 'the chant/lifecycle ledger branch is not in this checkout and could not be fetched from "origin"',
894
+ ),
895
+ );
896
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
897
+
898
+ const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }));
899
+
900
+ expect(code).toBe(1);
901
+ expect(appendGateResolutionMock).not.toHaveBeenCalled();
902
+ expect(pushLifecycleMock).not.toHaveBeenCalled();
903
+ expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain("chant/lifecycle ledger branch");
904
+ errSpy.mockRestore();
905
+ });
906
+
907
+ test("--expire is guarded the same way", async () => {
908
+ requireLifecycleLedgerMock.mockRejectedValue(new Error("the chant/lifecycle ledger branch has diverged"));
909
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
910
+
911
+ const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", expire: true }));
912
+
913
+ expect(code).toBe(1);
914
+ expect(appendPendingGateMock).not.toHaveBeenCalled();
915
+ errSpy.mockRestore();
916
+ });
917
+ });
918
+
919
+ /**
920
+ * #2309 review. `pushLifecycle().catch(() => undefined)` at both write sites
921
+ * meant a rejected push printed unconditional success and exited 0 — the
922
+ * operator walks away believing the gate is answered for everybody, while the
923
+ * resolution exists only in their own checkout.
924
+ */
925
+ describe("runApprove — a push that does not land is reported (#2309 review)", () => {
926
+ test("a rejected push warns and marks the success line local-only", async () => {
927
+ seedPending("fountain-apply", "rollout-gate", PLAN_A);
928
+ appendGateResolutionMock.mockResolvedValue({
929
+ commit: "sha",
930
+ record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
931
+ });
932
+ pushLifecycleMock.mockRejectedValue(new Error("Another snapshot completed for chant/lifecycle"));
933
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
934
+
935
+ const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }));
936
+
937
+ const out = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
938
+ expect(out).toMatch(/push to the remote was rejected/);
939
+ expect(out).toMatch(/local only . the push did not land/);
940
+ // Still exit 0: the local append is a correct local fact.
941
+ expect(code).toBe(0);
942
+ errSpy.mockRestore();
943
+ });
944
+
945
+ test("a project with no remote says nothing was pushed", async () => {
946
+ seedPending("fountain-apply", "rollout-gate", PLAN_A);
947
+ appendGateResolutionMock.mockResolvedValue({
948
+ commit: "sha",
949
+ record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
950
+ });
951
+ pushLifecycleMock.mockResolvedValue(false);
952
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
953
+
954
+ await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }));
955
+
956
+ expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toMatch(/No remote is configured/);
957
+ errSpy.mockRestore();
958
+ });
959
+ });
@@ -34,7 +34,8 @@ import {
34
34
  latestResolutionSince, latestPendingGate, isPendingGateExpired,
35
35
  resolveApprovalUrl, isApprovalUrl,
36
36
  } from "../../lifecycle/gate-ledger";
37
- import { pushLifecycle } from "../../lifecycle/git";
37
+ import { isPlanDigest } from "../../lifecycle/plan-digest";
38
+ import { pushLifecycle, requireLifecycleLedger } from "../../lifecycle/git";
38
39
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
39
40
  import type { CommandContext } from "../registry";
40
41
 
@@ -598,6 +599,39 @@ export async function runOperatorLog(ctx: CommandContext): Promise<number> {
598
599
  * is already expired, which supersedes the standing one on an append-only
599
600
  * ledger, so the next run decides the gate from scratch.
600
601
  */
602
+ /** The ledger branch, named in the warnings below so a reader can go look at it. */
603
+ const LIFECYCLE_BRANCH = "chant/lifecycle";
604
+
605
+ /**
606
+ * Push the ledger and say so when it does not land (#2309 review, refs #2310).
607
+ *
608
+ * Both write paths here used `pushLifecycle().catch(() => undefined)` and then
609
+ * printed unconditional success and exited 0. A rejected push — a stale lease,
610
+ * no credentials, no network — therefore read as a completed approval, which
611
+ * is the one thing an approval must never do: the operator walks away
612
+ * believing the gate is answered for everybody, while the resolution exists
613
+ * only in their own checkout.
614
+ *
615
+ * The append is still a correct *local* fact, so this is a warning and not a
616
+ * failure; the exit code is unchanged.
617
+ */
618
+ async function reportedPush(consequence: string): Promise<boolean> {
619
+ try {
620
+ const pushed = await pushLifecycle();
621
+ if (pushed) return true;
622
+ console.error(formatWarning({
623
+ message: `No remote is configured, so nothing was pushed. ${consequence}`,
624
+ }));
625
+ return false;
626
+ } catch (err) {
627
+ console.error(formatWarning({
628
+ message: `The push to the remote was rejected: ${err instanceof Error ? err.message : String(err)}`,
629
+ hint: consequence,
630
+ }));
631
+ return false;
632
+ }
633
+ }
634
+
601
635
  export async function runApprove(ctx: CommandContext): Promise<number> {
602
636
  const opName = ctx.args.path;
603
637
  const gate = ctx.args.extraPositional;
@@ -607,6 +641,18 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
607
641
  }
608
642
 
609
643
  if (ctx.args.expire) {
644
+ // Same guard as the approve path below (#2303): `--expire` reads the
645
+ // standing fact and appends beside it, so a clone that never fetched the
646
+ // branch would both fail to see the fact it is expiring and replace the
647
+ // branch with the expiry alone.
648
+ try {
649
+ await requireLifecycleLedger();
650
+ } catch (err) {
651
+ console.error(formatError({
652
+ message: `Cannot expire the gate: ${err instanceof Error ? err.message : String(err)}`,
653
+ }));
654
+ return 1;
655
+ }
610
656
  const now = new Date().toISOString();
611
657
  const standing = latestPendingGate((await readGateLedger(opName)).pending, gate);
612
658
  if (!standing || isPendingGateExpired(standing, now)) {
@@ -622,8 +668,14 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
622
668
  expiresAt: now,
623
669
  ...(standing.description ? { description: standing.description } : {}),
624
670
  });
625
- await pushLifecycle().catch(() => undefined);
626
- console.error(formatSuccess(`Gate "${gate}" on "${opName}" expired at ${now} — not approved`));
671
+ const pushed = await reportedPush(
672
+ `The expiry is recorded locally on ${LIFECYCLE_BRANCH}. Until it reaches the remote, ` +
673
+ `a run in another checkout still sees the old pending fact.`,
674
+ );
675
+ console.error(formatSuccess(
676
+ `Gate "${gate}" on "${opName}" expired at ${now} — not approved` +
677
+ (pushed ? "" : " (local only — the push did not land)"),
678
+ ));
627
679
  console.error(formatInfo(
628
680
  `The next \`chant run ${opName}\` decides this gate from scratch and records a fresh pending fact.`,
629
681
  ));
@@ -638,6 +690,7 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
638
690
  actor: ctx.args.approver ?? ctx.args.actor,
639
691
  note: ctx.args.note,
640
692
  url: ctx.args.url,
693
+ plan: ctx.args.plan,
641
694
  });
642
695
  if (!outcome.ok) return 1;
643
696
 
@@ -656,6 +709,12 @@ export interface GateApprovalOptions {
656
709
  note?: string;
657
710
  /** `--url` — the approval surface, validated as absolute http/https. */
658
711
  url?: string;
712
+ /**
713
+ * `--plan <digest>` (#2300) — the plan this approval is for. Omitted, the
714
+ * digest is read off the gate's standing pending fact, so the common path
715
+ * stays one command: run, read what it planned, approve it.
716
+ */
717
+ plan?: string;
659
718
  }
660
719
 
661
720
  export type GateApprovalOutcome =
@@ -669,6 +728,13 @@ export type GateApprovalOutcome =
669
728
  *
670
729
  * Prints the same warning for an undiscovered op and the same success line as
671
730
  * `chant approve` always did; the caller adds whatever it does next.
731
+ *
732
+ * Since #2300 the resolution names the plan it approves. By default that is
733
+ * the standing pending fact's plan — the one the gated run produced — so the
734
+ * common path is still `chant run`, read, `chant approve`. With no pending
735
+ * fact and no `--plan`, this refuses: there is nothing on the ledger saying
736
+ * what would be approved, and writing a resolution anyway is what made an
737
+ * approval mean "the next run" instead of "this plan".
672
738
  */
673
739
  export async function recordGateApproval(
674
740
  opName: string,
@@ -682,7 +748,20 @@ export async function recordGateApproval(
682
748
  }));
683
749
  }
684
750
 
685
- const resolvedBy = opts.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown";
751
+ // Read the branch before appending to it (#2303 finding 2). Without this,
752
+ // an approve in a clone that never fetched `chant/lifecycle` builds its
753
+ // commit from an empty tree with no parent and replaces the branch with a
754
+ // single commit holding only this resolution — the pending fact it is
755
+ // answering is discarded rather than appended to.
756
+ try {
757
+ await requireLifecycleLedger();
758
+ } catch (err) {
759
+ console.error(formatError({
760
+ message: `Cannot record the approval: ${err instanceof Error ? err.message : String(err)}`,
761
+ hint: "The pending fact this answers lives on that branch; appending without it would drop it.",
762
+ }));
763
+ return { ok: false };
764
+ }
686
765
 
687
766
  // #2028: the resolution's link is typed. `--url` wins; otherwise, running
688
767
  // inside the PR/MR job that carries the change is itself the address, the
@@ -696,6 +775,40 @@ export async function recordGateApproval(
696
775
  return { ok: false };
697
776
  }
698
777
 
778
+ // #2300: an approval is for a plan, so this command has to know which one.
779
+ // `--plan` names it outright; otherwise it comes off the gate's standing
780
+ // pending fact — the plan the run that stopped at this gate produced, which
781
+ // is the plan whoever is typing this just read. There is deliberately no
782
+ // third fallback: approving with no plan in sight is the behaviour
783
+ // INTENTIUS/choudoufu#1026 measured, where the resolution authorised the
784
+ // next run rather than anything anyone had seen.
785
+ let planDigest: string | undefined;
786
+ if (opts.plan !== undefined) {
787
+ if (!isPlanDigest(opts.plan)) {
788
+ console.error(formatError({
789
+ message: `--plan must be a plan digest ("sha256:" and 64 hex characters), got "${opts.plan}"`,
790
+ hint: "Copy it from the gated run's `plan :` line, or from the pending-gate summary. It is not a plan file path.",
791
+ }));
792
+ return { ok: false };
793
+ }
794
+ planDigest = opts.plan;
795
+ } else {
796
+ const standing = latestPendingGate((await readGateLedger(opName)).pending, gate);
797
+ if (!standing) {
798
+ console.error(formatError({
799
+ message: `Gate "${gate}" on "${opName}" has no pending fact, so there is no plan to approve`,
800
+ hint:
801
+ `Run \`chant run ${opName}\` first — it plans, stops at the gate, and records the plan this ` +
802
+ `approval would be for. To approve a plan you already have the digest for, pass ` +
803
+ `\`--plan <digest>\`.`,
804
+ }));
805
+ return { ok: false };
806
+ }
807
+ planDigest = standing.planDigest;
808
+ }
809
+
810
+ const resolvedBy = opts.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown";
811
+
699
812
  const { record } = await appendGateResolution({
700
813
  op: opName,
701
814
  gate,
@@ -703,12 +816,23 @@ export async function recordGateApproval(
703
816
  timestamp: new Date().toISOString(),
704
817
  ...(opts.note ? { note: opts.note } : {}),
705
818
  ...(url ? { url } : {}),
819
+ ...(planDigest !== undefined ? { planDigest } : {}),
706
820
  });
707
- await pushLifecycle().catch(() => undefined);
821
+ const pushed = await reportedPush(
822
+ `The resolution is recorded locally on ${LIFECYCLE_BRANCH}. Until it reaches the remote, ` +
823
+ `a run in another checkout will not see it.`,
824
+ );
708
825
 
709
826
  console.error(formatSuccess(
710
827
  `Gate "${gate}" on "${opName}" resolved by ${record.resolvedBy} at ${record.timestamp}` +
711
- (record.url ? ` (${record.url})` : ""),
828
+ (record.url ? ` (${record.url})` : "") +
829
+ (pushed ? "" : " (local only — the push did not land)"),
712
830
  ));
831
+ if (record.planDigest) {
832
+ console.error(formatInfo(
833
+ `This approves the plan ${record.planDigest}, and only that plan. A run whose fresh plan ` +
834
+ "differs refuses rather than applying it.",
835
+ ));
836
+ }
713
837
  return { ok: true, record };
714
838
  }
@@ -385,6 +385,7 @@ export async function runOpApprove(ctx: CommandContext): Promise<number> {
385
385
  actor: ctx.args.approver ?? ctx.args.actor,
386
386
  note: ctx.args.note,
387
387
  url: ctx.args.url,
388
+ plan: ctx.args.plan,
388
389
  });
389
390
  if (!outcome.ok) return 1;
390
391
 
@@ -744,6 +745,16 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
744
745
  console.error(formatInfo(`approve : ${approveCommand(gate.op, gate.gate)}`));
745
746
  if (gate.url) console.error(formatInfo(`approve at: ${gate.url}`));
746
747
  console.error(formatInfo(`expires : ${gate.expiresAt}`));
748
+ // #2310: this run's own append reached only the local chant/lifecycle
749
+ // branch. The gate is still right to stand, but an operator elsewhere
750
+ // cannot see the pending fact to approve it, and nothing else here says
751
+ // why not.
752
+ if (result.gated.pushed === false) {
753
+ console.error(formatWarning({
754
+ message: `the pending fact was not pushed to the remote: ${result.gated.pushWarning ?? "recorded locally only"}`,
755
+ hint: "an operator working from a clone of the remote cannot approve it until it does",
756
+ }));
757
+ }
747
758
  reportGatedRun(
748
759
  {
749
760
  op: gate.op,
@@ -751,6 +762,8 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
751
762
  ...(gate.description ? { description: gate.description } : {}),
752
763
  expiresAt: gate.expiresAt,
753
764
  ...(gate.url ? { url: gate.url } : {}),
765
+ ...(result.gated.pushed === false ? { pushed: false, pushWarning: result.gated.pushWarning } : {}),
766
+ ...(gate.planDigest ? { planDigest: gate.planDigest } : {}),
754
767
  },
755
768
  gatedExit,
756
769
  );
@@ -866,6 +879,12 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
866
879
  ...(pending?.description ? { description: pending.description } : {}),
867
880
  ...(pending?.expiresAt ? { expiresAt: pending.expiresAt } : {}),
868
881
  ...(pending?.url ? { url: pending.url } : {}),
882
+ // #2310: the local runtime knows whether this run's own append
883
+ // reached the remote; a runtime that reports only a state does not.
884
+ ...(status.result?.gatePushed === false
885
+ ? { pushed: false, pushWarning: status.result.gatePushWarning }
886
+ : {}),
887
+ ...(pending?.planDigest ? { planDigest: pending.planDigest } : {}),
869
888
  },
870
889
  gatedExit,
871
890
  );
package/src/cli/main.ts CHANGED
@@ -404,6 +404,8 @@ export function parseArgs(args: string[]): ParsedArgs {
404
404
  result.note = args[++i];
405
405
  } else if (arg === "--expire") {
406
406
  result.expire = true;
407
+ } else if (arg === "--plan") {
408
+ result.plan = args[++i];
407
409
  } else if (arg === "--url") {
408
410
  result.url = args[++i];
409
411
  } else if (arg === "--op") {
@@ -318,6 +318,8 @@ export interface ParsedArgs {
318
318
  note?: string;
319
319
  /** `chant approve <op> <gate> --expire` (#2119) — clear the gate's standing pending fact instead of approving it, so the next run decides the gate from scratch and records a fresh one. Writes no resolution: nothing is approved, the wait is only restarted. */
320
320
  expire?: boolean;
321
+ /** `chant approve <op> <gate> --plan <digest>` (#2300) — the plan this approval is for, as `sha256:<64 hex>`. Omitted, the digest is taken from the gate's standing pending fact, which is the plan the run that stopped at the gate actually produced; pass it to approve a plan explicitly, or to approve one before any run has recorded a pending fact. */
322
+ plan?: string;
321
323
  /** `chant operator log --op <name>` (#2029) — restrict the tick history to one ConvergeOp by name. Omitted, every discovered ConvergeOp's ticks are merged into one timeline. */
322
324
  op?: string;
323
325
  /** `chant operator log --since <iso>` (#2029) — only entries at or after this ISO-8601 instant. */
@@ -425,7 +425,13 @@ export interface RunComponentsResult {
425
425
  selected: string[];
426
426
  error?: string;
427
427
  /** Set when the run stopped at a `gate` nobody has approved (#2119): the component it stopped at and the pending fact recorded for it. */
428
- gated?: { component: string; gate: PendingGateRecord };
428
+ gated?: {
429
+ component: string;
430
+ gate: PendingGateRecord;
431
+ /** Whether this run's own append reached the remote (#2310); absent when the pending fact was already standing. */
432
+ pushed?: boolean;
433
+ pushWarning?: string;
434
+ };
429
435
  /** This run's resolved build-time parameters (chant #1108) — the component-driver counterpart of `../cli/commands/build.ts`'s `BuildResult.buildParams`. Present only once the run actually reached dispatch (mirrors `BuildResult.buildParams`, which is likewise absent on an early-error return). */
430
436
  buildParams?: BuildParamProvenance[];
431
437
  }
@@ -571,7 +577,14 @@ export async function runComponents(
571
577
  selected,
572
578
  buildParams: options.buildParams,
573
579
  ...(run.status === "gated" && run.gate
574
- ? { gated: { component: run.gatedComponent ?? selected[0], gate: run.gate } }
580
+ ? {
581
+ gated: {
582
+ component: run.gatedComponent ?? selected[0],
583
+ gate: run.gate,
584
+ ...(run.gatePushed !== undefined ? { pushed: run.gatePushed } : {}),
585
+ ...(run.gatePushWarning ? { pushWarning: run.gatePushWarning } : {}),
586
+ },
587
+ }
575
588
  : {}),
576
589
  };
577
590
  }
@@ -613,7 +626,12 @@ export async function runComponents(
613
626
  status: componentResult.status,
614
627
  failedComponent: componentResult.status === "fail" ? componentResult.component : undefined,
615
628
  ...(componentResult.status === "gated"
616
- ? { gatedComponent: componentResult.component, gate: componentResult.gate }
629
+ ? {
630
+ gatedComponent: componentResult.component,
631
+ gate: componentResult.gate,
632
+ ...(componentResult.gatePushed !== undefined ? { gatePushed: componentResult.gatePushed } : {}),
633
+ ...(componentResult.gatePushWarning ? { gatePushWarning: componentResult.gatePushWarning } : {}),
634
+ }
617
635
  : {}),
618
636
  componentOutputs,
619
637
  };
@@ -623,7 +641,14 @@ export async function runComponents(
623
641
  selected,
624
642
  buildParams: options.buildParams,
625
643
  ...(componentResult.status === "gated" && componentResult.gate
626
- ? { gated: { component: componentResult.component, gate: componentResult.gate } }
644
+ ? {
645
+ gated: {
646
+ component: componentResult.component,
647
+ gate: componentResult.gate,
648
+ ...(componentResult.gatePushed !== undefined ? { pushed: componentResult.gatePushed } : {}),
649
+ ...(componentResult.gatePushWarning ? { pushWarning: componentResult.gatePushWarning } : {}),
650
+ },
651
+ }
627
652
  : {}),
628
653
  };
629
654
  } catch (err) {
@@ -72,6 +72,16 @@ export function renderDriverHuman(result: DriverRunResult, write: Writer = stder
72
72
  write(`interpret run completed (${result.order.length} component(s))`);
73
73
  } else if (result.status === "gated") {
74
74
  write(`interpret run is gated at component "${result.gatedComponent}"`);
75
+ // #2310: this run's own append reached only the local chant/lifecycle
76
+ // branch. The gate still stands correctly — but an operator elsewhere
77
+ // cannot see the pending fact to approve it.
78
+ if (result.gatePushed === false) {
79
+ write(
80
+ ` warning : the pending fact was not pushed to the remote — ` +
81
+ (result.gatePushWarning ?? "it exists only in this checkout") +
82
+ `. An operator elsewhere cannot approve it until it does.`,
83
+ );
84
+ }
75
85
  } else {
76
86
  write(`interpret run failed at component "${result.failedComponent}"`);
77
87
  }