@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,211 @@
1
+ /**
2
+ * Receipt store seam (#1834, epic #1703) — how the `effect()` step and the
3
+ * receipt-reading Ops reach a materialized receipt without knowing which
4
+ * lexicon materializes it.
5
+ *
6
+ * The store is injectable: core defines the {@link ReceiptStore} interface and
7
+ * the activity contracts; the receipt row's lexicon implements the store over
8
+ * its own resource (#1835's aws row over `AWS::SSM::Parameter`) and exports
9
+ * the bound activities (`receiptRead`, `receiptWrite`, `receiptStaleness`)
10
+ * from its `op/activities` module via {@link receiptActivities}. The activity
11
+ * registry then resolves them by name, exactly like `ensureSecret`
12
+ * (#1829/#1830, provided by the k8s lexicon). Tests bind a mock store the
13
+ * same way.
14
+ *
15
+ * Write discipline (epic #1703 decision log, item 3): the `effect()` step is
16
+ * the SOLE writer of a receipt — on success of its nested steps, last. The
17
+ * store interface carries a `write`, but only the effect step's emitted
18
+ * read-compare-run-write (and its local-executor twin) reaches it.
19
+ * `receiptStaleness` — the WatchOp phase (#1834) — is read-only by
20
+ * construction: it never touches `write` and runs nothing.
21
+ *
22
+ * Expectation resolution follows the resolution split (decision 5): a fully
23
+ * static receipt's expectation is stamped at synthesis and rides the step
24
+ * data ({@link receiptCheckInput}); a hash-flavor receipt with reference
25
+ * inputs resolves at run, through the store lexicon's
26
+ * {@link ReceiptActivityOptions.resolveExpectation} hook — never by hashing
27
+ * placeholders.
28
+ */
29
+
30
+ import {
31
+ EXISTENCE_EXPECTATION,
32
+ isEffectReceipt,
33
+ receiptExpectation,
34
+ referenceInputPaths,
35
+ type EffectReceiptDeclaration,
36
+ type EffectReceiptFlavor,
37
+ } from "../effect-receipt";
38
+
39
+ /**
40
+ * The serializable identity + declaration data of an effect receipt — what an
41
+ * `effect()` step and a `receiptStaleness` check carry through codegen. Built
42
+ * from the typed {@link EffectReceiptDeclaration} only ({@link receiptCheckInput});
43
+ * there is no string form. Reference inputs stay in placeholder form.
44
+ */
45
+ export interface EffectReceiptRef {
46
+ /** The receipt's own name (the export-level identity of the witness). */
47
+ name: string;
48
+ /** The effect this receipt witnesses. */
49
+ effect: string;
50
+ /** How the receipt is compared: mere presence, or a digest of the inputs. */
51
+ flavor: EffectReceiptFlavor;
52
+ /** The effect's inputs as recorded at synthesis (references as placeholders). */
53
+ inputs: Record<string, unknown>;
54
+ }
55
+
56
+ /**
57
+ * The injectable seam over the materialized receipt (#1835 implements it for
58
+ * SSM; tests implement it with a map). `read` returns the receipt's current
59
+ * stored value, or `undefined` when the receipt is absent. `write` stores the
60
+ * expectation as the new value — called only by the effect step, on success,
61
+ * last.
62
+ */
63
+ export interface ReceiptStore {
64
+ read(receipt: EffectReceiptRef): Promise<string | undefined>;
65
+ write(receipt: EffectReceiptRef, expectation: string): Promise<void>;
66
+ }
67
+
68
+ /** One receipt to check: its identity plus, when the receipt is fully static,
69
+ * the expectation stamped at synthesis. */
70
+ export interface ReceiptCheckInput {
71
+ receipt: EffectReceiptRef;
72
+ /** Present when the expectation was computable at synthesis; absent when
73
+ * reference inputs resolve at run (decision 5). */
74
+ expectation?: string;
75
+ }
76
+
77
+ /**
78
+ * Snapshot a typed receipt declaration into check-input data: the
79
+ * {@link EffectReceiptRef} plus the synthesis-time expectation when the
80
+ * receipt is fully static. Used by the `effect()` builder and by WatchOp's
81
+ * staleness phase — the single place "static enough to hash now" is decided.
82
+ */
83
+ export function receiptCheckInput(receipt: EffectReceiptDeclaration): ReceiptCheckInput {
84
+ if (!isEffectReceipt(receipt)) {
85
+ throw new Error(
86
+ "receiptCheckInput: expected an EffectReceipt declaration — import the exported const; there is no string form",
87
+ );
88
+ }
89
+ const ref: EffectReceiptRef = {
90
+ name: receipt.name,
91
+ effect: receipt.effect,
92
+ flavor: receipt.flavor,
93
+ inputs: receipt.inputs as Record<string, unknown>,
94
+ };
95
+ // An existence receipt's expectation is a constant, references or not; a
96
+ // hash receipt is static only when no reference inputs remain.
97
+ const isStatic = receipt.flavor === "existence" || referenceInputPaths(receipt).length === 0;
98
+ return isStatic ? { receipt: ref, expectation: receiptExpectation(receipt) } : { receipt: ref };
99
+ }
100
+
101
+ // ── Activity contracts ────────────────────────────────────────────────────────
102
+
103
+ /** Args of the `receiptRead` activity, as the effect step's codegen emits them. */
104
+ export interface ReceiptReadArgs extends ReceiptCheckInput {}
105
+
106
+ /** Result of the `receiptRead` activity. `current` is `null` (not `undefined`)
107
+ * for an absent receipt so the value survives JSON transport. */
108
+ export interface ReceiptReadResult {
109
+ /** The receipt's live stored value, or null when absent. */
110
+ current: string | null;
111
+ /** The resolved expectation the workflow compares and later writes. */
112
+ expectation: string;
113
+ /** Convenience: `current === expectation`. */
114
+ applied: boolean;
115
+ }
116
+
117
+ /** Args of the `receiptWrite` activity — the receipt and the resolved
118
+ * expectation returned by the preceding `receiptRead`. */
119
+ export interface ReceiptWriteArgs {
120
+ receipt: EffectReceiptRef;
121
+ expectation: string;
122
+ }
123
+
124
+ /** Args of the read-only `receiptStaleness` activity (WatchOp). */
125
+ export interface ReceiptStalenessArgs {
126
+ receipts: ReceiptCheckInput[];
127
+ }
128
+
129
+ /** One stale receipt, reported as a finding — never acted on. */
130
+ export interface ReceiptStaleFinding {
131
+ /** The receipt's name. */
132
+ receipt: string;
133
+ /** The effect the receipt witnesses. */
134
+ effect: string;
135
+ /** `absent` — no receipt stored; `differs` — stored value is not the expectation. */
136
+ kind: "absent" | "differs";
137
+ /** The resolved expectation. */
138
+ expected: string;
139
+ /** The live stored value (present only for `differs`). */
140
+ current?: string;
141
+ }
142
+
143
+ /** Result of the `receiptStaleness` activity. */
144
+ export interface ReceiptStalenessResult {
145
+ stale: boolean;
146
+ findings: ReceiptStaleFinding[];
147
+ }
148
+
149
+ /** Options for {@link receiptActivities}. */
150
+ export interface ReceiptActivityOptions {
151
+ /**
152
+ * Resolve a reference-carrying receipt's expectation at run (decision 5) —
153
+ * the store lexicon's hook to deploy-time values, typically wrapping core's
154
+ * `resolveReceiptExpectation`. Without it, a check input that carries no
155
+ * synthesis-time expectation fails loudly rather than hashing placeholders.
156
+ */
157
+ resolveExpectation?: (receipt: EffectReceiptRef) => Promise<string> | string;
158
+ }
159
+
160
+ /** The three receipt activities, bound to one store. */
161
+ export interface ReceiptActivities {
162
+ receiptRead: (args: ReceiptReadArgs, signal?: AbortSignal) => Promise<ReceiptReadResult>;
163
+ receiptWrite: (args: ReceiptWriteArgs, signal?: AbortSignal) => Promise<{ written: true; receipt: string }>;
164
+ receiptStaleness: (args: ReceiptStalenessArgs, signal?: AbortSignal) => Promise<ReceiptStalenessResult>;
165
+ }
166
+
167
+ /**
168
+ * Bind the receipt activities to a store. The store's lexicon calls this once
169
+ * and re-exports the result from its `op/activities` module; the activity
170
+ * registry picks the functions up by name for both executors.
171
+ */
172
+ export function receiptActivities(store: ReceiptStore, opts?: ReceiptActivityOptions): ReceiptActivities {
173
+ const expectationOf = async (input: ReceiptCheckInput): Promise<string> => {
174
+ if (input.expectation !== undefined) return input.expectation;
175
+ if (input.receipt.flavor === "existence") return EXISTENCE_EXPECTATION;
176
+ if (opts?.resolveExpectation) return await opts.resolveExpectation(input.receipt);
177
+ throw new Error(
178
+ `receipt "${input.receipt.name}": no synthesis-time expectation and no resolveExpectation hook — ` +
179
+ `a hash-flavor receipt with reference inputs resolves at run (#1703 decision 5), ` +
180
+ `and hashing placeholders would be a wrong expectation`,
181
+ );
182
+ };
183
+
184
+ return {
185
+ async receiptRead(args: ReceiptReadArgs): Promise<ReceiptReadResult> {
186
+ const expectation = await expectationOf(args);
187
+ const current = await store.read(args.receipt);
188
+ return { current: current ?? null, expectation, applied: current === expectation };
189
+ },
190
+
191
+ async receiptWrite(args: ReceiptWriteArgs): Promise<{ written: true; receipt: string }> {
192
+ await store.write(args.receipt, args.expectation);
193
+ return { written: true, receipt: args.receipt.name };
194
+ },
195
+
196
+ // Read-only: reads and reports, runs nothing, never writes.
197
+ async receiptStaleness(args: ReceiptStalenessArgs): Promise<ReceiptStalenessResult> {
198
+ const findings: ReceiptStaleFinding[] = [];
199
+ for (const input of args.receipts) {
200
+ const expected = await expectationOf(input);
201
+ const current = await store.read(input.receipt);
202
+ if (current === undefined) {
203
+ findings.push({ receipt: input.receipt.name, effect: input.receipt.effect, kind: "absent", expected });
204
+ } else if (current !== expected) {
205
+ findings.push({ receipt: input.receipt.name, effect: input.receipt.effect, kind: "differs", expected, current });
206
+ }
207
+ }
208
+ return { stale: findings.length > 0, findings };
209
+ },
210
+ };
211
+ }
package/src/op/types.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  * in core without pulling in @temporalio/* as a dependency.
6
6
  */
