@intentius/chant 0.124.24 → 0.124.26

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 (195) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.js +4 -2
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/cli/commands/lint.js +4 -2
  5. package/dist/cli/handlers/approve.d.ts.map +1 -1
  6. package/dist/cli/handlers/approve.js +39 -4
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/main.js +21 -7
  9. package/dist/cli/mcp/workspace-tools.d.ts +3 -3
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  11. package/dist/cli/mcp/workspace-tools.js +27 -4
  12. package/dist/effect-receipt.d.ts +13 -0
  13. package/dist/effect-receipt.d.ts.map +1 -1
  14. package/dist/effect-receipt.js +19 -0
  15. package/dist/gate-decision.d.ts +1 -1
  16. package/dist/gate-decision.d.ts.map +1 -1
  17. package/dist/gate-decision.js +1 -1
  18. package/dist/lexicon-module.d.ts +7 -1
  19. package/dist/lexicon-module.d.ts.map +1 -1
  20. package/dist/lexicon-module.js +7 -2
  21. package/dist/lifecycle/gate-audit.d.ts +7 -1
  22. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  23. package/dist/lifecycle/gate-audit.js +40 -3
  24. package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
  25. package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
  26. package/dist/lifecycle/gate-checkpoint.js +103 -0
  27. package/dist/lifecycle/gate-ledger.d.ts +41 -0
  28. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  29. package/dist/lifecycle/gate-ledger.js +18 -0
  30. package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
  31. package/dist/lifecycle/receipt-plan.js +4 -2
  32. package/dist/lifecycle/run-ledger.d.ts +2 -0
  33. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  34. package/dist/lifecycle/run-ledger.js +2 -0
  35. package/dist/lint/rules/op/index.d.ts +6 -2
  36. package/dist/lint/rules/op/index.d.ts.map +1 -1
  37. package/dist/lint/rules/op/index.js +7 -3
  38. package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
  39. package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
  40. package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
  41. package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
  42. package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
  43. package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
  44. package/dist/op/activity-contract.d.ts +14 -0
  45. package/dist/op/activity-contract.d.ts.map +1 -1
  46. package/dist/op/activity-contract.js +16 -0
  47. package/dist/op/builders.d.ts +7 -0
  48. package/dist/op/builders.d.ts.map +1 -1
  49. package/dist/op/builders.js +13 -0
  50. package/dist/op/gate-alias.d.ts +57 -0
  51. package/dist/op/gate-alias.d.ts.map +1 -0
  52. package/dist/op/gate-alias.js +127 -0
  53. package/dist/op/gate-checkpoint.d.ts +123 -0
  54. package/dist/op/gate-checkpoint.d.ts.map +1 -0
  55. package/dist/op/gate-checkpoint.js +280 -0
  56. package/dist/op/gate.d.ts +54 -2
  57. package/dist/op/gate.d.ts.map +1 -1
  58. package/dist/op/gate.js +142 -1
  59. package/dist/op/index.d.ts +4 -1
  60. package/dist/op/index.d.ts.map +1 -1
  61. package/dist/op/index.js +2 -1
  62. package/dist/op/local-executor.d.ts +57 -2
  63. package/dist/op/local-executor.d.ts.map +1 -1
  64. package/dist/op/local-executor.js +200 -11
  65. package/dist/op/local-output.d.ts.map +1 -1
  66. package/dist/op/local-output.js +20 -2
  67. package/dist/op/op-ir.d.ts +4 -0
  68. package/dist/op/op-ir.d.ts.map +1 -1
  69. package/dist/op/op-ir.js +4 -0
  70. package/dist/op/receipt-store.d.ts +5 -0
  71. package/dist/op/receipt-store.d.ts.map +1 -1
  72. package/dist/op/receipt-store.js +4 -3
  73. package/dist/op/runtime.d.ts +15 -0
  74. package/dist/op/runtime.d.ts.map +1 -1
  75. package/dist/op/runtimes/local.d.ts.map +1 -1
  76. package/dist/op/runtimes/local.js +5 -0
  77. package/dist/op/step-output-ref.d.ts +26 -18
  78. package/dist/op/step-output-ref.d.ts.map +1 -1
  79. package/dist/op/step-output-ref.js +86 -63
  80. package/dist/op/types.d.ts +31 -0
  81. package/dist/op/types.d.ts.map +1 -1
  82. package/dist/wave-runtime.mjs +223 -221
  83. package/dist/workspace/agent-cli.d.ts.map +1 -1
  84. package/dist/workspace/agent-cli.js +3 -0
  85. package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  86. package/dist/workspace/conformance/writer.js +1 -1
  87. package/dist/workspace/decide.d.ts +51 -9
  88. package/dist/workspace/decide.d.ts.map +1 -1
  89. package/dist/workspace/decide.js +125 -14
  90. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  91. package/dist/workspace/lineage-upgrade.js +20 -11
  92. package/dist/workspace/point-answer.schema.json +16 -1
  93. package/dist/workspace/points-cli.d.ts +5 -3
  94. package/dist/workspace/points-cli.d.ts.map +1 -1
  95. package/dist/workspace/points-cli.js +16 -7
  96. package/dist/workspace/points-write.schema.json +39 -4
  97. package/dist/workspace/points.schema.json +42 -3
  98. package/dist/workspace/reason-codes.d.ts +2 -1
  99. package/dist/workspace/reason-codes.d.ts.map +1 -1
  100. package/dist/workspace/reason-codes.js +2 -1
  101. package/dist/workspace/records-cli.d.ts +12 -2
  102. package/dist/workspace/records-cli.d.ts.map +1 -1
  103. package/dist/workspace/records-cli.js +27 -7
  104. package/dist/workspace/records-write.d.ts.map +1 -1
  105. package/dist/workspace/records-write.js +2 -3
  106. package/dist/workspace/records.d.ts +10 -4
  107. package/dist/workspace/records.d.ts.map +1 -1
  108. package/dist/workspace/records.js +16 -3
  109. package/dist/workspace/records.schema.json +2 -1
  110. package/dist/workspace/trust/gate-seal.d.ts +32 -1
  111. package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
  112. package/dist/workspace/trust/gate-seal.js +41 -2
  113. package/dist/workspace/trust/seal.d.ts +1 -1
  114. package/dist/workspace/trust/seal.d.ts.map +1 -1
  115. package/dist/workspace/trust/seal.js +1 -1
  116. package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
  117. package/dist/workspace/trust/verify-cli.js +2 -1
  118. package/dist/workspace/trust/verify.d.ts +22 -0
  119. package/dist/workspace/trust/verify.d.ts.map +1 -1
  120. package/dist/workspace/trust/verify.js +94 -14
  121. package/dist/workspace/write-scope.d.ts +3 -1
  122. package/dist/workspace/write-scope.d.ts.map +1 -1
  123. package/dist/workspace/write-scope.js +11 -4
  124. package/package.json +1 -1
  125. package/src/cli/commands/build.ts +9 -2
  126. package/src/cli/commands/lint.ts +4 -2
  127. package/src/cli/compiled-cli.e2e.test.ts +45 -1
  128. package/src/cli/handlers/approve.ts +46 -4
  129. package/src/cli/handlers/operator.test.ts +91 -0
  130. package/src/cli/main.test.ts +10 -0
  131. package/src/cli/main.ts +21 -7
  132. package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
  133. package/src/cli/mcp/workspace-tools.ts +27 -4
  134. package/src/effect-receipt.ts +22 -0
  135. package/src/gate-decision.ts +2 -0
  136. package/src/lexicon-module.ts +9 -2
  137. package/src/lifecycle/gate-audit.ts +41 -4
  138. package/src/lifecycle/gate-checkpoint.test.ts +128 -0
  139. package/src/lifecycle/gate-checkpoint.ts +133 -0
  140. package/src/lifecycle/gate-ledger.ts +61 -0
  141. package/src/lifecycle/receipt-plan.test.ts +9 -0
  142. package/src/lifecycle/receipt-plan.ts +4 -1
  143. package/src/lifecycle/run-ledger.ts +4 -0
  144. package/src/lint/rules/op/index.ts +9 -3
  145. package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
  146. package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
  147. package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
  148. package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
  149. package/src/op/activity-contract.ts +22 -1
  150. package/src/op/builders.ts +21 -1
  151. package/src/op/effect-step.test.ts +95 -1
  152. package/src/op/gate-alias.e2e.test.ts +78 -0
  153. package/src/op/gate-alias.test.ts +350 -0
  154. package/src/op/gate-alias.ts +137 -0
  155. package/src/op/gate-checkpoint.test.ts +284 -0
  156. package/src/op/gate-checkpoint.ts +322 -0
  157. package/src/op/gate.ts +198 -4
  158. package/src/op/index.ts +4 -1
  159. package/src/op/local-executor.test.ts +65 -0
  160. package/src/op/local-executor.ts +248 -12
  161. package/src/op/local-output.ts +18 -2
  162. package/src/op/op-ir.test.ts +25 -0
  163. package/src/op/op-ir.ts +8 -0
  164. package/src/op/receipt-store.ts +13 -3
  165. package/src/op/runtime.ts +8 -0
  166. package/src/op/runtimes/local.ts +5 -0
  167. package/src/op/step-output-ref.test.ts +68 -24
  168. package/src/op/step-output-ref.ts +85 -65
  169. package/src/op/types.ts +32 -0
  170. package/src/workspace/agent-cli.ts +2 -0
  171. package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  172. package/src/workspace/conformance/writer.ts +1 -1
  173. package/src/workspace/decide.test.ts +87 -3
  174. package/src/workspace/decide.ts +142 -17
  175. package/src/workspace/lineage-upgrade.test.ts +21 -0
  176. package/src/workspace/lineage-upgrade.ts +19 -11
  177. package/src/workspace/point-answer.schema.json +16 -1
  178. package/src/workspace/points-cli.ts +16 -8
  179. package/src/workspace/points-write.schema.json +39 -4
  180. package/src/workspace/points.schema.json +42 -3
  181. package/src/workspace/reason-codes.ts +2 -1
  182. package/src/workspace/record-assets.test.ts +14 -0
  183. package/src/workspace/records-cli.ts +28 -7
  184. package/src/workspace/records-write-commands.e2e.test.ts +32 -0
  185. package/src/workspace/records-write.ts +2 -3
  186. package/src/workspace/records.schema.json +2 -1
  187. package/src/workspace/records.ts +17 -4
  188. package/src/workspace/trust/gate-seal.test.ts +29 -2
  189. package/src/workspace/trust/gate-seal.ts +46 -2
  190. package/src/workspace/trust/seal.ts +1 -1
  191. package/src/workspace/trust/trust.test.ts +105 -0
  192. package/src/workspace/trust/verify-cli.ts +2 -1
  193. package/src/workspace/trust/verify.ts +106 -13
  194. package/src/workspace/write-scope.test.ts +18 -0
  195. package/src/workspace/write-scope.ts +16 -5
