@intentius/chant 0.45.0 → 0.49.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 (223) hide show
  1. package/dist/audit/core.d.ts +21 -3
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +3 -2
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/build.d.ts +3 -3
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/cli/commands/build.d.ts.map +1 -1
  9. package/dist/cli/commands/check-lexicon.d.ts +14 -0
  10. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  11. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  12. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts.map +1 -1
  14. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  15. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  16. package/dist/cli/handlers/search.d.ts.map +1 -1
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  19. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  20. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  21. package/dist/cli/plugins.d.ts +1 -1
  22. package/dist/cli/plugins.d.ts.map +1 -1
  23. package/dist/cli/registry.d.ts +7 -0
  24. package/dist/cli/registry.d.ts.map +1 -1
  25. package/dist/cli/reporters/stylish.d.ts +15 -1
  26. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  27. package/dist/codegen/lexicon-regen.d.ts +11 -0
  28. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  29. package/dist/codegen/validate.d.ts +10 -0
  30. package/dist/codegen/validate.d.ts.map +1 -1
  31. package/dist/components/auto-release.d.ts +4 -0
  32. package/dist/components/auto-release.d.ts.map +1 -1
  33. package/dist/components/starter-plugin.d.ts +2 -0
  34. package/dist/components/starter-plugin.d.ts.map +1 -1
  35. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  36. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  37. package/dist/components/verbs/index.d.ts +8 -0
  38. package/dist/components/verbs/index.d.ts.map +1 -1
  39. package/dist/components/verbs/r2-sync.d.ts +76 -0
  40. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  41. package/dist/components/verbs/wrangler.d.ts +108 -0
  42. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  43. package/dist/config.d.ts +54 -0
  44. package/dist/config.d.ts.map +1 -1
  45. package/dist/deep-observation.d.ts +14 -0
  46. package/dist/deep-observation.d.ts.map +1 -1
  47. package/dist/effect-receipt.d.ts +177 -0
  48. package/dist/effect-receipt.d.ts.map +1 -0
  49. package/dist/env.d.ts +12 -1
  50. package/dist/env.d.ts.map +1 -1
  51. package/dist/fold/subset.d.ts +15 -2
  52. package/dist/fold/subset.d.ts.map +1 -1
  53. package/dist/index.d.ts +4 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/lexicon.d.ts +223 -2
  56. package/dist/lexicon.d.ts.map +1 -1
  57. package/dist/lifecycle/change-set.d.ts +33 -5
  58. package/dist/lifecycle/change-set.d.ts.map +1 -1
  59. package/dist/lifecycle/index.d.ts +3 -0
  60. package/dist/lifecycle/index.d.ts.map +1 -1
  61. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  62. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  63. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  64. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  65. package/dist/lifecycle/release-ledger.d.ts +20 -0
  66. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  67. package/dist/lifecycle/teardown.d.ts +132 -0
  68. package/dist/lifecycle/teardown.d.ts.map +1 -0
  69. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  70. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  71. package/dist/lint/engine.d.ts +6 -2
  72. package/dist/lint/engine.d.ts.map +1 -1
  73. package/dist/lint/knowledge-checks.d.ts +48 -0
  74. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  75. package/dist/lint/output-checks.d.ts +5 -0
  76. package/dist/lint/output-checks.d.ts.map +1 -0
  77. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  78. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  79. package/dist/lint/post-synth.d.ts +12 -0
  80. package/dist/lint/post-synth.d.ts.map +1 -1
  81. package/dist/lint/receipt-checks.d.ts +9 -0
  82. package/dist/lint/receipt-checks.d.ts.map +1 -0
  83. package/dist/lint/rule.d.ts +31 -0
  84. package/dist/lint/rule.d.ts.map +1 -1
  85. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  86. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  87. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  88. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  89. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  90. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  91. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  92. package/dist/lint/rules/index.d.ts +4 -1
  93. package/dist/lint/rules/index.d.ts.map +1 -1
  94. package/dist/okf-read.d.ts +78 -0
  95. package/dist/okf-read.d.ts.map +1 -0
  96. package/dist/op/builders.d.ts +134 -8
  97. package/dist/op/builders.d.ts.map +1 -1
  98. package/dist/op/index.d.ts +4 -2
  99. package/dist/op/index.d.ts.map +1 -1
  100. package/dist/op/local-executor.d.ts +2 -1
  101. package/dist/op/local-executor.d.ts.map +1 -1
  102. package/dist/op/receipt-store.d.ts +138 -0
  103. package/dist/op/receipt-store.d.ts.map +1 -0
  104. package/dist/op/types.d.ts +31 -1
  105. package/dist/op/types.d.ts.map +1 -1
  106. package/dist/secret-materialization.d.ts +138 -0
  107. package/dist/secret-materialization.d.ts.map +1 -0
  108. package/dist/secret-provenance.d.ts +218 -0
  109. package/dist/secret-provenance.d.ts.map +1 -0
  110. package/dist/serializer.d.ts +11 -0
  111. package/dist/serializer.d.ts.map +1 -1
  112. package/dist/testing.d.ts +136 -0
  113. package/dist/testing.d.ts.map +1 -0
  114. package/dist/yaml.d.ts.map +1 -1
  115. package/package.json +9 -1
  116. package/src/audit/core.test.ts +57 -0
  117. package/src/audit/core.ts +0 -0
  118. package/src/audit/detect-bundle.test.ts +1 -1
  119. package/src/audit/discover.test.ts +24 -0
  120. package/src/audit/discover.ts +11 -2
  121. package/src/audit/rules-doc.ts +11 -1
  122. package/src/build.test.ts +41 -0
  123. package/src/build.ts +34 -6
  124. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  125. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  126. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  127. package/src/cli/commands/audit.test.ts +53 -0
  128. package/src/cli/commands/audit.ts +1 -1
  129. package/src/cli/commands/build.test.ts +211 -0
  130. package/src/cli/commands/build.ts +126 -8
  131. package/src/cli/commands/check-lexicon.test.ts +45 -1
  132. package/src/cli/commands/check-lexicon.ts +45 -0
  133. package/src/cli/commands/lexicon-surface-diff.ts +9 -0
  134. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  135. package/src/cli/commands/lint.ts +32 -7
  136. package/src/cli/handlers/explain.test.ts +70 -1
  137. package/src/cli/handlers/graph.ts +4 -4
  138. package/src/cli/handlers/lifecycle.test.ts +345 -1
  139. package/src/cli/handlers/lifecycle.ts +300 -11
  140. package/src/cli/handlers/search.ts +5 -2
  141. package/src/cli/main.ts +12 -1
  142. package/src/cli/mcp/resource-handlers.ts +38 -1
  143. package/src/cli/mcp/server.test.ts +58 -1
  144. package/src/cli/mcp/tools/explain.ts +51 -2
  145. package/src/cli/plugins.ts +4 -2
  146. package/src/cli/registry.ts +7 -0
  147. package/src/cli/reporters/stylish.test.ts +154 -0
  148. package/src/cli/reporters/stylish.ts +154 -33
  149. package/src/codegen/lexicon-regen.ts +19 -1
  150. package/src/codegen/validate.test.ts +33 -0
  151. package/src/codegen/validate.ts +21 -2
  152. package/src/components/auto-release.ts +6 -0
  153. package/src/components/registry.test.ts +7 -2
  154. package/src/components/starter-plugin.ts +17 -0
  155. package/src/components/verbs/ensure-secret.test.ts +130 -0
  156. package/src/components/verbs/ensure-secret.ts +79 -0
  157. package/src/components/verbs/index.ts +8 -0
  158. package/src/components/verbs/r2-sync.test.ts +107 -0
  159. package/src/components/verbs/r2-sync.ts +124 -0
  160. package/src/components/verbs/wrangler.test.ts +170 -0
  161. package/src/components/verbs/wrangler.ts +241 -0
  162. package/src/config.test.ts +55 -0
  163. package/src/config.ts +88 -1
  164. package/src/deep-observation.test.ts +19 -0
  165. package/src/deep-observation.ts +17 -0
  166. package/src/effect-receipt-exclusion.test.ts +190 -0
  167. package/src/effect-receipt.test.ts +419 -0
  168. package/src/effect-receipt.ts +412 -0
  169. package/src/env.test.ts +35 -1
  170. package/src/env.ts +17 -3
  171. package/src/fold/subset.test.ts +26 -0
  172. package/src/fold/subset.ts +45 -19
  173. package/src/index.ts +4 -0
  174. package/src/lexicon.ts +227 -2
  175. package/src/lifecycle/change-set.ts +46 -7
  176. package/src/lifecycle/index.ts +3 -0
  177. package/src/lifecycle/observation-baseline.test.ts +46 -0
  178. package/src/lifecycle/observation-baseline.ts +33 -1
  179. package/src/lifecycle/receipt-plan.test.ts +250 -0
  180. package/src/lifecycle/receipt-plan.ts +249 -0
  181. package/src/lifecycle/release-ledger.ts +20 -0
  182. package/src/lifecycle/teardown.test.ts +568 -0
  183. package/src/lifecycle/teardown.ts +359 -0
  184. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  185. package/src/lifecycle/unobserved-gate.ts +102 -0
  186. package/src/lint/engine.ts +7 -1
  187. package/src/lint/knowledge-checks.test.ts +80 -0
  188. package/src/lint/knowledge-checks.ts +74 -0
  189. package/src/lint/output-checks.test.ts +85 -0
  190. package/src/lint/output-checks.ts +99 -0
  191. package/src/lint/pipeline-change-gate.test.ts +144 -0
  192. package/src/lint/pipeline-change-gate.ts +153 -0
  193. package/src/lint/post-synth.ts +15 -0
  194. package/src/lint/receipt-checks.test.ts +101 -0
  195. package/src/lint/receipt-checks.ts +93 -0
  196. package/src/lint/rule.ts +23 -0
  197. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  198. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  199. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  200. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  201. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  202. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  203. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  204. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  205. package/src/lint/rules/index.ts +10 -1
  206. package/src/okf-read.test.ts +149 -0
  207. package/src/okf-read.ts +197 -0
  208. package/src/op/builders.ts +179 -8
  209. package/src/op/effect-step.test.ts +311 -0
  210. package/src/op/index.ts +10 -3
  211. package/src/op/local-executor.ts +172 -25
  212. package/src/op/op.test.ts +25 -2
  213. package/src/op/receipt-store.ts +211 -0
  214. package/src/op/types.ts +33 -1
  215. package/src/secret-materialization.test.ts +199 -0
  216. package/src/secret-materialization.ts +235 -0
  217. package/src/secret-provenance.test.ts +388 -0
  218. package/src/secret-provenance.ts +475 -0
  219. package/src/serializer.ts +12 -0
  220. package/src/testing.test.ts +261 -0
  221. package/src/testing.ts +338 -0
  222. package/src/yaml.test.ts +88 -0
  223. package/src/yaml.ts +76 -6