7
7
 
8
+ import type { EffectReceiptRef } from "./receipt-store";
9
+
8
10
  export interface OpConfig {
9
11
  /** Kebab-case identifier. Used as the workflow function name (camelCase) and output directory name. */
10
12
  name: string;
@@ -33,7 +35,7 @@ export interface PhaseDefinition {
33
35
  parallel?: boolean;
34
36
  }
35
37
 
36
- export type StepDefinition = ActivityStep | GateStep;
38
+ export type StepDefinition = ActivityStep | GateStep | EffectStep;
37
39
 
38
40
  export interface ActivityStep {
39
41
  kind: "activity";
@@ -61,6 +63,36 @@ export interface ActivityStep {
61
63
  outcomeAttribute?: { name: string; from?: string };
62
64
  }
63
65
 
66
+ /**
67
+ * Read-compare-run-write over an effect receipt (#1834, epic #1703). The
68
+ * runtime reads the live receipt through the receipt store, compares it
69
+ * against the resolved expectation, skips the nested steps on a match, and
70
+ * otherwise runs them — writing the receipt only when every nested step
71
+ * succeeded, last. A nested-step failure leaves the receipt untouched
72
+ * (stale), so the next run re-proposes the effect.
73
+ *
74
+ * Authored via the `effect()` builder, which takes the typed EffectReceipt
75
+ * declaration only — there is no string form.
76
+ */
77
+ export interface EffectStep {
78
+ kind: "effect";
79
+ /** Receipt identity + declaration data (references in placeholder form). */
80
+ receipt: EffectReceiptRef;
81
+ /**
82
+ * The expectation stamped at synthesis when the receipt is fully static;
83
+ * absent when reference inputs resolve at run (#1703 decision 5).
84
+ */
85
+ expectation?: string;
86
+ /**
87
+ * Steps run when the live receipt does not match, in authored order. A gate
88
+ * authored here pauses only when the effect will fire. Effect steps do not
89
+ * nest.
90
+ */
91
+ steps: Array<ActivityStep | GateStep>;
92
+ /** Annotation carried into the generated workflow as a comment. */
93
+ description?: string;
94
+ }
95
+
64
96
  export interface GateStep {
65
97
  kind: "gate";
66
98
  /** Signal name. The generated workflow waits for this signal before continuing. */
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Generated-once materialization engine tests (#1829, epic #1365 decisions 3
3
+ * and 6): read-then-write, present means done, mismatch fails loudly naming
4
+ * key names and metadata keys — and no code path returns, logs, or retains
5
+ * the generated material.
6
+ */
7
+
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import { inspect } from "node:util";
10
+ import {
11
+ SecretMaterial,
12
+ SecretContractMismatchError,
13
+ consumeSecretMaterial,
14
+ defaultSecretMaterialGenerator,
15
+ ensureSecretMaterialization,
16
+ type SecretMaterialGenerator,
17
+ type SecretStoreAdapter,
18
+ type SecretStoreDescription,
19
+ } from "./secret-materialization";
20
+
21
+ /** A recognizable plaintext no output may ever contain. */
22
+ const CANARY = "canary-s3kr3t-material";
23
+
24
+ /** Generator minting a per-key canary value, so leak assertions can grep for it. */
25
+ const canaryGenerator: SecretMaterialGenerator = (key) => SecretMaterial.mint(`${CANARY}:${key}`);
26
+
27
+ interface StoredSecret {
28
+ data: Record<string, string>;
29
+ metadata?: Record<string, string>;
30
+ }
31
+
32
+ /**
33
+ * In-memory fake store. `create` consumes each key's material exactly the way
34
+ * a real adapter writes it, so the tests can check the stored bytes without
35
+ * any material ever traveling back through the engine.
36
+ */
37
+ function fakeStore(seed?: Record<string, StoredSecret>) {
38
+ const secrets = new Map<string, StoredSecret>(Object.entries(seed ?? {}));
39
+ const adapter: SecretStoreAdapter = {
40
+ exists: vi.fn(async (name: string) => secrets.has(name)),
41
+ describe: vi.fn(async (name: string): Promise<SecretStoreDescription> => {
42
+ const secret = secrets.get(name);
43
+ if (!secret) throw new Error(`fake store: no secret "${name}"`);
44
+ return { keys: Object.keys(secret.data), metadata: secret.metadata };
45
+ }),
46
+ create: vi.fn(async (name: string, keys: readonly string[], generate: SecretMaterialGenerator) => {
47
+ const data: Record<string, string> = {};
48
+ for (const key of keys) {
49
+ data[key] = consumeSecretMaterial(await generate(key));
50
+ }
51
+ secrets.set(name, { data });
52
+ }),
53
+ };
54
+ return { adapter, secrets };
55
+ }
56
+
57
+ afterEach(() => {
58
+ vi.restoreAllMocks();
59
+ });
60
+
61
+ describe("ensureSecretMaterialization", () => {
62
+ it("absent: mints once through the adapter and reports created", async () => {
63
+ const { adapter, secrets } = fakeStore();
64
+ const outcome = await ensureSecretMaterialization(
65
+ adapter,
66
+ { name: "master-key", keys: ["MASTER_SECRETS_KEY"] },
67
+ canaryGenerator,
68
+ );
69
+ expect(outcome).toEqual({ outcome: "created", name: "master-key", keys: ["MASTER_SECRETS_KEY"] });
70
+ expect(adapter.create).toHaveBeenCalledTimes(1);
71
+ expect(secrets.get("master-key")?.data).toEqual({
72
+ MASTER_SECRETS_KEY: `${CANARY}:MASTER_SECRETS_KEY`,
73
+ });
74
+ });
75
+
76
+ it("present and matching: stops without any write — a rerun is byte-identical and create never fires twice", async () => {
77
+ const { adapter, secrets } = fakeStore();
78
+ const spec = { name: "master-key", keys: ["a", "b"] };
79
+
80
+ await ensureSecretMaterialization(adapter, spec, canaryGenerator);
81
+ const afterFirstRun = JSON.stringify(secrets.get("master-key"));
82
+
83
+ // Second run of the whole ensure — the fountain-ops e2e shape.
84
+ const outcome = await ensureSecretMaterialization(adapter, spec, canaryGenerator);
85
+ expect(outcome.outcome).toBe("present");
86
+ expect(adapter.create).toHaveBeenCalledTimes(1);
87
+ expect(JSON.stringify(secrets.get("master-key"))).toBe(afterFirstRun);
88
+ });
89
+
90
+ it("present with a missing declared key: fails loudly naming the key, never minting over the existing value", async () => {
91
+ const { adapter, secrets } = fakeStore({
92
+ "master-key": { data: { a: "pre-existing-value" } },
93
+ });
94
+ await expect(
95
+ ensureSecretMaterialization(adapter, { name: "master-key", keys: ["a", "b"] }, canaryGenerator),
96
+ ).rejects.toThrowError(SecretContractMismatchError);
97
+ await expect(
98
+ ensureSecretMaterialization(adapter, { name: "master-key", keys: ["a", "b"] }, canaryGenerator),
99
+ ).rejects.toThrowError(/missing declared key\(s\): b/);
100
+ expect(adapter.create).not.toHaveBeenCalled();
101
+ expect(secrets.get("master-key")?.data).toEqual({ a: "pre-existing-value" });
102
+ });
103
+
104
+ it("present with an undeclared extra key: fails naming the extra key", async () => {
105
+ const { adapter } = fakeStore({
106
+ "master-key": { data: { a: "x", rogue: "y" } },
107
+ });
108
+ await expect(
109
+ ensureSecretMaterialization(adapter, { name: "master-key", keys: ["a"] }, canaryGenerator),
110
+ ).rejects.toThrowError(/undeclared key\(s\) present: rogue/);
111
+ });
112
+
113
+ it("declared metadata mismatch: fails naming the metadata KEY only, never its values", async () => {
114
+ const { adapter } = fakeStore({
115
+ "master-key": {
116
+ data: { a: "x" },
117
+ metadata: { "chant.dev/provenance": "hand-rolled" },
118
+ },
119
+ });
120
+ let thrown: unknown;
121
+ try {
122
+ await ensureSecretMaterialization(
123
+ adapter,
124
+ {
125
+ name: "master-key",
126
+ keys: ["a"],
127
+ metadata: { "chant.dev/provenance": "generated-once", "chant.dev/stack": "fountain" },
128
+ },
129
+ canaryGenerator,
130
+ );
131
+ } catch (e) {
132
+ thrown = e;
133
+ }
134
+ expect(thrown).toBeInstanceOf(SecretContractMismatchError);
135
+ const message = (thrown as Error).message;
136
+ expect(message).toContain("metadata key differs: chant.dev/provenance");
137
+ expect(message).toContain("missing declared metadata key: chant.dev/stack");
138
+ // The KEY is named; neither the declared nor the stored VALUE appears.
139
+ expect(message).not.toContain("hand-rolled");
140
+ expect(message).not.toContain("generated-once");
141
+ });
142
+
143
+ it("rejects an empty key-set — there is nothing to materialize", async () => {
144
+ const { adapter } = fakeStore();
145
+ await expect(
146
+ ensureSecretMaterialization(adapter, { name: "master-key", keys: [] }, canaryGenerator),
147
+ ).rejects.toThrowError(/at least one key/);
148
+ expect(adapter.exists).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it("never emits the generated material on any log or error surface", async () => {
152
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
153
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
154
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
155
+ const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
156
+ const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
157
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
158
+
159
+ const { adapter } = fakeStore({ mismatched: { data: { wrong: "stored-value" } } });
160
+ await ensureSecretMaterialization(adapter, { name: "fresh", keys: ["k1", "k2"] }, canaryGenerator);
161
+ const failure = await ensureSecretMaterialization(
162
+ adapter,
163
+ { name: "mismatched", keys: ["right"] },
164
+ canaryGenerator,
165
+ ).catch((e: Error) => e);
166
+
167
+ const captured = [logSpy, errorSpy, warnSpy, infoSpy, stdoutSpy, stderrSpy]
168
+ .flatMap((spy) => spy.mock.calls)
169
+ .map((call) => call.map((arg) => inspect(arg)).join(" "))
170
+ .join("\n");
171
+ expect(captured).not.toContain(CANARY);
172
+ expect((failure as Error).message).not.toContain(CANARY);
173
+ expect((failure as Error).message).not.toContain("stored-value");
174
+ });
175
+ });
176
+
177
+ describe("SecretMaterial", () => {
178
+ it("holds the plaintext out of reach: enumeration, JSON, string coercion, and inspect all redact", () => {
179
+ const material = SecretMaterial.mint(CANARY);
180
+ expect(Object.keys(material)).toEqual([]);
181
+ expect(JSON.stringify(material)).toBe('"[secret material]"');
182
+ expect(String(material)).toBe("[secret material]");
183
+ expect(inspect(material)).toBe("[secret material]");
184
+ expect(inspect({ nested: material })).not.toContain(CANARY);
185
+ });
186
+
187
+ it("is consumable exactly once — the adapter takes it, nothing re-reads it", () => {
188
+ const material = SecretMaterial.mint(CANARY);
189
+ expect(consumeSecretMaterial(material)).toBe(CANARY);
190
+ expect(() => consumeSecretMaterial(material)).toThrowError(/already consumed/);
191
+ });
192
+
193
+ it("default generator mints 32 CSPRNG bytes as base64url", () => {
194
+ const material = defaultSecretMaterialGenerator("any-key");
195
+ expect(material).toBeInstanceOf(SecretMaterial);
196
+ const plaintext = consumeSecretMaterial(material as SecretMaterial);
197
+ expect(plaintext).toMatch(/^[A-Za-z0-9_-]{43}$/);
198
+ });
199
+ });