@@ -0,0 +1,350 @@
1
+ /**
2
+ * #3949: a gate can carry aliases, so an Op can be restructured without
3
+ * orphaning the approvals on the ledger. loomster's case: three per-tier
4
+ * upgrade Ops, each with an `approve` gate, merged into one Op whose gates
5
+ * are its waves.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import { approvalsAnswering, evaluateGate, memoryGateLedgerPort, type GateApprovalRule, type GateCheckInput } from "./gate";
10
+ import { aliasingGate, gateAliasProblems, resolveGateAliases, validateGateAliases } from "./gate-alias";
11
+ import { gate } from "./builders";
12
+ import { decideGateResume } from "./gate-checkpoint";
13
+ import { runOpLocally } from "./local-executor";
14
+ import { renderHuman } from "./local-output";
15
+ import { stepOutput } from "./step-output-ref";
16
+ import { parseGateLedger, type GateResolutionRecord, type PendingGateRecord } from "../lifecycle/gate-ledger";
17
+ import { gateSealPayload, gateSealVersion } from "../workspace/trust/gate-seal";
18
+ import { deriveGateAudit } from "../lifecycle/gate-audit";
19
+ import type { AppliedGateRecord } from "../lifecycle/gate-applied";
20
+ import type { ActivityFn } from "./activity-registry";
21
+ import type { OpConfig } from "./types";
22
+
23
+ const PLAN = `jcs1-sha256:${"a".repeat(64)}`;
24
+ const MOVED = `jcs1-sha256:${"b".repeat(64)}`;
25
+ const CHECKPOINT = `jcs1-sha256:${"c".repeat(64)}`;
26
+ const COMMIT = "1".repeat(40);
27
+ const at = (minute: number) => `2026-10-12T12:${String(minute).padStart(2, "0")}:00.000Z`;
28
+ const NOW = at(30);
29
+
30
+ const pending = (op: string, gateName: string, over: Partial<PendingGateRecord> = {}): PendingGateRecord => ({
31
+ version: 1,
32
+ kind: "pending",
33
+ op,
34
+ gate: gateName,
35
+ timestamp: at(0),
36
+ expiresAt: "2099-01-01T00:00:00.000Z",
37
+ planDigest: PLAN,
38
+ runId: "old-run",
39
+ ...over,
40
+ });
41
+
42
+ const approval = (op: string, gateName: string, resolvedBy: string, over: Partial<GateResolutionRecord> = {}): GateResolutionRecord => ({
43
+ version: 1,
44
+ op,
45
+ gate: gateName,
46
+ resolvedBy,
47
+ timestamp: at(5),
48
+ planDigest: PLAN,
49
+ ...over,
50
+ });
51
+
52
+ /** The in-memory port, with each Op's ledger read on its own as the git port reads `_gates/<op>.jsonl`. */
53
+ function ports(seed: { resolutions?: GateResolutionRecord[]; pending?: PendingGateRecord[]; applied?: AppliedGateRecord[] } = {}) {
54
+ const base = memoryGateLedgerPort(seed);
55
+ return {
56
+ ...base,
57
+ async read(op: string) {
58
+ const all = await base.read(op);
59
+ return { resolutions: all.resolutions.filter((r) => r.op === op), pending: all.pending.filter((p) => p.op === op) };
60
+ },
61
+ };
62
+ }
63
+
64
+ const TIER_1 = { op: "upgrade-tier-1", gate: "approve" };
65
+ const input = (over: Partial<GateCheckInput> = {}): GateCheckInput => ({
66
+ op: "upgrade",
67
+ gate: "approve-tier-1",
68
+ planDigest: PLAN,
69
+ now: NOW,
70
+ aliases: [TIER_1],
71
+ ...over,
72
+ });
73
+
74
+ describe("declaring aliases", () => {
75
+ test("the builder keeps them and fills nothing in", () => {
76
+ const step = gate("approve-tier-1", { plan: "p", aliases: [{ op: "upgrade-tier-1", gate: "approve" }, { gate: "old" }] });
77
+ expect(step.aliases).toEqual([{ op: "upgrade-tier-1", gate: "approve" }, { gate: "old" }]);
78
+ expect(resolveGateAliases(step, "upgrade")).toEqual([TIER_1, { op: "upgrade", gate: "old" }]);
79
+ });
80
+
81
+ test("a gate with no plan, or one that asks a decision point, takes none", () => {
82
+ expect(() => gate("approve", { aliases: [{ gate: "old" }] })).toThrow(/aliases need a plan/);
83
+ expect(gateAliasProblems([{ gate: "old" }], { plan: "p", point: "prod" })).toContain("a gate that asks a decision point can't take aliases");
84
+ expect(gateAliasProblems([{ gate: "old" }, { gate: "old" }], { plan: "p" })).toContain("alias old is listed twice");
85
+ });
86
+ });
87
+
88
+ describe("OPS017's check", () => {
89
+ const merged: OpConfig = {
90
+ name: "upgrade",
91
+ overview: "upgrade every tier",
92
+ phases: [{ name: "Wave 1", steps: [{ kind: "gate", gate: "approve-tier-1", plan: PLAN, aliases: [TIER_1] }] }],
93
+ };
94
+ const tier1: OpConfig = { name: "upgrade-tier-1", overview: "tier 1", phases: [{ name: "Gate", steps: [{ kind: "gate", gate: "approve" }] }] };
95
+
96
+ test("refuses an alias that is still a gate", () => {
97
+ expect(validateGateAliases([merged])).toEqual([]);
98
+ const [issue] = validateGateAliases([merged, tier1]);
99
+ expect(issue).toMatchObject({ opName: "upgrade", gate: "approve-tier-1" });
100
+ expect(issue!.message).toContain("alias upgrade-tier-1/approve is still a gate");
101
+ });
102
+
103
+ test("refuses one alias on two gates", () => {
104
+ const twice: OpConfig = {
105
+ ...merged,
106
+ phases: [...merged.phases, { name: "Wave 2", steps: [{ kind: "gate", gate: "approve-tier-2", plan: PLAN, aliases: [TIER_1] }] }],
107
+ };
108
+ expect(validateGateAliases([twice]).map((i) => i.message)).toEqual([expect.stringContaining("is also an alias of upgrade/approve-tier-1")]);
109
+ });
110
+
111
+ test("refuses a gate that drops an identity.gates rule its alias had", () => {
112
+ expect(validateGateAliases([merged], new Map([["approve", null]]))[0]!.message).toContain("identity.gates names its alias approve");
113
+ expect(validateGateAliases([merged], new Map([["approve", "human"], ["approve-tier-1", null]]))[0]!.message).toContain("must keep its alias's class");
114
+ expect(validateGateAliases([merged], new Map([["approve", "human"], ["approve-tier-1", "human"]]))).toEqual([]);
115
+ expect(validateGateAliases([merged], new Map([["approve", null], ["approve-tier-1", "human"]]))).toEqual([]);
116
+ });
117
+
118
+ test("names the gate that aliases an old name, for chant approve", () => {
119
+ expect(aliasingGate([merged, undefined], "upgrade-tier-1", "approve")).toEqual({ op: "upgrade", gate: "approve-tier-1" });
120
+ expect(aliasingGate([merged], "upgrade-tier-2", "approve")).toBeUndefined();
121
+ });
122
+ });
123
+
124
+ describe("a gate passes on an approval recorded under its alias", () => {
125
+ test("the approval is written again under the new name, citing the line it came from", async () => {
126
+ const port = ports({ pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "alice", { url: "https://example.test/pr/1" })] });
127
+ const check = await evaluateGate(port, input());
128
+ expect(check).toMatchObject({ satisfied: true, via: "alias" });
129
+ expect(port.resolved).toHaveLength(1);
130
+ expect(port.resolved[0]).toMatchObject({
131
+ op: "upgrade",
132
+ gate: "approve-tier-1",
133
+ resolvedBy: "alice",
134
+ timestamp: NOW,
135
+ planDigest: PLAN,
136
+ url: "https://example.test/pr/1",
137
+ approver: { kind: "human" },
138
+ carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: at(5) },
139
+ });
140
+ expect(port.appended).toHaveLength(0);
141
+
142
+ // The next run counts the carried line as its own and carries nothing more.
143
+ const again = await evaluateGate(port, input({ now: at(40) }));
144
+ expect(again).toMatchObject({ satisfied: true, resolution: { carriedFrom: { op: "upgrade-tier-1" } } });
145
+ expect(again.satisfied && again.via).toBeUndefined();
146
+ expect(port.resolved).toHaveLength(1);
147
+ });
148
+
149
+ test("the alias's op defaults to the gate's own, for a gate renamed in place", async () => {
150
+ const port = ports({ pending: [pending("upgrade", "approve")], resolutions: [approval("upgrade", "approve", "alice")] });
151
+ expect(await evaluateGate(port, input({ aliases: resolveGateAliases({ aliases: [{ gate: "approve" }] }, "upgrade") }))).toMatchObject({ satisfied: true, via: "alias" });
152
+ });
153
+
154
+ const refusedBecause = async (name: string, seed: Parameters<typeof ports>[0], over: Partial<GateCheckInput> = {}) => {
155
+ const port = ports(seed);
156
+ const check = await evaluateGate(port, input(over));
157
+ expect(check.satisfied, name).toBe(false);
158
+ expect(port.resolved, name).toHaveLength(0);
159
+ };
160
+
161
+ test("but not once the old pending fact has expired", () =>
162
+ refusedBecause("expired", { pending: [pending("upgrade-tier-1", "approve", { expiresAt: at(20) })], resolutions: [approval("upgrade-tier-1", "approve", "alice")] }));
163
+
164
+ test("nor for another plan", async () => {
165
+ await refusedBecause("moved", { pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "alice")] }, { planDigest: MOVED });
166
+ await refusedBecause("no plan", { pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "alice")] }, { planDigest: undefined });
167
+ });
168
+
169
+ test("nor from an approval older than the old fact, or for another environment", async () => {
170
+ await refusedBecause("stale", { pending: [pending("upgrade-tier-1", "approve", { timestamp: at(10) })], resolutions: [approval("upgrade-tier-1", "approve", "alice")] });
171
+ await refusedBecause("env", { pending: [pending("upgrade-tier-1", "approve", { environment: "prod" })], resolutions: [approval("upgrade-tier-1", "approve", "alice", { environment: "prod" })] }, { environment: "staging" });
172
+ });
173
+
174
+ test("nor from an agent, or a policy permit", async () => {
175
+ await refusedBecause("agent", { pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "bot", { approver: { kind: "agent" } })] });
176
+ await refusedBecause("mcp", { pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "bot", { origin: "mcp" })] });
177
+ });
178
+
179
+ test("nor from an approval spent under the old name", () =>
180
+ refusedBecause("spent", {
181
+ pending: [pending("upgrade-tier-1", "approve")],
182
+ resolutions: [approval("upgrade-tier-1", "approve", "alice")],
183
+ applied: [{ version: 1, kind: "applied", op: "upgrade-tier-1", gate: "approve", planDigest: PLAN, approvedAt: at(5), approvedBy: "alice", timestamp: at(6) }],
184
+ }));
185
+
186
+ test("nor again, once the carried line was spent under the new name", async () => {
187
+ const carried = approval("upgrade", "approve-tier-1", "alice", { timestamp: at(20), carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: at(5) } });
188
+ await refusedBecause(
189
+ "spent carried",
190
+ { pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "alice"), carried] },
191
+ { spent: [{ version: 1, kind: "applied", op: "upgrade", gate: "approve-tier-1", planDigest: PLAN, approvedAt: at(20), approvedBy: "alice", timestamp: at(21) }] },
192
+ );
193
+ });
194
+
195
+ test("a carried line stops counting once the gate no longer names its alias", async () => {
196
+ const carried = approval("upgrade", "approve-tier-1", "alice", { timestamp: at(20), carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: at(5) } });
197
+ const port = ports({ resolutions: [carried] });
198
+ expect((await evaluateGate(port, input())).satisfied).toBe(true);
199
+ expect((await evaluateGate(port, input({ aliases: [] }))).satisfied).toBe(false);
200
+ });
201
+
202
+ test("the new gate's identity rule judges the approval, and names the one it refuses", async () => {
203
+ const rule: GateApprovalRule = {
204
+ requirement: { gate: "approve-tier-1", class: null },
205
+ refuses: (r) => (r.seal ? null : `the approval by ${r.resolvedBy} is not signed`),
206
+ };
207
+ const port = { ...ports({ pending: [pending("upgrade-tier-1", "approve")], resolutions: [approval("upgrade-tier-1", "approve", "alice")] }), approvalRule: async () => rule };
208
+ const check = await evaluateGate(port, input());
209
+ expect(check.satisfied).toBe(false);
210
+ expect(!check.satisfied && check.refused?.[0]?.reason).toBe("carried from upgrade-tier-1/approve: the approval by alice is not signed");
211
+ });
212
+ });
213
+
214
+ describe("old and new approvals add up toward one quorum", () => {
215
+ const quorum = { approval: { quorum: { count: 2 }, mode: "log-only" as const } };
216
+
217
+ test("an approver under each name passes it, and only the old one is carried", async () => {
218
+ const port = ports({
219
+ pending: [pending("upgrade-tier-1", "approve"), pending("upgrade", "approve-tier-1", { timestamp: at(10), approval: quorum.approval })],
220
+ resolutions: [approval("upgrade-tier-1", "approve", "alice"), approval("upgrade", "approve-tier-1", "bob", { timestamp: at(12) })],
221
+ });
222
+ const check = await evaluateGate(port, input(quorum));
223
+ expect(check).toMatchObject({ satisfied: true, via: "alias" });
224
+ expect(check.satisfied && check.approvals?.map((r) => r.resolvedBy).sort()).toEqual(["alice", "bob"]);
225
+ expect(port.resolved.map((r) => r.resolvedBy)).toEqual(["alice"]);
226
+ });
227
+
228
+ test("one person under both names counts once, and the progress shows them", async () => {
229
+ const port = ports({
230
+ pending: [pending("upgrade-tier-1", "approve"), pending("upgrade", "approve-tier-1", { timestamp: at(10), approval: quorum.approval })],
231
+ resolutions: [approval("upgrade-tier-1", "approve", "alice"), approval("upgrade", "approve-tier-1", "alice", { timestamp: at(12) })],
232
+ });
233
+ const check = await evaluateGate(port, input(quorum));
234
+ expect(check.satisfied).toBe(false);
235
+ expect(!check.satisfied && check.quorum).toEqual({ approvers: ["alice"], need: 2 });
236
+ expect(port.resolved).toHaveLength(0);
237
+ });
238
+
239
+ test("an old approval waiting on its own quorum still counts toward the new one", async () => {
240
+ const port = ports({
241
+ pending: [pending("upgrade-tier-1", "approve", { approval: quorum.approval }), pending("upgrade-tier-2", "approve")],
242
+ resolutions: [approval("upgrade-tier-1", "approve", "alice"), approval("upgrade-tier-2", "approve", "carol")],
243
+ });
244
+ const check = await evaluateGate(port, input({ ...quorum, aliases: [TIER_1, { op: "upgrade-tier-2", gate: "approve" }] }));
245
+ expect(check).toMatchObject({ satisfied: true, via: "alias" });
246
+ expect(port.resolved.map((r) => `${r.resolvedBy}<-${r.carriedFrom?.op}`)).toEqual(["alice<-upgrade-tier-1", "carol<-upgrade-tier-2"]);
247
+ });
248
+ });
249
+
250
+ describe("a carried approval and checkpoints", () => {
251
+ const sealed = approval("upgrade-tier-1", "approve", "alice", {
252
+ relayedBy: "follower",
253
+ checkpointDigest: CHECKPOINT,
254
+ seal: { signer: "alice", key: "SHA256:x", signature: "sig", version: 2 },
255
+ });
256
+
257
+ test("its seal is checked against the line it came from, checkpoint digest included", async () => {
258
+ const port = ports({ pending: [pending("upgrade-tier-1", "approve", { checkpointDigest: CHECKPOINT })], resolutions: [sealed] });
259
+ await evaluateGate(port, input());
260
+ const carried = port.resolved[0]!;
261
+ expect(carried).not.toHaveProperty("checkpointDigest");
262
+ expect(carried.carriedFrom).toEqual({ op: "upgrade-tier-1", gate: "approve", timestamp: at(5), checkpointDigest: CHECKPOINT });
263
+ expect(carried.seal).toEqual(sealed.seal);
264
+ expect(gateSealPayload(carried).toString()).toBe(gateSealPayload(sealed).toString());
265
+ expect(gateSealVersion(carried)).toBe(2);
266
+
267
+ // The line reads back whole, and a broken citation is a malformed line.
268
+ const text = [JSON.stringify(carried), JSON.stringify({ ...carried, carriedFrom: { op: "x" } })].join("\n");
269
+ expect(parseGateLedger(text)).toMatchObject({ resolutions: [{ carriedFrom: carried.carriedFrom }], malformed: 1 });
270
+ });
271
+
272
+ test("it never lets a run resume from the new gate's checkpoint", async () => {
273
+ const config: OpConfig = {
274
+ name: "upgrade",
275
+ overview: "upgrade every tier",
276
+ phases: [{ name: "Gate", steps: [{ kind: "gate", gate: "approve-tier-1", plan: PLAN, resume: true, aliases: [TIER_1] }] }],
277
+ };
278
+ const carried = approval("upgrade", "approve-tier-1", "alice", { timestamp: at(20), checkpointDigest: CHECKPOINT, carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: at(5) } });
279
+ const port = ports({ pending: [pending("upgrade", "approve-tier-1", { timestamp: at(10), checkpointDigest: CHECKPOINT, runId: "run-1" })], resolutions: [carried] });
280
+ expect(await decideGateResume(port, config, { now: NOW, commit: COMMIT })).toEqual({ kind: "none" });
281
+ });
282
+
283
+ test("a merged Op passes its resume gate on the old approval, starting at the first phase", async () => {
284
+ const calls: string[] = [];
285
+ const activities = new Map<string, ActivityFn>([
286
+ ["snapshot", async () => (calls.push("snapshot"), { id: "snap-1" })],
287
+ ["plan", async () => (calls.push("plan"), { planDigest: PLAN })],
288
+ ["apply", async () => (calls.push("apply"), { ok: true })],
289
+ ]);
290
+ const config: OpConfig = {
291
+ name: "upgrade",
292
+ overview: "upgrade every tier",
293
+ phases: [
294
+ { name: "Prepare", steps: [{ kind: "activity", id: "snapshot", fn: "snapshot" }, { kind: "activity", id: "plan", fn: "plan" }] },
295
+ { name: "Gate", steps: [{ kind: "gate", gate: "approve-tier-1", plan: stepOutput("plan", "planDigest"), resume: true, aliases: [TIER_1] }] },
296
+ { name: "Apply", steps: [{ kind: "activity", fn: "apply", args: { snapshot: stepOutput("snapshot", "id") } }] },
297
+ ],
298
+ };
299
+ const port = ports({ pending: [pending("upgrade-tier-1", "approve", { checkpointDigest: CHECKPOINT })], resolutions: [sealed] });
300
+ const result = await runOpLocally(config, activities, {}, undefined, { gates: port, now: NOW, runId: "run-1", commit: COMMIT });
301
+ expect(result.status).toBe("ok");
302
+ expect(result.resumed).toBeUndefined();
303
+ expect(calls).toEqual(["snapshot", "plan", "apply"]);
304
+ const gateRecord = result.records.find((r) => r.fn === "gate:approve-tier-1")!;
305
+ expect(gateRecord.approval).toMatchObject({
306
+ resolvedBy: "alice",
307
+ via: "alias",
308
+ carriedFrom: [{ op: "upgrade-tier-1", gate: "approve", resolvedBy: "alice", timestamp: at(5) }],
309
+ });
310
+ expect(port.checkpoints.size).toBe(0);
311
+ const lines: string[] = [];
312
+ renderHuman(result, (l) => lines.push(l));
313
+ expect(lines).toContain(` [carried] alice's approval of upgrade-tier-1/approve at ${at(5)}`);
314
+ });
315
+ });
316
+
317
+ describe("the rule an alias reads approvals by", () => {
318
+ test("approvalsAnswering takes the standing fact's gate, plan and environment, since the fact", () => {
319
+ const standing = pending("upgrade-tier-1", "approve", { timestamp: at(3) });
320
+ const rs = [
321
+ approval("upgrade-tier-1", "approve", "a"),
322
+ approval("upgrade-tier-1", "approve", "b", { timestamp: at(1) }),
323
+ approval("upgrade-tier-1", "approve", "c", { planDigest: MOVED }),
324
+ approval("upgrade-tier-1", "approve", "d", { environment: "prod" }),
325
+ approval("upgrade-tier-1", "other", "e"),
326
+ ];
327
+ expect(approvalsAnswering(rs, standing).map((r) => r.resolvedBy)).toEqual(["a"]);
328
+ });
329
+ });
330
+
331
+ describe("the audit of a carried approval", () => {
332
+ const carried = approval("upgrade", "approve-tier-1", "alice", { timestamp: at(20), carriedFrom: { op: "upgrade-tier-1", gate: "approve", timestamp: at(5) } });
333
+ const lines = (...records: unknown[]) => records.map((record) => ({ record }));
334
+
335
+ test("names where it came from, and finds its request under the old name", () => {
336
+ const { entries, gaps } = deriveGateAudit({
337
+ ledgers: [
338
+ { op: "upgrade-tier-1", lines: lines(pending("upgrade-tier-1", "approve"), approval("upgrade-tier-1", "approve", "alice")) },
339
+ { op: "upgrade", lines: lines(carried) },
340
+ ],
341
+ });
342
+ expect(entries.find((e) => e.op === "upgrade")).toMatchObject({ kind: "approved", carriedFrom: { op: "upgrade-tier-1", gate: "approve", at: at(5) } });
343
+ expect(gaps).toEqual([]);
344
+ });
345
+
346
+ test("says when the cited line is missing", () => {
347
+ const { gaps } = deriveGateAudit({ ledgers: [{ op: "upgrade-tier-1", lines: lines(pending("upgrade-tier-1", "approve")) }, { op: "upgrade", lines: lines(carried) }] });
348
+ expect(gaps.map((g) => g.kind)).toEqual(["carried-without-source"]);
349
+ });
350
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Gate aliases (#3949, epic #3946): the names a gate had before its Op was
3
+ * restructured, so the approvals already on the ledger under them still
4
+ * count.
5
+ *
6
+ * A gate's identity is its name and its Op's: the ledger is per Op
7
+ * (`_gates/<op>.jsonl`) and every decision filters by gate. Merging three
8
+ * per-tier Ops into one wave-driven Op renames every gate and orphans the
9
+ * approvals recorded under the old names. A gate declares those names as
10
+ * `aliases: [{ op?, gate }]`, `op` defaulting to its own Op.
11
+ *
12
+ * `evaluateGate` (`./gate.ts`) decides which approvals an alias admits, and
13
+ * writes each one it counts again under the new name with `carriedFrom`
14
+ * naming the line it came from. This module holds the parts that read only
15
+ * the Op's config: where its gates are, what an alias resolves to, what the
16
+ * builder and OPS017 refuse, and which gate aliases an old name, which
17
+ * `chant approve` asks before it records an approval under that name.
18
+ */
19
+
20
+ import type { GateAlias, GateStep, OpConfig, StepDefinition } from "./types";
21
+ import { gateName } from "./gate-name";
22
+
23
+ /** An alias with its Op filled in. */
24
+ export interface ResolvedGateAlias {
25
+ op: string;
26
+ gate: string;
27
+ }
28
+
29
+ /** Every gate step of `config`, main phases first, then `onFailure`, inside effects too, with the phase it sits in. */
30
+ export function gateStepsOf(config: Pick<OpConfig, "phases" | "onFailure">): Array<{ phase: string; step: GateStep }> {
31
+ const out: Array<{ phase: string; step: GateStep }> = [];
32
+ const visit = (phase: string, step: StepDefinition) => {
33
+ if (step.kind === "gate") out.push({ phase, step });
34
+ else if (step.kind === "effect") for (const nested of step.steps) visit(phase, nested);
35
+ };
36
+ for (const phase of [...config.phases, ...(config.onFailure ?? [])]) for (const step of phase.steps) visit(phase.name, step);
37
+ return out;
38
+ }
39
+
40
+ /** A gate's aliases with each `op` filled in from `op`, the gate's own Op. */
41
+ export function resolveGateAliases(step: Pick<GateStep, "aliases">, op: string): ResolvedGateAlias[] {
42
+ return (step.aliases ?? []).map((a) => ({ op: a.op ?? op, gate: a.gate }));
43
+ }
44
+
45
+ /** Why a gate's `aliases` can't stand on the step itself, one line each. The builder throws the first. */
46
+ export function gateAliasProblems(aliases: readonly GateAlias[], step: Pick<GateStep, "plan" | "point">): string[] {
47
+ const problems: string[] = [];
48
+ if (step.plan === undefined) {
49
+ problems.push("aliases need a plan: an alias admits only approvals of the plan this run planned, and a gate with no plan has none");
50
+ }
51
+ if (step.point !== undefined) problems.push("a gate that asks a decision point can't take aliases");
52
+ const seen = new Set<string>();
53
+ for (const a of aliases) {
54
+ if (typeof a?.gate !== "string" || a.gate === "") {
55
+ problems.push("each alias names a gate");
56
+ continue;
57
+ }
58
+ if (a.op !== undefined && (typeof a.op !== "string" || a.op === "")) problems.push(`alias ${JSON.stringify(a.gate)} names an empty op`);
59
+ const key = `${a.op ?? ""}/${a.gate}`;
60
+ if (seen.has(key)) problems.push(`alias ${a.op ? `${a.op}/` : ""}${a.gate} is listed twice`);
61
+ seen.add(key);
62
+ }
63
+ return problems;
64
+ }
65
+
66
+ /** The gate of `ops` whose aliases include `op`/`gate`, or undefined when none does. */
67
+ export function aliasingGate(
68
+ ops: Iterable<Pick<OpConfig, "name" | "phases" | "onFailure"> | undefined>,
69
+ op: string,
70
+ gate: string,
71
+ ): { op: string; gate: string } | undefined {
72
+ for (const config of ops) {
73
+ if (!config || !Array.isArray(config.phases)) continue;
74
+ for (const { step } of gateStepsOf(config)) {
75
+ if (resolveGateAliases(step, config.name).some((a) => a.op === op && a.gate === gate)) return { op: config.name, gate: gateName(step) };
76
+ }
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ /** One OPS017 finding: the Op, phase and gate it is about, and what is wrong. */
82
+ export interface GateAliasIssue {
83
+ opName: string;
84
+ phase: string;
85
+ gate: string;
86
+ message: string;
87
+ }
88
+
89
+ /**
90
+ * OPS017's check (#3949) over every Op in a build. An alias must stand on a
91
+ * gate bound to a plan that asks no decision point, must not name a gate
92
+ * that still exists, must not name the gate itself, and must not be claimed
93
+ * by two gates, since an approval would then be carried twice. When the
94
+ * workspace's `identity.gates` names an alias, the new gate must be named
95
+ * too, with the alias's class when it has one: `identityGates` maps each
96
+ * gate the declaration names to its class (null for any signer). Absent,
97
+ * outside a workspace, that part is skipped.
98
+ */
99
+ export function validateGateAliases(
100
+ ops: ReadonlyArray<Pick<OpConfig, "name" | "phases" | "onFailure">>,
101
+ identityGates?: ReadonlyMap<string, string | null>,
102
+ ): GateAliasIssue[] {
103
+ const issues: GateAliasIssue[] = [];
104
+ const existing = new Set<string>();
105
+ for (const config of ops) for (const { step } of gateStepsOf(config)) existing.add(`${config.name}/${gateName(step)}`);
106
+ const claimed = new Map<string, string>();
107
+
108
+ for (const config of ops) {
109
+ for (const { phase, step } of gateStepsOf(config)) {
110
+ if (step.aliases === undefined) continue;
111
+ const name = gateName(step);
112
+ const issue = (message: string) => issues.push({ opName: config.name, phase, gate: name, message });
113
+ for (const problem of gateAliasProblems(step.aliases, step)) issue(problem);
114
+ for (const alias of resolveGateAliases(step, config.name)) {
115
+ if (alias.gate === "") continue;
116
+ const key = `${alias.op}/${alias.gate}`;
117
+ if (alias.op === config.name && alias.gate === name) {
118
+ issue(`alias ${key} is the gate's own name`);
119
+ continue;
120
+ }
121
+ if (existing.has(key)) issue(`alias ${key} is still a gate; remove that gate, or this alias, so one approval doesn't answer two gates`);
122
+ const owner = claimed.get(key);
123
+ if (owner !== undefined && owner !== `${config.name}/${name}`) issue(`alias ${key} is also an alias of ${owner}, so its approvals would be carried twice`);
124
+ else claimed.set(key, `${config.name}/${name}`);
125
+ if (identityGates?.has(alias.gate)) {
126
+ const want = identityGates.get(alias.gate) ?? null;
127
+ if (!identityGates.has(name)) {
128
+ issue(`identity.gates names its alias ${alias.gate}, so gate ${name} needs an identity.gates entry too, or approvals that needed a seal would count without one`);
129
+ } else if (want !== null && identityGates.get(name) !== want) {
130
+ issue(`identity.gates.${alias.gate} needs class ${want}, and identity.gates.${name} needs ${identityGates.get(name) ?? "any signer"}; the gate must keep its alias's class`);
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return issues;
137
+ }