@intentius/chant 0.46.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 (183) 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/lint.d.ts.map +1 -1
  10. package/dist/cli/handlers/lifecycle.d.ts +1 -1
  11. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  12. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  13. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  14. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  15. package/dist/cli/plugins.d.ts +1 -1
  16. package/dist/cli/plugins.d.ts.map +1 -1
  17. package/dist/cli/reporters/stylish.d.ts +15 -1
  18. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  19. package/dist/components/auto-release.d.ts +4 -0
  20. package/dist/components/auto-release.d.ts.map +1 -1
  21. package/dist/components/starter-plugin.d.ts +2 -0
  22. package/dist/components/starter-plugin.d.ts.map +1 -1
  23. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  24. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  25. package/dist/components/verbs/index.d.ts +8 -0
  26. package/dist/components/verbs/index.d.ts.map +1 -1
  27. package/dist/components/verbs/r2-sync.d.ts +76 -0
  28. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  29. package/dist/components/verbs/wrangler.d.ts +108 -0
  30. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  31. package/dist/config.d.ts +26 -0
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/deep-observation.d.ts +14 -0
  34. package/dist/deep-observation.d.ts.map +1 -1
  35. package/dist/effect-receipt.d.ts +177 -0
  36. package/dist/effect-receipt.d.ts.map +1 -0
  37. package/dist/fold/subset.d.ts +15 -2
  38. package/dist/fold/subset.d.ts.map +1 -1
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/lexicon.d.ts +44 -3
  42. package/dist/lexicon.d.ts.map +1 -1
  43. package/dist/lifecycle/change-set.d.ts +33 -5
  44. package/dist/lifecycle/change-set.d.ts.map +1 -1
  45. package/dist/lifecycle/index.d.ts +2 -0
  46. package/dist/lifecycle/index.d.ts.map +1 -1
  47. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  48. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  49. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  50. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  51. package/dist/lifecycle/release-ledger.d.ts +20 -0
  52. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  53. package/dist/lifecycle/teardown.d.ts +6 -4
  54. package/dist/lifecycle/teardown.d.ts.map +1 -1
  55. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  56. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  57. package/dist/lint/knowledge-checks.d.ts +48 -0
  58. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  59. package/dist/lint/output-checks.d.ts +5 -0
  60. package/dist/lint/output-checks.d.ts.map +1 -0
  61. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  62. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  63. package/dist/lint/post-synth.d.ts +12 -0
  64. package/dist/lint/post-synth.d.ts.map +1 -1
  65. package/dist/lint/receipt-checks.d.ts +9 -0
  66. package/dist/lint/receipt-checks.d.ts.map +1 -0
  67. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  68. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  69. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  70. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  71. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  72. package/dist/lint/rules/index.d.ts +3 -1
  73. package/dist/lint/rules/index.d.ts.map +1 -1
  74. package/dist/okf-read.d.ts +78 -0
  75. package/dist/okf-read.d.ts.map +1 -0
  76. package/dist/op/builders.d.ts +98 -1
  77. package/dist/op/builders.d.ts.map +1 -1
  78. package/dist/op/index.d.ts +4 -2
  79. package/dist/op/index.d.ts.map +1 -1
  80. package/dist/op/local-executor.d.ts +2 -1
  81. package/dist/op/local-executor.d.ts.map +1 -1
  82. package/dist/op/receipt-store.d.ts +138 -0
  83. package/dist/op/receipt-store.d.ts.map +1 -0
  84. package/dist/op/types.d.ts +31 -1
  85. package/dist/op/types.d.ts.map +1 -1
  86. package/dist/secret-materialization.d.ts +138 -0
  87. package/dist/secret-materialization.d.ts.map +1 -0
  88. package/dist/secret-provenance.d.ts +218 -0
  89. package/dist/secret-provenance.d.ts.map +1 -0
  90. package/dist/serializer.d.ts +11 -0
  91. package/dist/serializer.d.ts.map +1 -1
  92. package/dist/yaml.d.ts.map +1 -1
  93. package/package.json +4 -1
  94. package/src/audit/core.test.ts +57 -0
  95. package/src/audit/core.ts +0 -0
  96. package/src/audit/detect-bundle.test.ts +1 -1
  97. package/src/audit/discover.test.ts +24 -0
  98. package/src/audit/discover.ts +11 -2
  99. package/src/audit/rules-doc.ts +11 -1
  100. package/src/build.test.ts +41 -0
  101. package/src/build.ts +34 -6
  102. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  103. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  104. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  105. package/src/cli/commands/audit.test.ts +53 -0
  106. package/src/cli/commands/audit.ts +1 -1
  107. package/src/cli/commands/build.test.ts +80 -0
  108. package/src/cli/commands/build.ts +106 -8
  109. package/src/cli/commands/lint.ts +15 -3
  110. package/src/cli/handlers/explain.test.ts +70 -1
  111. package/src/cli/handlers/graph.ts +2 -2
  112. package/src/cli/handlers/lifecycle.test.ts +115 -1
  113. package/src/cli/handlers/lifecycle.ts +84 -11
  114. package/src/cli/mcp/resource-handlers.ts +38 -1
  115. package/src/cli/mcp/server.test.ts +58 -1
  116. package/src/cli/mcp/tools/explain.ts +51 -2
  117. package/src/cli/plugins.ts +4 -2
  118. package/src/cli/reporters/stylish.test.ts +154 -0
  119. package/src/cli/reporters/stylish.ts +154 -33
  120. package/src/components/auto-release.ts +6 -0
  121. package/src/components/registry.test.ts +7 -2
  122. package/src/components/starter-plugin.ts +17 -0
  123. package/src/components/verbs/ensure-secret.test.ts +130 -0
  124. package/src/components/verbs/ensure-secret.ts +79 -0
  125. package/src/components/verbs/index.ts +8 -0
  126. package/src/components/verbs/r2-sync.test.ts +107 -0
  127. package/src/components/verbs/r2-sync.ts +124 -0
  128. package/src/components/verbs/wrangler.test.ts +170 -0
  129. package/src/components/verbs/wrangler.ts +241 -0
  130. package/src/config.test.ts +15 -0
  131. package/src/config.ts +30 -0
  132. package/src/deep-observation.test.ts +19 -0
  133. package/src/deep-observation.ts +17 -0
  134. package/src/effect-receipt-exclusion.test.ts +190 -0
  135. package/src/effect-receipt.test.ts +419 -0
  136. package/src/effect-receipt.ts +412 -0
  137. package/src/fold/subset.test.ts +26 -0
  138. package/src/fold/subset.ts +45 -19
  139. package/src/index.ts +4 -0
  140. package/src/lexicon.ts +48 -3
  141. package/src/lifecycle/change-set.ts +46 -7
  142. package/src/lifecycle/index.ts +2 -0
  143. package/src/lifecycle/observation-baseline.test.ts +46 -0
  144. package/src/lifecycle/observation-baseline.ts +33 -1
  145. package/src/lifecycle/receipt-plan.test.ts +250 -0
  146. package/src/lifecycle/receipt-plan.ts +249 -0
  147. package/src/lifecycle/release-ledger.ts +20 -0
  148. package/src/lifecycle/teardown.test.ts +31 -0
  149. package/src/lifecycle/teardown.ts +6 -4
  150. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  151. package/src/lifecycle/unobserved-gate.ts +102 -0
  152. package/src/lint/knowledge-checks.test.ts +80 -0
  153. package/src/lint/knowledge-checks.ts +74 -0
  154. package/src/lint/output-checks.test.ts +85 -0
  155. package/src/lint/output-checks.ts +99 -0
  156. package/src/lint/pipeline-change-gate.test.ts +144 -0
  157. package/src/lint/pipeline-change-gate.ts +153 -0
  158. package/src/lint/post-synth.ts +15 -0
  159. package/src/lint/receipt-checks.test.ts +101 -0
  160. package/src/lint/receipt-checks.ts +93 -0
  161. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  162. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  163. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  164. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  165. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  166. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  167. package/src/lint/rules/index.ts +7 -1
  168. package/src/okf-read.test.ts +149 -0
  169. package/src/okf-read.ts +197 -0
  170. package/src/op/builders.ts +139 -1
  171. package/src/op/effect-step.test.ts +311 -0
  172. package/src/op/index.ts +10 -3
  173. package/src/op/local-executor.ts +172 -25
  174. package/src/op/op.test.ts +25 -2
  175. package/src/op/receipt-store.ts +211 -0
  176. package/src/op/types.ts +33 -1
  177. package/src/secret-materialization.test.ts +199 -0
  178. package/src/secret-materialization.ts +235 -0
  179. package/src/secret-provenance.test.ts +388 -0
  180. package/src/secret-provenance.ts +475 -0
  181. package/src/serializer.ts +12 -0
  182. package/src/yaml.test.ts +88 -0
  183. package/src/yaml.ts +76 -6
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Effect receipts (chant #1831, epic #1703).
3
+ *
4
+ * A RECEIPT is the declared witness that an out-of-band effect (a migration,
5
+ * a seed job, a one-shot bootstrap) has run — declared, diffed, and observed
6
+ * like any resource, but observe-only to the generic apply path: the
7
+ * `effect()` step (#1834) is the sole writer, on success, last. Anything
8
+ * else silently converts at-least-once into never (epic decision log, item
9
+ * 3). That write-exclusion is #1832's enforcement; what THIS module provides
10
+ * is the recognition marker that makes it possible: core code (lint, plan,
11
+ * apply) identifies receipts via {@link EFFECT_RECEIPT_MARKER} without any
12
+ * lexicon knowledge, while per-lexicon rows (#1835's `AWS::SSM::Parameter`)
13
+ * materialize them as real resources. Unlike a secret provenance declaration
14
+ * (./secret-provenance.ts), a receipt DOES serialize — the marker identifies,
15
+ * it does not exclude.
16
+ *
17
+ * The resolution split (epic decision log, item 5):
18
+ *
19
+ * - STATIC inputs hash at synthesis, in the serializer — a fully static
20
+ * receipt's expectation is already known when the template is written
21
+ * ({@link receiptExpectation}).
22
+ * - REFERENCE inputs (attr-refs and other intrinsics — deploy-time values)
23
+ * are recorded in placeholder form at synthesis and resolve in the plan
24
+ * engine (#1832) and again in the effect step (#1834), via
25
+ * {@link resolveReceiptExpectation}. Synthesis resolves NOTHING:
26
+ * {@link receiptExpectation} refuses a hash-flavor receipt that still
27
+ * carries references rather than hashing a placeholder.
28
+ * - Build-time `params.*` are not references here: they fold to literals
29
+ * before discovery ever runs (../params.ts), so by the time this factory
30
+ * sees them they are static.
31
+ *
32
+ * Nothing in this module reads live state. Every function is pure over the
33
+ * declaration and, for resolution, the caller-supplied resolver.
34
+ *
35
+ * Hashing is JCS-style canonical JSON (RFC 8785 shape: sorted keys, standard
36
+ * ECMAScript number/string encoding — implemented minimally here, no
37
+ * dependency) digested with sha256 ({@link canonicalJson},
38
+ * `sha256:<hex>` like the build-ledger digests in ./lifecycle/build-ledger.ts).
39
+ */
40
+
41
+ import { createHash } from "node:crypto";
42
+ import { DECLARABLE_MARKER, type Declarable } from "./declarable";
43
+ import { isIntrinsic, type Intrinsic } from "./intrinsic";
44
+
45
+ /** The closed union of receipt flavors. */
46
+ export type EffectReceiptFlavor = "existence" | "hash";
47
+
48
+ /** Every receipt flavor, for exhaustiveness checks. */
49
+ export const EFFECT_RECEIPT_FLAVORS: readonly EffectReceiptFlavor[] = ["existence", "hash"];
50
+
51
+ /** Marker symbol identifying an effect receipt. `Symbol.for` so it survives
52
+ * the entity-wire codec (./discovery/entity-wire-codec.ts) and so a lexicon
53
+ * row can stamp the SAME symbol on its materialized resource — core's lint
54
+ * (#1833), plan (#1832), and apply write-exclusion recognize receipts through
55
+ * this marker alone, lexicon-independently. */
56
+ export const EFFECT_RECEIPT_MARKER = Symbol.for("chant.effect-receipt");
57
+
58
+ /** The `entityType` the core factory stamps. Lexicon materialization rows
59
+ * (#1835) use their own entityType and carry the marker instead. */
60
+ export const EFFECT_RECEIPT_ENTITY_TYPE = "Chant::EffectReceipt";
61
+
62
+ /**
63
+ * The expected stored value of an `existence`-flavor receipt: a fixed marker
64
+ * constant, the same for every existence receipt. Present-and-equal means the
65
+ * effect has run; anything else renders an effect-will-fire row (#1832).
66
+ */
67
+ export const EXISTENCE_EXPECTATION = "chant.effect-receipt:exists";
68
+
69
+ /**
70
+ * An effect receipt declaration — a Declarable, so discovery collects it,
71
+ * `chant list` shows it, and (unlike a secret declaration) serialization
72
+ * keeps it: a lexicon row turns it into a real, observable resource.
73
+ */
74
+ export interface EffectReceiptDeclaration extends Declarable {
75
+ readonly [EFFECT_RECEIPT_MARKER]: true;
76
+ /** `"chant"` for the plain factory; a lexicon row (#1835) declares its own. */
77
+ readonly lexicon: string;
78
+ /** The core factory stamps EFFECT_RECEIPT_ENTITY_TYPE; a lexicon row uses its materialized kind. */
79
+ readonly entityType: string;
80
+ /** The receipt's own name (the export-level identity of the witness). */
81
+ readonly name: string;
82
+ /** The effect this receipt witnesses — the identity the `effect()` step
83
+ * (#1834) and the receipt path (#1835) key on. */
84
+ readonly effect: string;
85
+ /** How the receipt is compared: mere presence, or a digest of the inputs. */
86
+ readonly flavor: EffectReceiptFlavor;
87
+ /**
88
+ * The effect's inputs as recorded at synthesis: static values verbatim,
89
+ * references (intrinsics) kept in placeholder form — never resolved here.
90
+ */
91
+ readonly inputs: Readonly<Record<string, unknown>>;
92
+ }
93
+
94
+ /** Factory options for {@link EffectReceipt}. */
95
+ export interface EffectReceiptOptions {
96
+ /** The effect this receipt witnesses. Non-empty. */
97
+ readonly effect: string;
98
+ /** `existence` — presence is the witness; `hash` — a digest of the inputs
99
+ * is, so changed inputs re-propose the fire. */
100
+ readonly flavor: EffectReceiptFlavor;
101
+ /** The effect's inputs. Static values hash at synthesis; intrinsic values
102
+ * (attr-refs, deploy-time references) resolve at plan and at run. */
103
+ readonly inputs?: Record<string, unknown>;
104
+ }
105
+
106
+ /**
107
+ * Declare an effect receipt. The returned object is a locked Declarable:
108
+ * declared fields are immutable, plain-data input structures are frozen
109
+ * (intrinsics are left live — discovery still stamps logical names onto
110
+ * attr-refs), and the top-level object stays extensible for discovery's own
111
+ * symbol-keyed metadata.
112
+ */
113
+ export function EffectReceipt(name: string, options: EffectReceiptOptions): EffectReceiptDeclaration {
114
+ if (typeof name !== "string" || name.length === 0) {
115
+ throw new Error("EffectReceipt: `name` must be a non-empty string");
116
+ }
117
+ if (typeof options?.effect !== "string" || options.effect.length === 0) {
118
+ throw new Error(`EffectReceipt("${name}"): \`effect\` must be a non-empty string`);
119
+ }
120
+ if (!EFFECT_RECEIPT_FLAVORS.includes(options.flavor)) {
121
+ throw new Error(
122
+ `EffectReceipt("${name}"): unknown flavor "${String(options.flavor)}" — ` +
123
+ `expected one of ${EFFECT_RECEIPT_FLAVORS.join(", ")}`,
124
+ );
125
+ }
126
+ if (options.inputs !== undefined && (typeof options.inputs !== "object" || options.inputs === null || Array.isArray(options.inputs))) {
127
+ throw new Error(`EffectReceipt("${name}"): \`inputs\` must be a plain object when present`);
128
+ }
129
+
130
+ const inputs = deepFreezeStatic({ ...(options.inputs ?? {}) }) as Readonly<Record<string, unknown>>;
131
+
132
+ const decl: EffectReceiptDeclaration = {
133
+ [DECLARABLE_MARKER]: true,
134
+ [EFFECT_RECEIPT_MARKER]: true,
135
+ lexicon: "chant",
136
+ entityType: EFFECT_RECEIPT_ENTITY_TYPE,
137
+ name,
138
+ effect: options.effect,
139
+ flavor: options.flavor,
140
+ inputs,
141
+ };
142
+ // Declared fields immutable, object extensible — same shape secret
143
+ // declarations use (./secret-provenance.ts's lockDeclaredFields).
144
+ for (const key of Object.keys(decl)) {
145
+ Object.defineProperty(decl, key, { writable: false, configurable: false });
146
+ }
147
+ return decl;
148
+ }
149
+
150
+ /** Freeze plain objects and arrays in place, leaving intrinsics (and any
151
+ * other class instance) untouched — discovery mutates AttrRefs when it
152
+ * assigns logical names. */
153
+ function deepFreezeStatic(value: unknown, seen: Set<object> = new Set()): unknown {
154
+ if (typeof value !== "object" || value === null || isIntrinsic(value)) return value;
155
+ if (seen.has(value)) return value;
156
+ seen.add(value);
157
+ if (Array.isArray(value)) {
158
+ for (const el of value) deepFreezeStatic(el, seen);
159
+ return Object.freeze(value);
160
+ }
161
+ if (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) {
162
+ for (const el of Object.values(value)) deepFreezeStatic(el, seen);
163
+ return Object.freeze(value);
164
+ }
165
+ return value;
166
+ }
167
+
168
+ /** Type guard for an effect receipt — the recognition read core's guards use.
169
+ * True for the core declaration AND for any lexicon-materialized resource
170
+ * that carries the marker. */
171
+ export function isEffectReceipt(value: unknown): value is EffectReceiptDeclaration {
172
+ return (
173
+ typeof value === "object" &&
174
+ value !== null &&
175
+ EFFECT_RECEIPT_MARKER in value &&
176
+ (value as Record<symbol, unknown>)[EFFECT_RECEIPT_MARKER] === true
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Extract the effect receipts from a discovered entity map — the read surface
182
+ * for lint (#1833), the plan engine (#1832), and the apply write-exclusion.
183
+ * Keyed by entity name (export name), the same key `DiscoveryResult.entities`
184
+ * uses.
185
+ */
186
+ export function collectEffectReceipts(
187
+ entities: ReadonlyMap<string, Declarable>,
188
+ ): Map<string, EffectReceiptDeclaration> {
189
+ const out = new Map<string, EffectReceiptDeclaration>();
190
+ for (const [name, entity] of entities) {
191
+ if (isEffectReceipt(entity)) out.set(name, entity);
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * Split an entity map into the apply-bound set and the receipts (#1832).
198
+ *
199
+ * The write-exclusion seam: receipts are observe-only to the generic apply
200
+ * path — the `effect()` step is the sole writer (epic #1703, decision 3), and
201
+ * a receipt the generic apply stamped would silently convert at-least-once
202
+ * into never. The build calls this at serializer-input assembly, the one core
203
+ * choke point every applier's input flows through (appliers consume serialized
204
+ * build outputs), so no lexicon's serialized apply document ever contains a
205
+ * receipt and no applier's desired or prune set can. Receipts still reach the
206
+ * serializer — for visibility rendering outside the apply-bound document
207
+ * (#1835) — via `SerializeContext.receipts`, never in the entity map.
208
+ */
209
+ export function splitReceiptEntities(entities: ReadonlyMap<string, Declarable>): {
210
+ applyBound: Map<string, Declarable>;
211
+ receipts: Map<string, EffectReceiptDeclaration>;
212
+ } {
213
+ const applyBound = new Map<string, Declarable>();
214
+ const receipts = new Map<string, EffectReceiptDeclaration>();
215
+ for (const [name, entity] of entities) {
216
+ if (isEffectReceipt(entity)) receipts.set(name, entity);
217
+ else applyBound.set(name, entity);
218
+ }
219
+ return { applyBound, receipts };
220
+ }
221
+
222
+ // ─────────────────────────────────────────────────────────────────────────
223
+ // Canonical hashing — JCS-style canonical JSON + sha256.
224
+ // ─────────────────────────────────────────────────────────────────────────
225
+
226
+ /**
227
+ * JCS-style canonical JSON (the RFC 8785 shape, implemented minimally):
228
+ * object keys sorted by UTF-16 code units, numbers and strings in standard
229
+ * ECMAScript `JSON.stringify` encoding, no insignificant whitespace.
230
+ * `toJSON()` is honored the way `JSON.stringify` honors it — which is what
231
+ * puts an intrinsic's PLACEHOLDER envelope (e.g. an attr-ref's
232
+ * `{"__attrRef":{...}}`) into the canonical form rather than a resolved
233
+ * value. Non-representable values (undefined outside an object property,
234
+ * functions, symbols, bigints, non-finite numbers, cycles) throw — a hash
235
+ * input silently coerced is a wrong expectation.
236
+ */
237
+ export function canonicalJson(value: unknown): string {
238
+ const out = encodeCanonical(value, "$", new Set());
239
+ if (out === undefined) {
240
+ throw new Error(`canonicalJson: value at $ is not representable in JSON`);
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /** Returns undefined only for values JSON.stringify would drop as an object
246
+ * property (undefined); throws for everything else non-representable. */
247
+ function encodeCanonical(value: unknown, path: string, seen: Set<object>): string | undefined {
248
+ // toJSON first, like JSON.stringify — once per node.
249
+ if (value !== null && (typeof value === "object" || typeof value === "function")) {
250
+ const toJSON = (value as { toJSON?: unknown }).toJSON;
251
+ if (typeof toJSON === "function") {
252
+ value = (toJSON as () => unknown).call(value);
253
+ }
254
+ }
255
+ if (value === undefined) return undefined;
256
+ if (value === null) return "null";
257
+ switch (typeof value) {
258
+ case "boolean":
259
+ return value ? "true" : "false";
260
+ case "number":
261
+ if (!Number.isFinite(value)) {
262
+ throw new Error(`canonicalJson: non-finite number at ${path}`);
263
+ }
264
+ return String(value); // ECMAScript ToString — what JCS specifies.
265
+ case "string":
266
+ return JSON.stringify(value);
267
+ case "bigint":
268
+ throw new Error(`canonicalJson: bigint at ${path} is not representable in JSON`);
269
+ case "function":
270
+ case "symbol":
271
+ throw new Error(`canonicalJson: ${typeof value} at ${path} is not representable in JSON`);
272
+ }
273
+ const obj = value as object;
274
+ if (seen.has(obj)) {
275
+ throw new Error(`canonicalJson: circular structure at ${path}`);
276
+ }
277
+ seen.add(obj);
278
+ try {
279
+ if (Array.isArray(obj)) {
280
+ const parts = obj.map((el, i) => encodeCanonical(el, `${path}[${i}]`, seen) ?? "null");
281
+ return `[${parts.join(",")}]`;
282
+ }
283
+ const keys = Object.keys(obj).sort();
284
+ const parts: string[] = [];
285
+ for (const key of keys) {
286
+ const encoded = encodeCanonical((obj as Record<string, unknown>)[key], `${path}.${key}`, seen);
287
+ if (encoded !== undefined) parts.push(`${JSON.stringify(key)}:${encoded}`);
288
+ }
289
+ return `{${parts.join(",")}}`;
290
+ } finally {
291
+ seen.delete(obj);
292
+ }
293
+ }
294
+
295
+ function sha256Digest(canonical: string): string {
296
+ return `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`;
297
+ }
298
+
299
+ // ─────────────────────────────────────────────────────────────────────────
300
+ // The resolution split.
301
+ // ─────────────────────────────────────────────────────────────────────────
302
+
303
+ /** The paths (dot/bracket, rooted at `inputs`) of every reference (intrinsic)
304
+ * input still unresolved on the receipt. Empty means the receipt is fully
305
+ * static and {@link receiptExpectation} can stamp its digest at synthesis. */
306
+ export function referenceInputPaths(receipt: EffectReceiptDeclaration): string[] {
307
+ const paths: string[] = [];
308
+ collectReferencePaths(receipt.inputs, "inputs", paths, new Set());
309
+ return paths;
310
+ }
311
+
312
+ function collectReferencePaths(value: unknown, path: string, out: string[], seen: Set<object>): void {
313
+ if (typeof value !== "object" || value === null) return;
314
+ if (isIntrinsic(value)) {
315
+ out.push(path);
316
+ return;
317
+ }
318
+ if (seen.has(value)) return;
319
+ seen.add(value);
320
+ if (Array.isArray(value)) {
321
+ value.forEach((el, i) => collectReferencePaths(el, `${path}[${i}]`, out, seen));
322
+ return;
323
+ }
324
+ for (const [key, el] of Object.entries(value)) {
325
+ collectReferencePaths(el, `${path}.${key}`, out, seen);
326
+ }
327
+ }
328
+
329
+ /**
330
+ * The receipt's expected stored value, computable at synthesis:
331
+ *
332
+ * - `existence` → {@link EXISTENCE_EXPECTATION}, always.
333
+ * - `hash` → `sha256:<hex>` over the canonical JSON of
334
+ * `{ effect, inputs }` — the effect name is bound into the digest so the
335
+ * same inputs under a different effect never collide.
336
+ *
337
+ * A hash-flavor receipt that still carries reference inputs THROWS instead
338
+ * of hashing placeholders: synthesis resolves nothing (epic decision log,
339
+ * item 5). The plan engine and the effect step get the digest through
340
+ * {@link resolveReceiptExpectation}.
341
+ */
342
+ export function receiptExpectation(receipt: EffectReceiptDeclaration): string {
343
+ if (receipt.flavor === "existence") return EXISTENCE_EXPECTATION;
344
+ const refs = referenceInputPaths(receipt);
345
+ if (refs.length > 0) {
346
+ throw new Error(
347
+ `receiptExpectation("${receipt.name}"): unresolved reference inputs at ${refs.join(", ")} — ` +
348
+ `references resolve at plan and at run, never at synthesis; ` +
349
+ `use resolveReceiptExpectation with a resolver`,
350
+ );
351
+ }
352
+ return sha256Digest(canonicalJson({ effect: receipt.effect, inputs: receipt.inputs }));
353
+ }
354
+
355
+ /**
356
+ * Resolves one reference input to its live value. `path` is the reference's
357
+ * location (as {@link referenceInputPaths} renders it). Must return a
358
+ * JSON-representable value — returning `undefined` or another intrinsic is
359
+ * an error, reported with the path.
360
+ */
361
+ export type ReceiptInputResolver = (ref: Intrinsic, path: string) => unknown;
362
+
363
+ /**
364
+ * The receipt's expected stored value with references resolved — the form
365
+ * the plan engine (#1832) compares against the live receipt and the effect
366
+ * step (#1834) writes on success. Deterministic over the receipt and the
367
+ * resolver's answers; resolves nothing itself and reads no live state (the
368
+ * resolver is the caller's seam to deploy-time values).
369
+ */
370
+ export function resolveReceiptExpectation(
371
+ receipt: EffectReceiptDeclaration,
372
+ resolver: ReceiptInputResolver,
373
+ ): string {
374
+ if (receipt.flavor === "existence") return EXISTENCE_EXPECTATION;
375
+ const resolved = resolveValue(receipt.inputs, "inputs", resolver, new Set());
376
+ return sha256Digest(canonicalJson({ effect: receipt.effect, inputs: resolved }));
377
+ }
378
+
379
+ function resolveValue(
380
+ value: unknown,
381
+ path: string,
382
+ resolver: ReceiptInputResolver,
383
+ seen: Set<object>,
384
+ ): unknown {
385
+ if (typeof value !== "object" || value === null) return value;
386
+ if (isIntrinsic(value)) {
387
+ const resolved = resolver(value, path);
388
+ if (resolved === undefined) {
389
+ throw new Error(`resolveReceiptExpectation: resolver returned undefined for reference at ${path}`);
390
+ }
391
+ if (isIntrinsic(resolved)) {
392
+ throw new Error(`resolveReceiptExpectation: resolver returned another reference for ${path}`);
393
+ }
394
+ return resolved;
395
+ }
396
+ if (seen.has(value)) {
397
+ throw new Error(`resolveReceiptExpectation: circular structure at ${path}`);
398
+ }
399
+ seen.add(value);
400
+ try {
401
+ if (Array.isArray(value)) {
402
+ return value.map((el, i) => resolveValue(el, `${path}[${i}]`, resolver, seen));
403
+ }
404
+ const out: Record<string, unknown> = {};
405
+ for (const [key, el] of Object.entries(value)) {
406
+ out[key] = resolveValue(el, `${path}.${key}`, resolver, seen);
407
+ }
408
+ return out;
409
+ } finally {
410
+ seen.delete(value);
411
+ }
412
+ }
@@ -258,6 +258,32 @@ describe("documented divergences — NOT unified by design (see subset.ts module
258
258
  expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
259
259
  });
260
260
 
261
+ test("composite step access: fold still rejects a call as a value; EVL001 accepts .step access (chant #1544, opt-in only)", () => {
262
+ // `Checkout({...}).step` — the single-action Composite() wrapper idiom
263
+ // every lexicon's own docs/examples embed inline inside a Job's
264
+ // `steps:` array. fold() has no way to invoke an arbitrary composite
265
+ // factory, so it still rejects this shape and falls the file back to
266
+ // the run path (documented, correct behavior — never an error). EVL001
267
+ // now opts INTO treating it as shape-valid via `allowCompositeStepAccess`
268
+ // — a caller of `findSubsetViolation` that does not pass that flag
269
+ // (fold(), or this very call below) is completely unaffected.
270
+ const source = `const bad = new Thing({ x: Checkout({}).step });`;
271
+ const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
272
+ const consts = collectConsts(sourceFile);
273
+ const badInit = consts.get("bad") as ts.NewExpression;
274
+
275
+ expect(() => foldResource(badInit, consts, [])).toThrow(FoldError);
276
+
277
+ // findSubsetViolation with no third argument (fold()'s own answer, and
278
+ // every pre-#1544 caller) is unchanged — still a violation.
279
+ const bareCallExpr = (badInit.arguments![0] as ts.ObjectLiteralExpression).properties[0];
280
+ expect(findSubsetViolation(bareCallExpr)).toBeDefined();
281
+
282
+ // EVL001 itself opts in and stops flagging it.
283
+ const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined };
284
+ expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
285
+ });
286
+
261
287
  test("nested resource construction: NO LONGER a divergence (chant #1169) — both fold and EVL001 accept it", () => {
262
288
  // This was the largest divergence in the table until #1169: a nested
263
289
  // `new Type()` as a property value could only fold to a {__resource, props}
@@ -231,18 +231,19 @@ export function unsupportedExpressionMessage(node: ts.Node): string {
231
231
  export function checkObjectMember(
232
232
  prop: ts.ObjectLiteralElementLike,
233
233
  intrinsics?: readonly IntrinsicDef[],
234
+ allowCompositeStepAccess?: boolean,
234
235
  ): SubsetViolation | undefined {
235
236
  if (ts.isPropertyAssignment(prop)) {
236
237
  if (!isLiteralPropertyName(prop.name)) {
237
238
  return violation(prop.name, computedPropertyNameMessage(prop.name));
238
239
  }
239
- return findSubsetViolation(prop.initializer, intrinsics);
240
+ return findSubsetViolation(prop.initializer, intrinsics, allowCompositeStepAccess);
240
241
  }
241
242
  if (ts.isShorthandPropertyAssignment(prop)) {
242
243
  return undefined;
243
244
  }
244
245
  if (ts.isSpreadAssignment(prop)) {
245
- return findSubsetViolation(prop.expression, intrinsics);
246
+ return findSubsetViolation(prop.expression, intrinsics, allowCompositeStepAccess);
246
247
  }
247
248
  return violation(prop, UNSUPPORTED_OBJECT_MEMBER_MESSAGE);
248
249
  }
@@ -251,9 +252,10 @@ export function checkObjectMember(
251
252
  function checkArrayElement(
252
253
  el: ts.Expression,
253
254
  intrinsics?: readonly IntrinsicDef[],
255
+ allowCompositeStepAccess?: boolean,
254
256
  ): SubsetViolation | undefined {
255
- if (ts.isSpreadElement(el)) return findSubsetViolation(el.expression, intrinsics);
256
- return findSubsetViolation(el, intrinsics);
257
+ if (ts.isSpreadElement(el)) return findSubsetViolation(el.expression, intrinsics, allowCompositeStepAccess);
258
+ return findSubsetViolation(el, intrinsics, allowCompositeStepAccess);
257
259
  }
258
260
 
259
261
  /**
@@ -265,10 +267,24 @@ function checkArrayElement(
265
267
  * environment-dependent exceptions). Returns the first (deepest,
266
268
  * `fold()`-evaluation-order) unsupported node, or `undefined` when `node`'s
267
269
  * whole shape is foldable.
270
+ *
271
+ * `allowCompositeStepAccess` (chant #1544) is an EVL-only, MORE-permissive
272
+ * divergence in the same direction as points 1/2/2b/2c in the module doc
273
+ * above: a call immediately narrowed to a single member access — the
274
+ * `Checkout({...}).step` shape every lexicon's single-action `Composite()`
275
+ * wrappers document as their embedded-inline idiom (see composites.mdx —
276
+ * "normally embedded inline as `checkout.step` inside a `Job`'s `steps`
277
+ * array") — is treated as shape-valid. `fold()` itself never passes this
278
+ * (it has no such parameter to pass; only EVL001 opts in), so `fold()`'s own
279
+ * behavior is unchanged: it still falls the file back to the run path for
280
+ * this shape, exactly as composites.mdx describes as the expected, correct
281
+ * outcome — not an error. What changes is that EVL001 stops treating that
282
+ * ordinary, documented fallback as a lint error.
268
283
  */
269
284
  export function findSubsetViolation(
270
285
  node: ts.Node,
271
286
  intrinsics?: readonly IntrinsicDef[],
287
+ allowCompositeStepAccess?: boolean,
272
288
  ): SubsetViolation | undefined {
273
289
  if (
274
290
  ts.isParenthesizedExpression(node) ||
@@ -276,7 +292,7 @@ export function findSubsetViolation(
276
292
  ts.isSatisfiesExpression(node) ||
277
293
  ts.isNonNullExpression(node)
278
294
  ) {
279
- return findSubsetViolation(node.expression, intrinsics);
295
+ return findSubsetViolation(node.expression, intrinsics, allowCompositeStepAccess);
280
296
  }
281
297
 
282
298
  if (
@@ -311,7 +327,7 @@ export function findSubsetViolation(
311
327
 
312
328
  if (ts.isTemplateExpression(node)) {
313
329
  for (const span of node.templateSpans) {
314
- const v = findSubsetViolation(span.expression, intrinsics);
330
+ const v = findSubsetViolation(span.expression, intrinsics, allowCompositeStepAccess);
315
331
  if (v) return v;
316
332
  }
317
333
  return undefined;
@@ -319,7 +335,7 @@ export function findSubsetViolation(
319
335
 
320
336
  if (ts.isObjectLiteralExpression(node)) {
321
337
  for (const prop of node.properties) {
322
- const v = checkObjectMember(prop, intrinsics);
338
+ const v = checkObjectMember(prop, intrinsics, allowCompositeStepAccess);
323
339
  if (v) return v;
324
340
  }
325
341
  return undefined;
@@ -327,28 +343,35 @@ export function findSubsetViolation(
327
343
 
328
344
  if (ts.isArrayLiteralExpression(node)) {
329
345
  for (const el of node.elements) {
330
- const v = checkArrayElement(el, intrinsics);
346
+ const v = checkArrayElement(el, intrinsics, allowCompositeStepAccess);
331
347
  if (v) return v;
332
348
  }
333
349
  return undefined;
334
350
  }
335
351
 
336
352
  if (ts.isPropertyAccessExpression(node)) {
337
- return findSubsetViolation(node.expression, intrinsics);
353
+ // chant #1544 — see this function's doc comment. `<call>(...).step`
354
+ // (any callee, any argument shape) is the composite-consumer idiom, EVL
355
+ // opt-in only: `fold()` never sets `allowCompositeStepAccess`, so it is
356
+ // unaffected and still falls back to the run path for this shape.
357
+ if (allowCompositeStepAccess && node.name.text === "step" && ts.isCallExpression(node.expression)) {
358
+ return undefined;
359
+ }
360
+ return findSubsetViolation(node.expression, intrinsics, allowCompositeStepAccess);
338
361
  }
339
362
 
340
363
  if (ts.isElementAccessExpression(node)) {
341
364
  if (!isLiteralElementKey(node.argumentExpression)) {
342
365
  return violation(node.argumentExpression, dynamicElementAccessMessage(node.argumentExpression), "EVL003");
343
366
  }
344
- return findSubsetViolation(node.expression, intrinsics);
367
+ return findSubsetViolation(node.expression, intrinsics, allowCompositeStepAccess);
345
368
  }
346
369
 
347
370
  if (ts.isPrefixUnaryExpression(node)) {
348
371
  if (!SUPPORTED_UNARY_OPERATORS.has(node.operator)) {
349
372
  return violation(node, UNSUPPORTED_UNARY_MESSAGE);
350
373
  }
351
- return findSubsetViolation(node.operand, intrinsics);
374
+ return findSubsetViolation(node.operand, intrinsics, allowCompositeStepAccess);
352
375
  }
353
376
 
354
377
  if (ts.isBinaryExpression(node)) {
@@ -358,15 +381,18 @@ export function findSubsetViolation(
358
381
  }
359
382
  // Flow-insensitive — see module doc: fold() short-circuits &&/||/?? and
360
383
  // only evaluates the taken side; EVL requires both sides shape-valid.
361
- return findSubsetViolation(node.left, intrinsics) ?? findSubsetViolation(node.right, intrinsics);
384
+ return (
385
+ findSubsetViolation(node.left, intrinsics, allowCompositeStepAccess) ??
386
+ findSubsetViolation(node.right, intrinsics, allowCompositeStepAccess)
387
+ );
362
388
  }
363
389
 
364
390
  if (ts.isConditionalExpression(node)) {
365
391
  // Flow-insensitive — see module doc: fold() only folds the taken branch.
366
392
  return (
367
- findSubsetViolation(node.condition, intrinsics) ??
368
- findSubsetViolation(node.whenTrue, intrinsics) ??
369
- findSubsetViolation(node.whenFalse, intrinsics)
393
+ findSubsetViolation(node.condition, intrinsics, allowCompositeStepAccess) ??
394
+ findSubsetViolation(node.whenTrue, intrinsics, allowCompositeStepAccess) ??
395
+ findSubsetViolation(node.whenFalse, intrinsics, allowCompositeStepAccess)
370
396
  );
371
397
  }
372
398
 
@@ -377,14 +403,14 @@ export function findSubsetViolation(
377
403
  // argument is classified on its own terms and nothing is rejected merely
378
404
  // for being in the "wrong" position.
379
405
  for (const arg of node.arguments ?? []) {
380
- const v = findSubsetViolation(arg, intrinsics);
406
+ const v = findSubsetViolation(arg, intrinsics, allowCompositeStepAccess);
381
407
  if (v) return v;
382
408
  }
383
409
  return undefined;
384
410
  }
385
411
 
386
412
  if (ts.isSpreadElement(node)) {
387
- return findSubsetViolation(node.expression, intrinsics);
413
+ return findSubsetViolation(node.expression, intrinsics, allowCompositeStepAccess);
388
414
  }
389
415
 
390
416
  if (ts.isCallExpression(node)) {
@@ -400,7 +426,7 @@ export function findSubsetViolation(
400
426
  // ever be MORE permissive than `fold()`, never stricter.
401
427
  if (ts.isIdentifier(node.expression) && isFoldableHelperName(node.expression.text)) {
402
428
  for (const arg of node.arguments) {
403
- const v = findSubsetViolation(arg, intrinsics);
429
+ const v = findSubsetViolation(arg, intrinsics, allowCompositeStepAccess);
404
430
  if (v) return v;
405
431
  }
406
432
  return undefined;
@@ -420,7 +446,7 @@ export function findSubsetViolation(
420
446
  intrinsics.some((i) => i.name === (node.expression as ts.Identifier).text && intrinsicCallFolds(i))
421
447
  ) {
422
448
  for (const arg of node.arguments) {
423
- const v = findSubsetViolation(arg, intrinsics);
449
+ const v = findSubsetViolation(arg, intrinsics, allowCompositeStepAccess);
424
450
  if (v) return v;
425
451
  }
426
452
  return undefined;
package/src/index.ts CHANGED
@@ -7,6 +7,9 @@
7
7
  export * from "./declarable";
8
8
  export * from "./composite";
9
9
  export * from "./provenance";
10
+ export * from "./secret-provenance";
11
+ export * from "./secret-materialization";
12
+ export * from "./effect-receipt";
10
13
  export * from "./build-params";
11
14
  export * from "./intrinsic";
12
15
  export * from "./types";
@@ -43,6 +46,7 @@ export * from "./lint/selectors";
43
46
  export * from "./lint/named-checks";
44
47
  export * from "./lint/post-synth";
45
48
  export * from "./lint/policy";
49
+ export * from "./lint/pipeline-change-gate";
46
50
  export * from "./lint/rule-loader";
47
51
  export * from "./lint/discover";
48
52
  export * from "./import/parser";