@@ -0,0 +1,311 @@
1
+ /**
2
+ * effect() step + receipt store seam + local-executor semantics (#1834, epic
3
+ * #1703). The contract under test:
4
+ *
5
+ * - the builder takes the typed EffectReceipt declaration only (no string
6
+ * form — enforced at the type level and at runtime);
7
+ * - read-compare-run-write: a matching receipt skips the nested steps; a
8
+ * mismatch runs them and writes the receipt ONLY on their success, last;
9
+ * - a nested-step failure leaves the receipt untouched (stale), so a rerun
10
+ * re-proposes the effect;
11
+ * - `receiptStaleness` (WatchOp's phase) is read-only: it reports absent and
12
+ * differing receipts as findings and never writes.
13
+ */
14
+ import { describe, test, expect } from "vitest";
15
+ import { EffectReceipt, receiptExpectation, EXISTENCE_EXPECTATION } from "../effect-receipt";
16
+ import { INTRINSIC_MARKER, type Intrinsic } from "../intrinsic";
17
+ import { effect, phase } from "./builders";
18
+ import {
19
+ receiptActivities,
20
+ receiptCheckInput,
21
+ type ReceiptStore,
22
+ type EffectReceiptRef,
23
+ } from "./receipt-store";
24
+ import type { ActivityFn, ActivityProfile } from "./activity-registry";
25
+ import type { OpConfig, ActivityStep } from "./types";
26
+ import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure } from "./local-executor";
27
+
28
+ function fakeIntrinsic(json: unknown): Intrinsic {
29
+ return {
30
+ [INTRINSIC_MARKER]: true,
31
+ toJSON: () => json,
32
+ };
33
+ }
34
+
35
+ const seeded = EffectReceipt("seeded", {
36
+ effect: "db-seed",
37
+ flavor: "hash",
38
+ inputs: { file: "seed.sql", version: 3 },
39
+ });
40
+
41
+ const step = (fn: string, args?: Record<string, unknown>): ActivityStep => ({
42
+ kind: "activity",
43
+ fn,
44
+ ...(args ? { args } : {}),
45
+ });
46
+
47
+ // ── Builder ───────────────────────────────────────────────────────────────────
48
+
49
+ describe("effect() builder", () => {
50
+ test("wraps nested steps with the receipt's identity data and stamps a static expectation", () => {
51
+ const s = effect(seeded, [step("shellCmd", { cmd: "npm run db:seed" })]);
52
+ expect(s.kind).toBe("effect");
53
+ expect(s.receipt).toEqual({
54
+ name: "seeded",
55
+ effect: "db-seed",
56
+ flavor: "hash",
57
+ inputs: { file: "seed.sql", version: 3 },
58
+ });
59
+ expect(s.expectation).toBe(receiptExpectation(seeded));
60
+ expect(s.steps.map((n) => (n.kind === "activity" ? n.fn : n.signalName))).toEqual(["shellCmd"]);
61
+ });
62
+
63
+ test("existence receipts always get the constant expectation, reference inputs or not", () => {
64
+ const r = EffectReceipt("booted", {
65
+ effect: "bootstrap",
66
+ flavor: "existence",
67
+ inputs: { endpoint: fakeIntrinsic({ __attrRef: { entity: "Db", attribute: "endpoint" } }) },
68
+ });
69
+ expect(effect(r, []).expectation).toBe(EXISTENCE_EXPECTATION);
70
+ });
71
+
72
+ test("a hash receipt with reference inputs carries no synthesis-time expectation (resolves at run, #1703 decision 5)", () => {
73
+ const r = EffectReceipt("migrated", {
74
+ effect: "db-migrate",
75
+ flavor: "hash",
76
+ inputs: { endpoint: fakeIntrinsic({ __attrRef: { entity: "Db", attribute: "endpoint" } }) },
77
+ });
78
+ const s = effect(r, []);
79
+ expect(s.expectation).toBeUndefined();
80
+ expect("expectation" in s).toBe(false);
81
+ });
82
+
83
+ test("preserves authored order and nested gates", () => {
84
+ const s = effect(seeded, [
85
+ { kind: "gate", signalName: "approve-seed" },
86
+ step("shellCmd", { cmd: "seed" }),
87
+ ]);
88
+ expect(s.steps.map((n) => n.kind)).toEqual(["gate", "activity"]);
89
+ });
90
+
91
+ test("refuses anything that is not the typed receipt declaration", () => {
92
+ expect(() => effect({ name: "seeded" } as never, [])).toThrow(/no string form/);
93
+ // Type level: a string receipt name must not compile.
94
+ // @ts-expect-error — receipt is the EffectReceipt declaration, never a string
95
+ expect(() => effect("seeded", [])).toThrow(/no string form/);
96
+ });
97
+
98
+ test("effect steps do not nest", () => {
99
+ const inner = effect(seeded, []);
100
+ expect(() => effect(seeded, [inner as never])).toThrow(/do not nest/);
101
+ });
102
+ });
103
+
104
+ // ── Receipt store seam ────────────────────────────────────────────────────────
105
+
106
+ function memStore(initial?: Record<string, string>): {
107
+ store: ReceiptStore;
108
+ values: Map<string, string>;
109
+ writes: Array<{ name: string; expectation: string }>;
110
+ } {
111
+ const values = new Map(Object.entries(initial ?? {}));
112
+ const writes: Array<{ name: string; expectation: string }> = [];
113
+ const store: ReceiptStore = {
114
+ async read(r: EffectReceiptRef) {
115
+ return values.get(r.name);
116
+ },
117
+ async write(r: EffectReceiptRef, expectation: string) {
118
+ writes.push({ name: r.name, expectation });
119
+ values.set(r.name, expectation);
120
+ },
121
+ };
122
+ return { store, values, writes };
123
+ }
124
+
125
+ describe("receiptActivities (the injectable store seam)", () => {
126
+ const input = receiptCheckInput(seeded);
127
+ const expected = receiptExpectation(seeded);
128
+
129
+ test("receiptRead: absent receipt reads as current null, applied false", async () => {
130
+ const { store } = memStore();
131
+ const { receiptRead } = receiptActivities(store);
132
+ expect(await receiptRead(input)).toEqual({ current: null, expectation: expected, applied: false });
133
+ });
134
+
135
+ test("receiptRead: matching receipt reads as applied true", async () => {
136
+ const { store } = memStore({ seeded: expected });
137
+ const { receiptRead } = receiptActivities(store);
138
+ expect(await receiptRead(input)).toEqual({ current: expected, expectation: expected, applied: true });
139
+ });
140
+
141
+ test("receiptRead: refuses to invent an expectation for a reference-carrying hash receipt without a resolver", async () => {
142
+ const r = EffectReceipt("migrated", {
143
+ effect: "db-migrate",
144
+ flavor: "hash",
145
+ inputs: { endpoint: fakeIntrinsic("placeholder") },
146
+ });
147
+ const { store } = memStore();
148
+ const { receiptRead } = receiptActivities(store);
149
+ await expect(receiptRead(receiptCheckInput(r))).rejects.toThrow(/resolveExpectation/);
150
+ });
151
+
152
+ test("receiptWrite stores the expectation", async () => {
153
+ const { store, writes, values } = memStore();
154
+ const { receiptWrite } = receiptActivities(store);
155
+ await receiptWrite({ receipt: input.receipt, expectation: expected });
156
+ expect(writes).toEqual([{ name: "seeded", expectation: expected }]);
157
+ expect(values.get("seeded")).toBe(expected);
158
+ });
159
+
160
+ test("receiptStaleness reports absent and differing receipts as findings, and never writes", async () => {
161
+ const fresh = EffectReceipt("fresh", { effect: "e1", flavor: "hash", inputs: { v: 1 } });
162
+ const differs = EffectReceipt("differs", { effect: "e2", flavor: "hash", inputs: { v: 2 } });
163
+ const absent = EffectReceipt("absent", { effect: "e3", flavor: "existence" });
164
+ const { store, writes } = memStore({
165
+ fresh: receiptExpectation(fresh),
166
+ differs: "sha256:stale",
167
+ });
168
+ const { receiptStaleness } = receiptActivities(store);
169
+ const result = await receiptStaleness({
170
+ receipts: [fresh, differs, absent].map(receiptCheckInput),
171
+ });
172
+ expect(result.stale).toBe(true);
173
+ expect(result.findings).toEqual([
174
+ { receipt: "differs", effect: "e2", kind: "differs", expected: receiptExpectation(differs), current: "sha256:stale" },
175
+ { receipt: "absent", effect: "e3", kind: "absent", expected: EXISTENCE_EXPECTATION },
176
+ ]);
177
+ expect(writes).toEqual([]);
178
+ });
179
+
180
+ test("receiptStaleness with everything fresh reports stale false", async () => {
181
+ const { store } = memStore({ seeded: expected });
182
+ const { receiptStaleness } = receiptActivities(store);
183
+ expect(await receiptStaleness({ receipts: [input] })).toEqual({ stale: false, findings: [] });
184
+ });
185
+ });
186
+
187
+ // ── Local executor: read-compare-run-write ────────────────────────────────────
188
+
189
+ const PROFILES: Record<string, ActivityProfile> = {
190
+ fastIdempotent: { startToCloseTimeout: "5m", retry: { maximumAttempts: 1 } },
191
+ };
192
+
193
+ function activityMap(
194
+ store: ReceiptStore,
195
+ extra: Record<string, ActivityFn>,
196
+ ): Map<string, ActivityFn> {
197
+ const map = new Map<string, ActivityFn>();
198
+ for (const [name, fn] of Object.entries(receiptActivities(store))) {
199
+ map.set(name, fn as unknown as ActivityFn);
200
+ }
201
+ for (const [name, fn] of Object.entries(extra)) map.set(name, fn);
202
+ return map;
203
+ }
204
+
205
+ function seedOp(): OpConfig {
206
+ return {
207
+ name: "seed-op",
208
+ overview: "",
209
+ phases: [phase("Seed", [effect(seeded, [step("runSeed")])])],
210
+ };
211
+ }
212
+
213
+ describe("runOpLocally — effect steps", () => {
214
+ test("matching receipt skips the nested steps and writes nothing", async () => {
215
+ const { store, writes } = memStore({ seeded: receiptExpectation(seeded) });
216
+ const ran: string[] = [];
217
+ const activities = activityMap(store, { runSeed: async () => void ran.push("runSeed") });
218
+ const result = await runOpLocally(seedOp(), activities, PROFILES);
219
+ expect(result.ok).toBe(true);
220
+ expect(ran).toEqual([]);
221
+ expect(writes).toEqual([]);
222
+ // The read records the effect as already applied; the nested step is skipped.
223
+ expect(result.records.map((r) => [r.fn, r.status])).toEqual([
224
+ ["receiptRead", "ok"],
225
+ ["runSeed", "skipped"],
226
+ ]);
227
+ expect(result.records[0].outcome).toEqual({ name: "EffectApplied", value: true });
228
+ });
229
+
230
+ test("mismatch runs the nested steps, then writes the receipt once, last", async () => {
231
+ const { store, writes } = memStore();
232
+ const order: string[] = [];
233
+ const activities = activityMap(
234
+ { read: store.read, write: async (r, e) => { order.push("write"); await store.write(r, e); } },
235
+ { runSeed: async () => void order.push("runSeed") },
236
+ );
237
+ const result = await runOpLocally(seedOp(), activities, PROFILES);
238
+ expect(result.ok).toBe(true);
239
+ expect(order).toEqual(["runSeed", "write"]);
240
+ expect(writes).toEqual([{ name: "seeded", expectation: receiptExpectation(seeded) }]);
241
+ expect(result.records.map((r) => [r.fn, r.status])).toEqual([
242
+ ["receiptRead", "ok"],
243
+ ["runSeed", "ok"],
244
+ ["receiptWrite", "ok"],
245
+ ]);
246
+ });
247
+
248
+ test("a nested failure leaves the receipt untouched, and a rerun re-proposes the effect", async () => {
249
+ const { store, writes, values } = memStore();
250
+ let healthy = false;
251
+ const activities = activityMap(store, {
252
+ runSeed: async () => {
253
+ if (!healthy) throw new Error("seed blew up");
254
+ },
255
+ });
256
+
257
+ await expect(runOpLocally(seedOp(), activities, PROFILES)).rejects.toThrow(OpRunFailure);
258
+ expect(writes).toEqual([]);
259
+ expect(values.has("seeded")).toBe(false);
260
+
261
+ // The receipt is stale, so the next run runs the effect again — and only
262
+ // then writes.
263
+ healthy = true;
264
+ const rerun = await runOpLocally(seedOp(), activities, PROFILES);
265
+ expect(rerun.ok).toBe(true);
266
+ expect(writes).toEqual([{ name: "seeded", expectation: receiptExpectation(seeded) }]);
267
+ });
268
+
269
+ test("the failed run records the write as skipped, not run", async () => {
270
+ const { store } = memStore();
271
+ const activities = activityMap(store, {
272
+ runSeed: async () => {
273
+ throw new Error("boom");
274
+ },
275
+ });
276
+ const failure = await runOpLocally(seedOp(), activities, PROFILES).catch((e: OpRunFailure) => e);
277
+ expect(failure).toBeInstanceOf(OpRunFailure);
278
+ expect((failure as OpRunFailure).result.records.map((r) => [r.fn, r.status])).toEqual([
279
+ ["receiptRead", "ok"],
280
+ ["runSeed", "fail"],
281
+ ["receiptWrite", "skipped"],
282
+ ]);
283
+ });
284
+
285
+ test("a gate nested in an effect step is rejected up front, like any other gate", () => {
286
+ const config: OpConfig = {
287
+ name: "gated",
288
+ overview: "",
289
+ phases: [
290
+ phase("Seed", [effect(seeded, [{ kind: "gate", signalName: "approve-seed" }, step("runSeed")])]),
291
+ ],
292
+ };
293
+ expect(findGate(config)?.signalName).toBe("approve-seed");
294
+ const { store } = memStore();
295
+ return expect(
296
+ runOpLocally(config, activityMap(store, { runSeed: async () => {} }), PROFILES),
297
+ ).rejects.toThrow(LocalGateUnsupportedError);
298
+ });
299
+
300
+ test("an effect step in a parallel phase is refused — read-compare-run-write is ordered", async () => {
301
+ const config: OpConfig = {
302
+ name: "par",
303
+ overview: "",
304
+ phases: [phase("P", [effect(seeded, [step("runSeed")])], { parallel: true })],
305
+ };
306
+ const { store } = memStore();
307
+ await expect(
308
+ runOpLocally(config, activityMap(store, { runSeed: async () => {} }), PROFILES),
309
+ ).rejects.toThrow(/parallel phase/);
310
+ });
311
+ });
package/src/op/index.ts CHANGED
@@ -1,5 +1,6 @@
1
- export { Op, phase, activity, gate, build, kubectlApply, helmInstall, waitForStack, waitForReady,
2
- gitlabPipeline, lifecycleSnapshot, shell, teardown, k3dUp, k3dDown, flociUp, flociDown,
1
+ export { Op, phase, activity, gate, effect, build, kubectlApply, helmInstall, helmInstallPinned, waitForStack, waitForReady,
2
+ gitlabPipeline, lifecycleSnapshot, shell, ensureSecret, teardown, envTeardown, k3dUp, k3dDown,
3
+ k3sInstall, k3sUninstall, flociUp, flociDown,
3
4
  flociAzUp, flociAzDown, flociGcpUp, flociGcpDown, httpCheck,
4
5
  azGroupEnsure, azGroupDelete, azApply, azDelete, awsApply, awsDelete, gcpApply, gcpDelete, policyGate,
5
6
  spriteCreate, spriteExec, spriteCheckpoint, spriteRestore, listCheckpoints, spriteDestroy,
@@ -13,7 +14,13 @@ export { emulatorLifecycle, emulatorsOf, endpointEnvVars } from "./emulator-life
13
14
  export type { EmulatorSpec, EmulatorCapability, EmulatorDeclaration, EmulatorUpArgs, EmulatorLifecycle } from "./emulator-lifecycle";
14
15
  export { checkFreshness, compare, formatResult, latestRelease, parseVersion, unpinned } from "./emulator-freshness";
15
16
  export type { FreshnessResult } from "./emulator-freshness";
16
- export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "./types";
17
+ export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep, EffectStep } from "./types";
18
+ export { receiptActivities, receiptCheckInput } from "./receipt-store";
19
+ export type {
20
+ ReceiptStore, EffectReceiptRef, ReceiptCheckInput, ReceiptActivities, ReceiptActivityOptions,
21
+ ReceiptReadArgs, ReceiptReadResult, ReceiptWriteArgs,
22
+ ReceiptStalenessArgs, ReceiptStalenessResult, ReceiptStaleFinding,
23
+ } from "./receipt-store";
17
24
  export { discoverOps } from "./discover";
18
25
  export type { DiscoveredOp, OpDiscoveryResult } from "./discover";
19
26
  export { loadActivities, loadProfiles, resolveActivity } from "./activity-registry";
@@ -11,8 +11,9 @@
11
11
  * so core never statically depends on `@intentius/chant-lexicon-temporal`.
12
12
  */
13
13
 
14
- import type { OpConfig, PhaseDefinition, ActivityStep, GateStep, StepDefinition } from "./types";
14
+ import type { OpConfig, PhaseDefinition, ActivityStep, GateStep, EffectStep, StepDefinition } from "./types";
15
15
  import { resolveActivity, type ActivityFn, type ActivityProfile } from "./activity-registry";
16
+ import type { ReceiptReadResult } from "./receipt-store";
16
17
 
17
18
  // ── Records ─────────────────────────────────────────────────────────────────
18
19
 
@@ -69,6 +70,7 @@ const FALLBACK_TIMEOUT_MS = 5 * 60_000;
69
70
 
70
71
  const isActivity = (s: StepDefinition): s is ActivityStep => s.kind === "activity";
71
72
  const isGate = (s: StepDefinition): s is GateStep => s.kind === "gate";
73
+ const isEffect = (s: StepDefinition): s is EffectStep => s.kind === "effect";
72
74
 
73
75
  /** Parse a Temporal duration string ("5m", "30s", "1h30m", "100ms") to ms. */
74
76
  export function parseDuration(s: string): number {
@@ -92,12 +94,18 @@ function resolvePath(value: unknown, path?: string): unknown {
92
94
  );
93
95
  }
94
96
 
95
- /** Find the first gate step anywhere in the Op (phases + onFailure), if any. */
97
+ /** Find the first gate step anywhere in the Op (phases + onFailure, including
98
+ * gates nested inside effect steps), if any. */
96
99
  export function findGate(config: OpConfig): GateStep | undefined {
97
100
  const all = [...config.phases, ...(config.onFailure ?? [])];
98
101
  for (const phase of all) {
99
- const gate = phase.steps.find(isGate);
100
- if (gate) return gate;
102
+ for (const step of phase.steps) {
103
+ if (isGate(step)) return step;
104
+ if (isEffect(step)) {
105
+ const nested = step.steps.find(isGate);
106
+ if (nested) return nested;
107
+ }
108
+ }
101
109
  }
102
110
  return undefined;
103
111
  }
@@ -145,6 +153,13 @@ async function callWithTimeout(
145
153
 
146
154
  // ── Step + phase execution ────────────────────────────────────────────────��─
147
155
 
156
+ /** A finished step: its record plus (on success) the activity's return value —
157
+ * the effect-step path needs `receiptRead`'s result, not just its status. */
158
+ interface RanStep {
159
+ record: StepRecord;
160
+ result?: unknown;
161
+ }
162
+
148
163
  /** Run one activity step with retry + timeout. Never throws — returns a record. */
149
164
  async function runStep(
150
165
  step: ActivityStep,
@@ -152,7 +167,7 @@ async function runStep(
152
167
  activities: Map<string, ActivityFn>,
153
168
  profiles: Record<string, ActivityProfile>,
154
169
  signal?: AbortSignal,
155
- ): Promise<StepRecord> {
170
+ ): Promise<RanStep> {
156
171
  const args = step.args ?? {};
157
172
  const base = { phase: phaseName, fn: step.fn, args };
158
173
  const start = Date.now();
@@ -161,7 +176,7 @@ async function runStep(
161
176
  try {
162
177
  fn = resolveActivity(activities, step.fn);
163
178
  } catch (err) {
164
- return { ...base, status: "fail", durationMs: 0, error: errMessage(err) };
179
+ return { record: { ...base, status: "fail", durationMs: 0, error: errMessage(err) } };
165
180
  }
166
181
 
167
182
  const profile = profiles[step.profile ?? DEFAULT_PROFILE] ?? {};
@@ -190,7 +205,7 @@ async function runStep(
190
205
  value: resolvePath(result, step.outcomeAttribute.from),
191
206
  };
192
207
  }
193
- return record;
208
+ return { record, result };
194
209
  } catch (err) {
195
210
  lastErr = err;
196
211
  // Stop retrying on abort (Ctrl-C / timeout cascade) or a non-retryable error.
@@ -204,7 +219,105 @@ async function runStep(
204
219
  break;
205
220
  }
206
221
  }
207
- return { ...base, status: "fail", durationMs: Date.now() - start, error: errMessage(lastErr) };
222
+ return { record: { ...base, status: "fail", durationMs: Date.now() - start, error: errMessage(lastErr) } };
223
+ }
224
+
225
+ // ── Effect steps (#1834) ──────────────────────────────────────────────────────
226
+
227
+ /** The step data the executor synthesizes to read a receipt through the store
228
+ * activities (`receiptRead`/`receiptWrite` — provided by the receipt row's
229
+ * lexicon, #1835, or a mock store in tests via `receiptActivities`). */
230
+ function receiptReadStep(step: EffectStep): ActivityStep {
231
+ return {
232
+ kind: "activity",
233
+ fn: "receiptRead",
234
+ args: {
235
+ receipt: step.receipt,
236
+ ...(step.expectation !== undefined ? { expectation: step.expectation } : {}),
237
+ },
238
+ profile: "fastIdempotent",
239
+ outcomeAttribute: { name: "EffectApplied", from: "applied" },
240
+ };
241
+ }
242
+
243
+ /** A skipped-record for a step that will not run. */
244
+ function skippedRecord(phaseName: string, fn: string, args?: Record<string, unknown>): StepRecord {
245
+ return { phase: phaseName, fn, args: args ?? {}, status: "skipped", durationMs: 0 };
246
+ }
247
+
248
+ /**
249
+ * Run one effect step: read-compare-run-write. On a match the nested steps are
250
+ * recorded as skipped ("effect already applied") and nothing is written. On a
251
+ * mismatch the nested steps run in authored order; only when every one
252
+ * succeeds is the receipt written — last, once (the sole writer, #1703
253
+ * decision 3). Any failure leaves the receipt untouched (stale), so the next
254
+ * run re-proposes the effect.
255
+ */
256
+ async function runEffectStep(
257
+ step: EffectStep,
258
+ phaseName: string,
259
+ activities: Map<string, ActivityFn>,
260
+ profiles: Record<string, ActivityProfile>,
261
+ signal?: AbortSignal,
262
+ ): Promise<{ records: StepRecord[]; failed: boolean }> {
263
+ const records: StepRecord[] = [];
264
+
265
+ const read = await runStep(receiptReadStep(step), phaseName, activities, profiles, signal);
266
+ records.push(read.record);
267
+ if (read.record.status === "fail") return { records, failed: true };
268
+
269
+ const result = read.result as Partial<ReceiptReadResult> | undefined;
270
+ if (typeof result?.expectation !== "string") {
271
+ records.push({
272
+ phase: phaseName,
273
+ fn: `effect:${step.receipt.name}`,
274
+ args: {},
275
+ status: "fail",
276
+ durationMs: 0,
277
+ error: "receiptRead returned no expectation — the receipt store activity must return { current, expectation }",
278
+ });
279
+ return { records, failed: true };
280
+ }
281
+ const expectation = result.expectation;
282
+
283
+ if (result.current === expectation) {
284
+ // Effect already applied — skip the nested steps, write nothing.
285
+ for (const nested of step.steps) {
286
+ if (nested.kind === "activity") records.push(skippedRecord(phaseName, nested.fn, nested.args));
287
+ }
288
+ return { records, failed: false };
289
+ }
290
+
291
+ // Gates are pre-flighted by findGate; only activities remain here.
292
+ const nestedActivities = step.steps.filter(isActivity);
293
+ for (let i = 0; i < nestedActivities.length; i++) {
294
+ const ran = await runStep(nestedActivities[i], phaseName, activities, profiles, signal);
295
+ records.push(ran.record);
296
+ if (ran.record.status === "fail") {
297
+ // Receipt left untouched (stale) — the next run re-proposes the effect.
298
+ for (const skipped of nestedActivities.slice(i + 1)) {
299
+ records.push(skippedRecord(phaseName, skipped.fn, skipped.args));
300
+ }
301
+ records.push(skippedRecord(phaseName, "receiptWrite"));
302
+ return { records, failed: true };
303
+ }
304
+ }
305
+
306
+ // Sole writer of the receipt: on success of every nested step, last.
307
+ const wrote = await runStep(
308
+ {
309
+ kind: "activity",
310
+ fn: "receiptWrite",
311
+ args: { receipt: step.receipt, expectation },
312
+ profile: "fastIdempotent",
313
+ },
314
+ phaseName,
315
+ activities,
316
+ profiles,
317
+ signal,
318
+ );
319
+ records.push(wrote.record);
320
+ return { records, failed: wrote.record.status === "fail" };
208
321
  }
209
322
 
210
323
  /** Run a phase. Throws PhaseFailure (with records so far) if any step fails. */
@@ -214,35 +327,57 @@ async function runPhase(
214
327
  profiles: Record<string, ActivityProfile>,
215
328
  signal?: AbortSignal,
216
329
  ): Promise<StepRecord[]> {
217
- // Defensive: gates are pre-flighted, but never execute one if it slips through.
218
- const gate = phase.steps.find(isGate);
330
+ // Defensive: gates are pre-flighted, but never execute one if it slips
331
+ // through — including a gate nested inside an effect step.
332
+ const gate =
333
+ phase.steps.find(isGate) ??
334
+ phase.steps.filter(isEffect).flatMap((e) => e.steps).find(isGate);
219
335
  if (gate) throw new LocalGateUnsupportedError(gate.signalName);
220
336
 
221
- const steps = phase.steps.filter(isActivity);
222
-
223
337
  if (phase.parallel) {
224
- const records = await Promise.all(
225
- steps.map((s) => runStep(s, phase.name, activities, profiles, signal)),
226
- );
338
+ const eff = phase.steps.find(isEffect);
339
+ if (eff) {
340
+ throw new Error(
341
+ `effect step "${eff.receipt.name}" cannot run in a parallel phase — read-compare-run-write is ordered`,
342
+ );
343
+ }
344
+ const steps = phase.steps.filter(isActivity);
345
+ const records = (
346
+ await Promise.all(steps.map((s) => runStep(s, phase.name, activities, profiles, signal)))
347
+ ).map((r) => r.record);
227
348
  if (records.some((r) => r.status === "fail")) throw new PhaseFailure(records);
228
349
  return records;
229
350
  }
230
351
 
352
+ const steps = phase.steps.filter((s): s is ActivityStep | EffectStep => !isGate(s));
231
353
  const records: StepRecord[] = [];
354
+
355
+ const skipRemaining = (from: number) => {
356
+ for (const skipped of steps.slice(from)) {
357
+ if (isEffect(skipped)) {
358
+ records.push(skippedRecord(phase.name, `effect:${skipped.receipt.name}`));
359
+ } else {
360
+ records.push(skippedRecord(phase.name, skipped.fn, skipped.args));
361
+ }
362
+ }
363
+ };
364
+
232
365
  for (let i = 0; i < steps.length; i++) {
233
- const record = await runStep(steps[i], phase.name, activities, profiles, signal);
366
+ const step = steps[i];
367
+ if (isEffect(step)) {
368
+ const { records: effRecords, failed } = await runEffectStep(step, phase.name, activities, profiles, signal);
369
+ records.push(...effRecords);
370
+ if (failed) {
371
+ skipRemaining(i + 1);
372
+ throw new PhaseFailure(records);
373
+ }
374
+ continue;
375
+ }
376
+ const { record } = await runStep(step, phase.name, activities, profiles, signal);
234
377
  records.push(record);
235
378
  if (record.status === "fail") {
236
379
  // Mark the remaining steps in this phase as skipped, then abort.
237
- for (const skipped of steps.slice(i + 1)) {
238
- records.push({
239
- phase: phase.name,
240
- fn: skipped.fn,
241
- args: skipped.args ?? {},
242
- status: "skipped",
243
- durationMs: 0,
244
- });
245
- }
380
+ skipRemaining(i + 1);
246
381
  throw new PhaseFailure(records);
247
382
  }
248
383
  }
@@ -266,6 +401,18 @@ export async function runOpLocally(
266
401
  const gate = findGate(config);
267
402
  if (gate) throw new LocalGateUnsupportedError(gate.signalName);
268
403
 
404
+ // Effect steps are ordered (read-compare-run-write): refuse them in a
405
+ // parallel phase up front, with the phase named, rather than mid-run.
406
+ for (const phase of [...config.phases, ...(config.onFailure ?? [])]) {
407
+ const eff = phase.parallel ? phase.steps.find(isEffect) : undefined;
408
+ if (eff) {
409
+ throw new Error(
410
+ `phase "${phase.name}": effect step "${eff.receipt.name}" cannot run in a ` +
411
+ `parallel phase — read-compare-run-write is ordered`,
412
+ );
413
+ }
414
+ }
415
+
269
416
  const records: StepRecord[] = [];
270
417
  const start = Date.now();
271
418
 
package/src/op/op.test.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, it } from "vitest";
6
- import { Op, phase, activity, gate, build, kubectlApply, helmInstall,
7
- waitForStack, gitlabPipeline, lifecycleSnapshot, shell, teardown, policyGate } from "./builders";
6
+ import { Op, phase, activity, gate, build, kubectlApply, helmInstall, helmInstallPinned,
7
+ waitForStack, gitlabPipeline, lifecycleSnapshot, shell, ensureSecret, teardown, policyGate } from "./builders";
8
8
  import { DECLARABLE_MARKER, type Declarable } from "../declarable";
9
9
 
10
10
  // ── Op() ──────────────────────────────────────────────────────────────────────
@@ -163,6 +163,16 @@ describe("pre-built shortcuts", () => {
163
163
  expect(a.profile).toBe("longInfra");
164
164
  });
165
165
 
166
+ it("helmInstallPinned() produces a helmInstall activity keyed by contentDigest, no chart", () => {
167
+ const a = helmInstallPinned("my-release", "sha256:abc", { namespace: "web" });
168
+ expect(a.fn).toBe("helmInstall");
169
+ expect(a.args?.name).toBe("my-release");
170
+ expect(a.args?.contentDigest).toBe("sha256:abc");
171
+ expect(a.args?.namespace).toBe("web");
172
+ expect(a.args?.chart).toBeUndefined();
173
+ expect(a.profile).toBe("longInfra");
174
+ });
175
+
166
176
  it("waitForStack() produces waitForStack activity with k8sWait profile", () => {
167
177
  const a = waitForStack("my-stack");
168
178
  expect(a.fn).toBe("waitForStack");
@@ -190,6 +200,19 @@ describe("pre-built shortcuts", () => {
190
200
  expect(a.args?.cmd).toBe("echo hello");
191
201
  });
192
202
 
203
+ it("ensureSecret() produces ensureSecret activity with name/keys args and no material-shaped field", () => {
204
+ const a = ensureSecret("master-key", ["MASTER_SECRETS_KEY"], {
205
+ metadata: { "chant.dev/provenance": "generated-once" },
206
+ });
207
+ expect(a.fn).toBe("ensureSecret");
208
+ expect(a.args?.name).toBe("master-key");
209
+ expect(a.args?.keys).toEqual(["MASTER_SECRETS_KEY"]);
210
+ expect(a.args?.metadata).toEqual({ "chant.dev/provenance": "generated-once" });
211
+ // The step is contract-only: nothing on it could carry secret material.
212
+ expect(a.args && "value" in a.args).toBe(false);
213
+ expect(a.args && "data" in a.args).toBe(false);
214
+ });
215
+
193
216
  it("teardown() produces chantTeardown activity with longInfra profile", () => {
194
217
  const a = teardown("./project");
195
218
  expect(a.fn).toBe("chantTeardown");